From 604f58ff07a31538a801b8312c17f235871d8715 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:44:55 -0400 Subject: [PATCH] feat: deprecate unused plugin-facing methods for removal in 3.7.0 (#610) 35 methods on CacheManager, DisplayManager, FontManager and PluginManager have no caller in core, the ledmatrix-plugins monorepo or the registry's third-party plugins, but plugins live elsewhere, so they stay for one release. src.deprecation.deprecated logs a warning (and emits a DeprecationWarning) the first time each is called in a process, naming the release that removes it. The list and replacements are in CHANGELOG and PLUGIN_API_REFERENCE's new Deprecated APIs section; a test pins the set. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 18 ++++++ docs/PLUGIN_API_REFERENCE.md | 21 +++++++ src/cache_manager.py | 14 +++++ src/deprecation.py | 47 ++++++++++++++++ src/display_manager.py | 8 +++ src/font_manager.py | 15 +++++ src/plugin_system/plugin_manager.py | 2 + test/test_deprecation.py | 85 +++++++++++++++++++++++++++++ 8 files changed, 210 insertions(+) create mode 100644 src/deprecation.py create mode 100644 test/test_deprecation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a3cd6452..9bb8c7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,24 @@ accepts both, but the store flags the old spelling as deprecated - `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for the display are written (`config/wifi_status.json`). +Deprecated, removed in 3.7.0 (each logs a warning on first use; see +`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in +core, the monorepo or the registry's third-party plugins calls them: + +- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`, + `get_sport_live_interval`, `get_sport_key_from_cache_key`, + `get_background_cached_data`, `is_background_data_available`, + `record_cache_hit`, `record_cache_miss`, `record_fetch_time`, + `get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`. +- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`, + `draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`. +- `FontManager`: `set_override`, `remove_override`, `get_overrides`, + `add_font`, `remove_font`, `validate_font`, `get_font_catalog`, + `get_available_fonts`, `get_size_tokens`, `get_performance_stats`, + `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, + `unregister_plugin_fonts`. +- `PluginManager.get_enabled_plugins`. + ## 3.5.0 New modules a plugin may import via `src.*` (floor on 3.5.0): diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index e8decf9c..eb1fa985 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -13,6 +13,7 @@ Complete API reference for plugin developers. This document describes all method - [Display Manager](#display-manager) - [Cache Manager](#cache-manager) - [Plugin Manager](#plugin-manager) +- [Deprecated APIs](#deprecated-apis) --- @@ -1074,3 +1075,23 @@ if "weather" in enabled_plugins: - [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide - [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples +--- + +## Deprecated APIs + +These still work in 3.6 but log a warning the first time they are called +(`journalctl -u ledmatrix` shows which one), and are **removed in 3.7.0**. +Nothing in core, the official plugins or the third-party plugins in the +registry calls them. + +| Object | Methods | Instead | +|---|---|---| +| `cache_manager` | `update_cache` | `set()` | +| `cache_manager` | `get_background_cached_data`, `is_background_data_available` | `get()` | +| `cache_manager` | `has_data_changed`, `setup_persistent_cache`, `get_sport_live_interval`, `get_sport_key_from_cache_key`, `record_cache_hit`, `record_cache_miss`, `record_fetch_time`, `get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats` | no replacement | +| `display_manager` | `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`, `draw_snow`, `draw_text_with_icons` | draw your own icons (the weather plugin ships `WeatherIcons`) | +| `display_manager` | `get_scrolling_stats` | no replacement | +| `font_manager` | `get_font_catalog`, `get_available_fonts` | read `font_catalog` | +| `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement | +| `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` | + diff --git a/src/cache_manager.py b/src/cache_manager.py index dc7cebdc..7d353d0f 100644 --- a/src/cache_manager.py +++ b/src/cache_manager.py @@ -38,6 +38,7 @@ from src.cache.disk_cache import DiskCache from src.cache.cache_strategy import CacheStrategy from src.cache.cache_metrics import CacheMetrics from src.logging_config import get_logger +from src.deprecation import deprecated # Canonical implementation lives in src.cache.disk_cache; re-exported here # because this module's docstring documents it and external code may import @@ -473,6 +474,7 @@ class CacheManager: """Get the cache directory path.""" return self.cache_dir + @deprecated("3.7.0") def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool: """Check if data has changed from cached version.""" cached_data = self.load_cache(data_type) @@ -578,6 +580,7 @@ class CacheManager: """Check if the US stock market is currently open.""" return self._strategy_component.is_market_open() + @deprecated("3.7.0", "use set()") def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool: """Update cache with new data.""" cache_data = { @@ -623,6 +626,7 @@ class CacheManager: cache_data['ttl'] = ttl self.save_cache(key, cache_data) + @deprecated("3.7.0") def setup_persistent_cache(self) -> bool: """ Set up a persistent cache directory with proper permissions. @@ -834,6 +838,7 @@ class CacheManager: else: self.logger.info("Disk cache cleanup thread stopped successfully") + @deprecated("3.7.0") def get_sport_live_interval(self, sport_key: str) -> int: """ Get the live_update_interval for a specific sport from config. @@ -855,6 +860,7 @@ class CacheManager: """ return self._strategy_component.get_data_type_from_key(key) + @deprecated("3.7.0") def get_sport_key_from_cache_key(self, key: str) -> Optional[str]: """ Extract sport key from cache key to determine appropriate live_update_interval. @@ -894,6 +900,7 @@ class CacheManager: data_type = self.get_data_type_from_key(key) return self.get_cached_data_with_strategy(key, data_type) + @deprecated("3.7.0", "use get()") def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]: """ Get data from background service cache with appropriate strategy. @@ -931,6 +938,7 @@ class CacheManager: self.record_cache_miss('background') return None + @deprecated("3.7.0", "use get()") def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool: """ Check if background service has fresh data available. @@ -960,26 +968,32 @@ class CacheManager: date_str = datetime.now(pytz.utc).strftime('%Y%m%d') return f"{sport}_{date_str}" + @deprecated("3.7.0") def record_cache_hit(self, cache_type: str = 'regular') -> None: """Record a cache hit for performance monitoring.""" self._metrics_component.record_hit(cache_type) + @deprecated("3.7.0") def record_cache_miss(self, cache_type: str = 'regular') -> None: """Record a cache miss for performance monitoring.""" self._metrics_component.record_miss(cache_type) + @deprecated("3.7.0") def record_fetch_time(self, duration: float) -> None: """Record fetch operation duration for performance monitoring.""" self._metrics_component.record_fetch_time(duration) + @deprecated("3.7.0") def get_cache_metrics(self) -> Dict[str, Any]: """Get current cache performance metrics.""" return self._metrics_component.get_metrics() + @deprecated("3.7.0") def log_cache_metrics(self) -> None: """Log current cache performance metrics.""" self._metrics_component.log_metrics() + @deprecated("3.7.0") def get_memory_cache_stats(self) -> Dict[str, Any]: """ Get statistics about the memory cache. diff --git a/src/deprecation.py b/src/deprecation.py new file mode 100644 index 00000000..e0f09e02 --- /dev/null +++ b/src/deprecation.py @@ -0,0 +1,47 @@ +"""Marking plugin-facing core APIs for removal. + +Plugins live in other repositories, so a method nothing in core calls may +still be called by a plugin nobody has checked. Such methods get +``@deprecated`` for one release before they are removed: the first call in a +process logs a warning naming the method and the release that removes it +(visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for +tooling. +""" + +import functools +import threading +import warnings +from typing import Callable, Optional, TypeVar + +from src.logging_config import get_logger + +logger = get_logger(__name__) + +F = TypeVar("F", bound=Callable) + +_warned = set() +_warned_lock = threading.Lock() + + +def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F], F]: + """Decorate a function or method that will be removed in ``removal``.""" + + def decorate(func: F) -> F: + message = f"{func.__qualname__}() is deprecated and will be removed in LEDMatrix {removal}" + if alternative: + message += f"; {alternative}" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + with _warned_lock: + first = func.__qualname__ not in _warned + _warned.add(func.__qualname__) + if first: + logger.warning(message) + warnings.warn(message, DeprecationWarning, stacklevel=2) + return func(*args, **kwargs) + + wrapper.__deprecated__ = message + return wrapper # type: ignore[return-value] + + return decorate diff --git a/src/display_manager.py b/src/display_manager.py index 6c01dc55..bcbdfeac 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -52,6 +52,7 @@ import zlib import freetype from src.common import snapshot_policy +from src.deprecation import deprecated from src.common.permission_utils import ( ensure_directory_permissions, ensure_file_permissions, @@ -1139,6 +1140,7 @@ class DisplayManager: except Exception as e: logger.error(f"Error drawing text: {e}", exc_info=True) + @deprecated("3.7.0") def draw_sun(self, x: int, y: int, size: int = 16): """Draw a sun icon using yellow circles and lines.""" center = (x + size//2, y + size//2) @@ -1159,6 +1161,7 @@ class DisplayManager: end_y = center[1] + ((radius + ray_length) * math.sin(rad)) self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2) + @deprecated("3.7.0") def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)): """Draw a cloud icon.""" # Draw multiple circles to form a cloud shape @@ -1166,6 +1169,7 @@ class DisplayManager: self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color) self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color) + @deprecated("3.7.0") def draw_rain(self, x: int, y: int, size: int = 16): """Draw rain icon with cloud and droplets.""" # Draw cloud @@ -1180,6 +1184,7 @@ class DisplayManager: self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size], fill=drop_color, width=2) + @deprecated("3.7.0") def draw_snow(self, x: int, y: int, size: int = 16): """Draw snow icon with cloud and snowflakes.""" # Draw cloud @@ -1300,6 +1305,7 @@ class DisplayManager: ] self.draw.polygon(bolt_points, fill=bolt_color) + @deprecated("3.7.0") def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None: """Draw a weather icon based on the condition.""" if condition.lower() in ['clear', 'sunny']: @@ -1316,6 +1322,7 @@ class DisplayManager: self._draw_sun(x, y, size) # Note: No update_display() here - let the caller handle the update + @deprecated("3.7.0") def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)): """Draw text with weather icons at specified positions.""" @@ -1600,6 +1607,7 @@ class DisplayManager: if removed_count > 0: logger.debug(f"Cleaned up {removed_count} expired deferred updates") + @deprecated("3.7.0") def get_scrolling_stats(self) -> dict: """Get current scrolling statistics for debugging.""" return { diff --git a/src/font_manager.py b/src/font_manager.py index 4df80f38..a6927c1e 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -40,6 +40,7 @@ from pathlib import Path from PIL import ImageFont from src.common.font_layout import load_truetype, resolve_asset_path from typing import Dict, Tuple, Optional, Union, Any, List +from src.deprecation import deprecated logger = logging.getLogger(__name__) @@ -167,6 +168,7 @@ class FontManager: logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px") + @deprecated("3.7.0") def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]: """ Get registered fonts for a specific manager or all managers. @@ -181,6 +183,7 @@ class FontManager: return self.manager_fonts.get(manager_id, {}) return self.manager_fonts.copy() + @deprecated("3.7.0") def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]: """Get all detected font usage across managers.""" return self.detected_fonts.copy() @@ -357,6 +360,7 @@ class FontManager: logger.error(f"Plugin font not found: {font_path}") return None + @deprecated("3.7.0") def unregister_plugin_fonts(self, plugin_id: str) -> bool: """Unregister all fonts for a plugin.""" try: @@ -393,6 +397,7 @@ class FontManager: for key in keys_to_remove: del self.font_cache[key] + @deprecated("3.7.0") def get_plugin_fonts(self, plugin_id: str) -> List[str]: """Get list of font families registered by a plugin.""" if plugin_id in self.plugin_font_catalogs: @@ -637,6 +642,7 @@ class FontManager: # ==================== Override Management ==================== + @deprecated("3.7.0") def set_override(self, element_key: str, family: str = None, size_px: int = None): """Set font override for a specific element.""" if element_key not in self.font_overrides: @@ -656,6 +662,7 @@ class FontManager: self.clear_cache() logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}") + @deprecated("3.7.0") def remove_override(self, element_key: str): """Remove font override for a specific element.""" if element_key in self.font_overrides: @@ -664,6 +671,7 @@ class FontManager: self.clear_cache() logger.info(f"Font override removed for {element_key}") + @deprecated("3.7.0") def get_overrides(self) -> Dict[str, Dict[str, str]]: """Get current font overrides.""" return self.font_overrides.copy() @@ -753,10 +761,12 @@ class FontManager: self.metrics_cache.clear() logger.info("Font cache cleared") + @deprecated("3.7.0", "read font_catalog") def get_available_fonts(self) -> Dict[str, str]: """Get dictionary of available font families and their paths.""" return self.font_catalog.copy() + @deprecated("3.7.0") def get_size_tokens(self) -> Dict[str, int]: """Get available size tokens.""" return self.size_tokens.copy() @@ -767,6 +777,7 @@ class FontManager: self.performance_stats[operation] = {} self.performance_stats[operation][font_key] = duration + @deprecated("3.7.0") def get_performance_stats(self) -> Dict[str, Any]: """Get performance statistics.""" uptime = time.time() - self.performance_stats["start_time"] @@ -788,10 +799,12 @@ class FontManager: "detected_fonts": len(self.detected_fonts) } + @deprecated("3.7.0", "read font_catalog") def get_font_catalog(self) -> Dict[str, str]: """Get the current font catalog.""" return self.font_catalog.copy() + @deprecated("3.7.0") def add_font(self, font_file_path: str, family_name: str) -> bool: """Add a new font to the catalog.""" try: @@ -824,6 +837,7 @@ class FontManager: logger.error(f"Error adding font {family_name}: {e}") return False + @deprecated("3.7.0") def remove_font(self, family_name: str) -> bool: """Remove a font from the catalog.""" try: @@ -851,6 +865,7 @@ class FontManager: logger.error(f"Error removing font {family_name}: {e}") return False + @deprecated("3.7.0") def validate_font(self, font_path: str) -> Dict[str, Any]: """Validate a font file.""" try: diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index 9e724e50..98b1ee6c 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -26,6 +26,7 @@ from src.plugin_system.schema_manager import ( CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans, ) from src.common.path_safety import safe_path_component +from src.deprecation import deprecated from src.common.permission_utils import ( ensure_directory_permissions, get_plugin_dir_mode @@ -698,6 +699,7 @@ class PluginManager: """ return self.plugins.copy() + @deprecated("3.7.0", "check each plugin's enabled flag in plugins") def get_enabled_plugins(self) -> List[str]: """ Get list of enabled plugin IDs. diff --git a/test/test_deprecation.py b/test/test_deprecation.py new file mode 100644 index 00000000..7c18fcd0 --- /dev/null +++ b/test/test_deprecation.py @@ -0,0 +1,85 @@ +"""@deprecated: plugin-facing APIs nothing in core, the monorepo or the +registry's third-party plugins calls, kept for one release with a warning.""" + +import logging +import os +import warnings + +import pytest + +os.environ.setdefault("EMULATOR", "true") + +from src import deprecation +from src.deprecation import deprecated + +#: Everything deprecated for removal in 3.7.0. Removing one of these, or +#: deprecating another, should be a deliberate edit here too. +DEPRECATED = { + "src.cache_manager.CacheManager": [ + "has_data_changed", "update_cache", "setup_persistent_cache", + "get_sport_live_interval", "get_sport_key_from_cache_key", + "get_background_cached_data", "is_background_data_available", + "record_cache_hit", "record_cache_miss", "record_fetch_time", + "get_cache_metrics", "log_cache_metrics", "get_memory_cache_stats", + ], + "src.display_manager.DisplayManager": [ + "draw_sun", "draw_cloud", "draw_rain", "draw_snow", "draw_weather_icon", + "draw_text_with_icons", "get_scrolling_stats", + ], + "src.font_manager.FontManager": [ + "get_manager_fonts", "get_detected_fonts", "unregister_plugin_fonts", + "get_plugin_fonts", "set_override", "remove_override", "get_overrides", + "get_available_fonts", "get_size_tokens", "get_performance_stats", + "get_font_catalog", "add_font", "remove_font", "validate_font", + ], + "src.plugin_system.plugin_manager.PluginManager": ["get_enabled_plugins"], +} + + +def _cls(path): + import importlib + module, name = path.rsplit(".", 1) + return getattr(importlib.import_module(module), name) + + +@pytest.mark.parametrize("path", sorted(DEPRECATED)) +def test_exactly_these_methods_are_deprecated(path): + cls = _cls(path) + marked = sorted(name for name, value in vars(cls).items() + if hasattr(value, "__deprecated__")) + assert marked == sorted(DEPRECATED[path]) + for name in marked: + assert "3.7.0" in getattr(cls, name).__deprecated__ + + +@pytest.fixture +def fresh(monkeypatch): + monkeypatch.setattr(deprecation, "_warned", set()) + + +def test_first_call_warns_and_logs_then_stays_quiet(fresh, caplog): + @deprecated("9.9.9", "use other()") + def old(x): + """Doc.""" + return x * 2 + + with warnings.catch_warnings(record=True) as caught, caplog.at_level(logging.WARNING): + warnings.simplefilter("always") + assert old(2) == 4 + assert old(3) == 6 + + assert [str(w.message) for w in caught] == [ + "test_first_call_warns_and_logs_then_stays_quiet..old() is deprecated " + "and will be removed in LEDMatrix 9.9.9; use other()"] + assert caught[0].category is DeprecationWarning + assert caught[0].filename == __file__ # points at the caller + assert sum("will be removed in LEDMatrix 9.9.9" in r.message for r in caplog.records) == 1 + assert old.__name__ == "old" and old.__doc__ == "Doc." + + +def test_decorated_methods_still_work(fresh): + from src.font_manager import FontManager + fm = FontManager({}) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert fm.get_font_catalog() == fm.font_catalog