diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e2da60..1e912ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,23 @@ accepts both, but the store flags the old spelling as deprecated this release rewrites. CI runs it against the monorepo's main as a report-only job ("Sports drift report"; never fails the build). +### Deprecations + +- The 35 plugin-facing methods deprecated in 3.5.0 are now removed in 3.8.0, + not 3.7.0: 3.7.0 shipped with all of them still in place, still warning + "will be removed in LEDMatrix 3.7.0". The warning, the docs and + `test/test_deprecation.py` now say 3.8.0. Nothing is removed yet. +- New `scripts/plugin_api_usage.py` lists every `@deprecated` core method and + scans core, the plugin monorepo and the registry's third-party plugins for + calls and overrides, telling real uses from unrelated methods of the same + name. Its output is `docs/DEPRECATIONS_3.8.md` (linked from + `docs/PLUGIN_API_REFERENCE.md#deprecated-apis`): 34 of the 35 are unused; + `CacheManager.get_memory_cache_stats` is still called by core's own + `log_memory_cache_stats()`, so it stays until that call migrates. +- `test/test_deprecation.py` fails while any `@deprecated` marker names a + release at or below `src.__version__`, so a release can no longer ship + warning about a removal it has already passed. + ## 3.7.0 Sports consolidation stage 3 (#672). No behaviour change: nothing in core @@ -286,7 +303,8 @@ New names in existing modules (a plugin using these must floor on 3.5.0): - `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`, and `FontManager.forget_manager_fonts()` is new (see Fonts). -Deprecated, removed in 3.7.0 (each logs a warning on first use; see +Deprecated for removal in 3.7.0, later moved to 3.8.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: diff --git a/docs/ADVANCED_PLUGIN_DEVELOPMENT.md b/docs/ADVANCED_PLUGIN_DEVELOPMENT.md index 20d5a792..4c56650f 100644 --- a/docs/ADVANCED_PLUGIN_DEVELOPMENT.md +++ b/docs/ADVANCED_PLUGIN_DEVELOPMENT.md @@ -27,7 +27,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`, `draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` — -are deprecated, removed in 3.7.0. Draw your own icons instead: render them +are deprecated, removed in 3.8.0. Draw your own icons instead: render them onto a PIL image and paste it onto `self.display_manager.image`, or ship icon images with the plugin. The weather plugin's `WeatherIcons` class is an example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis). @@ -194,7 +194,7 @@ def update(self): sport_key = "nhl" cache_key = f"{self.plugin_id}_{sport_key}_games" - # get_background_cached_data() is deprecated, removed in 3.7.0 — use get() + # get_background_cached_data() is deprecated, removed in 3.8.0 — use get() cached = self.cache_manager.get(cache_key, max_age=60) if cached: @@ -596,7 +596,7 @@ def update(self): ```python def update(self): - # get_enabled_plugins() is deprecated, removed in 3.7.0 — check the + # get_enabled_plugins() is deprecated, removed in 3.8.0 — check the # instance's `enabled` flag instead weather_plugin = self.plugin_manager.get_plugin("weather") if weather_plugin is not None and weather_plugin.enabled: diff --git a/docs/DEPRECATIONS_3.8.md b/docs/DEPRECATIONS_3.8.md new file mode 100644 index 00000000..09738fa1 --- /dev/null +++ b/docs/DEPRECATIONS_3.8.md @@ -0,0 +1,162 @@ +# Deprecated plugin APIs: usage scan + +Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)). + +- Scanned: 2026-09-29, core 3.7.0 +- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4327c2e4), 46 plugins +- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar) + +**35 deprecated methods: 35 unused, 0 still used, 0 need review.** + +Counted per plugin: a *call* is `.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files. + +| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict | +|---|---|---|---|---|---| +| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 | +| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 | +| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 | +| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | + +## Unused — safe to remove (35) + +`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `PluginManager.get_enabled_plugins` + +## Every hit + +File paths are relative to the plugin's directory (core: the repo root). + +| Method | Where | File:line | Kind | Code | +|---|---|---|---|---| +| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` | +| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` | +| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` | +| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` | +| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` | +| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` | +| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` | +| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` | +| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` | +| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` | +| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` | +| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` | +| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` | +| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` | +| `DisplayManager.draw_cloud` | core | src/display_manager.py:1356 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` | +| `DisplayManager.draw_cloud` | core | src/display_manager.py:1371 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` | +| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` | +| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` | +| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` | +| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` | +| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1515 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` | +| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` | +| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` | +| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:76 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` | +| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1265 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` | +| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1544 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` | +| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1635 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` | +| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` | +| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` | +| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:217 | test call | `assert fm.get_font_catalog() == fm.font_catalog` | + +## Sources scanned + +| Source | Group | Python files | Hits | +|---|---|---|---| +| core | core | 158 | 20 | +| core tests | core-tests | 316 | 14 | +| 7-segment-clock | monorepo | 3 | 0 | +| afl-scoreboard | monorepo | 34 | 0 | +| baseball-scoreboard | monorepo | 60 | 0 | +| basketball-scoreboard | monorepo | 48 | 0 | +| birdnet-go | monorepo | 2 | 0 | +| blackjack | monorepo | 7 | 0 | +| calendar | monorepo | 5 | 0 | +| christmas-countdown | monorepo | 3 | 0 | +| clock-simple | monorepo | 2 | 0 | +| countdown | monorepo | 5 | 0 | +| cricket-scoreboard | monorepo | 8 | 0 | +| f1-scoreboard | monorepo | 15 | 0 | +| fantasy-blitz | monorepo | 13 | 0 | +| football-scoreboard | monorepo | 73 | 0 | +| geochron | monorepo | 10 | 0 | +| hello-world | monorepo | 2 | 0 | +| hockey-scoreboard | monorepo | 51 | 0 | +| incoming-packages | monorepo | 8 | 0 | +| jellyfin-now-playing | monorepo | 4 | 0 | +| lacrosse-scoreboard | monorepo | 39 | 0 | +| ledmatrix-elections | monorepo | 12 | 0 | +| ledmatrix-flights | monorepo | 45 | 0 | +| ledmatrix-leaderboard | monorepo | 9 | 0 | +| ledmatrix-music | monorepo | 11 | 0 | +| ledmatrix-stocks | monorepo | 7 | 0 | +| ledmatrix-weather | monorepo | 14 | 6 | +| march-madness | monorepo | 4 | 0 | +| masters-tournament | monorepo | 10 | 0 | +| mqtt-notifications | monorepo | 4 | 0 | +| news | monorepo | 6 | 0 | +| nfl-draft | monorepo | 3 | 0 | +| nfl-stat-leaders | monorepo | 8 | 0 | +| nrl-scoreboard | monorepo | 29 | 0 | +| odds-ticker | monorepo | 9 | 0 | +| of-the-day | monorepo | 14 | 0 | +| olympics | monorepo | 16 | 0 | +| on-air | monorepo | 2 | 0 | +| pomodoro-timer | monorepo | 3 | 0 | +| soccer-scoreboard | monorepo | 46 | 0 | +| static-image | monorepo | 3 | 0 | +| stock-news | monorepo | 3 | 0 | +| text-display | monorepo | 4 | 0 | +| tide-display | monorepo | 3 | 0 | +| ufc-scoreboard | monorepo | 34 | 0 | +| web-ui-info | monorepo | 2 | 0 | +| youtube-stats | monorepo | 5 | 0 | +| f1-live | third-party | 10 | 0 | +| gif-player | third-party | 1 | 0 | +| pga-tour-leaderboard | third-party | 2 | 0 | +| plex-marquee | third-party | 1 | 0 | +| ledmatrix-dresden-departures | third-party | 1 | 0 | +| tidbyt-baseball-scoreboard | third-party | 2 | 0 | +| sleeper-fantasy | third-party | 1 | 0 | +| ledmatrix-nascar | third-party | 1 | 0 | + +## How to re-run + +```bash +# Clones the monorepo and each third-party plugin (depth 1) into a temp cache: +python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md +# Or scan a local monorepo checkout (read only) instead of cloning it: +python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins +``` + +Before removing a method in its release, re-run the scan against the current monorepo and registry: a plugin added since this file was generated may have started calling it. Remove only methods the fresh scan reports unused; move the rest to a later release (the test in `test/test_deprecation.py` fails while a marker names a release at or below `src.__version__`). diff --git a/docs/DEVELOPER_QUICK_REFERENCE.md b/docs/DEVELOPER_QUICK_REFERENCE.md index ed2c92e9..55092679 100644 --- a/docs/DEVELOPER_QUICK_REFERENCE.md +++ b/docs/DEVELOPER_QUICK_REFERENCE.md @@ -54,7 +54,7 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1) self.draw_fit("12:34", rows[0]) # largest crisp font that fits self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True) -# Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 — +# Weather icons: draw_weather_icon() is deprecated, removed in 3.8.0 — # draw your own icons (the weather plugin ships WeatherIcons) # Scrolling state @@ -78,7 +78,7 @@ strategy = cache_manager.get_cache_strategy("weather") ``` `get_background_cached_data()` (use `get()`) and `get_sport_live_interval()` -are deprecated, removed in 3.7.0. See +are deprecated, removed in 3.8.0. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis). ## Plugin Manager Quick Methods @@ -87,7 +87,7 @@ are deprecated, removed in 3.7.0. See # Get plugins plugin = plugin_manager.get_plugin("plugin-id") all_plugins = plugin_manager.get_all_plugins() -# get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled` +# get_enabled_plugins() is deprecated, removed in 3.8.0 — check `enabled` # on the entries in plugin_manager.plugins # Get info diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 3390b1ac..efea04dc 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -13,7 +13,7 @@ BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records which plugin uses which font so the web UI can show it. -Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they +Several methods are deprecated and will be removed in LEDMatrix 3.8.0; they log a warning on first call. They are listed in [Deprecated methods](#deprecated-methods) below, and the full set is pinned in [`test/test_deprecation.py`](../test/test_deprecation.py). @@ -209,7 +209,7 @@ Current methods: ### Deprecated methods -Removed in 3.7.0. Each logs a warning on first call. +Removed in 3.8.0. Each logs a warning on first call. | Method | Use instead | |---|---| diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index ec53f223..efe3663f 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -485,7 +485,7 @@ This is the canonical way to render arbitrary images. ### Weather Icons (deprecated) -> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin +> Deprecated, removed in 3.8.0 — draw your own icons (the weather plugin > ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis). - `draw_weather_icon(condition, x, y, size=16)` — icon for a condition @@ -587,7 +587,7 @@ Process any deferred updates if not currently scrolling. Called automatically by #### `get_scrolling_stats() -> dict` -> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis). Get current scrolling statistics for debugging. @@ -730,7 +730,7 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores") #### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]` -> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0 — use `get()`. See [Deprecated APIs](#deprecated-apis). Get background service cached data with sport-specific intervals. @@ -769,7 +769,7 @@ max_age = strategy['max_age'] # Get configured max age #### `get_sport_live_interval(sport_key: str) -> int` -> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis). Get the live_update_interval for a specific sport from config. @@ -795,7 +795,7 @@ Extract data type from cache key to determine appropriate cache strategy. #### `get_sport_key_from_cache_key(key: str) -> Optional[str]` -> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis). Extract sport key from cache key for sport-specific strategies. @@ -845,7 +845,7 @@ for file_info in files: #### `get_cache_metrics() -> Dict[str, Any]` -> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis). Get cache performance metrics. @@ -859,7 +859,7 @@ self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}") #### `get_memory_cache_stats() -> Dict[str, Any]` -> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis). Get memory cache statistics. @@ -905,7 +905,7 @@ for plugin_id, plugin in all_plugins.items(): #### `get_enabled_plugins() -> List[str]` -> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis). +> Deprecated, removed in 3.8.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis). Get list of enabled plugin IDs. @@ -1076,10 +1076,13 @@ if weather is not None and weather.enabled: ## 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. +These still work but log a warning the first time they are called +(`journalctl -u ledmatrix` shows which one), and are **removed in 3.8.0** +(first announced for 3.7.0, which shipped with them still in place). +[DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan behind that +decision: which of these the official plugins, the registry's third-party +plugins and core still call or override. Only methods that scan reports unused +are removed in 3.8.0; the rest stay until their callers migrate. | Object | Methods | Instead | |---|---|---| diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index d46de13b..67c4d63a 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -520,14 +520,14 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s `display_manager.image` (a PIL Image) and call `update_display()`; there is no `draw_image()` helper method. - `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons - (deprecated, removed in 3.7.0 — draw your own icons) + (deprecated, removed in 3.8.0 — draw your own icons) - `get_text_width()`, `get_font_height()` - Text utilities - `set_scrolling_state()`, `defer_update()` - Scrolling state management **Cache Manager** (`self.cache_manager`): - `get()`, `set()`, `delete()` - Basic caching - `get_cached_data_with_strategy()` - Advanced caching with strategies -- `get_background_cached_data()` - deprecated, removed in 3.7.0 — use `get()` +- `get_background_cached_data()` - deprecated, removed in 3.8.0 — use `get()` **Plugin Manager** (`self.plugin_manager`): - `get_plugin()`, `get_all_plugins()` - Access other plugins @@ -535,7 +535,7 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis) -table for everything removed in 3.7.0. +table for everything removed in 3.8.0. ## 3rd Party Plugin Development diff --git a/scripts/README.md b/scripts/README.md index 6c51c99f..3f1ad3e2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -34,6 +34,7 @@ display; **diagnostic** — run by hand on a Pi when something is wrong. | `frame_soak.py` | diagnostic | Soaks a running display and reports how often frames reached the panel late (docs/SCROLL_PERFORMANCE.md) | | `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) | | `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails | +| `plugin_api_usage.py` | dev-only | Scans core, the plugin monorepo and the registry's third-party plugins for callers of every `@deprecated` core method; its output is [docs/DEPRECATIONS_3.8.md](../docs/DEPRECATIONS_3.8.md) | | `prove_security.py` | keep | Security property checks run by pre-commit | | `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip | | `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG | diff --git a/scripts/plugin_api_usage.py b/scripts/plugin_api_usage.py new file mode 100644 index 00000000..0d7fd1eb --- /dev/null +++ b/scripts/plugin_api_usage.py @@ -0,0 +1,778 @@ +#!/usr/bin/env python3 +"""Who still calls or overrides the core methods marked ``@deprecated``? + +A deprecated plugin-facing method may only be removed once nothing uses it, +and plugins live in other repositories. This script answers the question for +every method ``src/deprecation.py``'s decorator marks in core: + +1. it lists the markers by parsing ``src/`` (so the list can never drift from + the code); +2. it scans, with the ``ast`` module, core itself (``src/``, + ``web_interface/``, ``scripts/``, the top-level ``*.py``; ``test/`` + separately), the official monorepo's ``plugins/`` directory, and every + third-party plugin the monorepo's ``plugins.json`` lists with its own repo + URL (shallow-cloned read-only into a cache directory); +3. it reports, per method and per plugin, the calls and overrides it found, + and a verdict: unused (safe to remove in the marker's release), still used + (keep or migrate those plugins first), or needs review. + +Matching is by method name, so it has to separate real uses from unrelated +methods that happen to share the name (the weather plugin's own ``draw_sun``, +say). Each hit is classified by what it is attached to: + +* **call** -- ``.name`` where the receiver is named like the owning + object (``self.cache_manager``, ``display_manager``, ``plugin_manager`` ..., + or a local alias assigned from one), or ``self``/``super()`` inside a class + that subclasses the owner. Attribute references that are not called + (``callback=cm.get_cache_metrics``) count too. +* **override** -- ``def name`` in a class that subclasses the owner. +* **review** -- ``.name`` where the receiver says nothing about its + type, or ``getattr(obj, "name")``. Possibly a real use; read the listed line. +* **unrelated** -- ``self.name`` inside a class that defines ``name`` itself + and does not subclass the owner, ``Klass.name`` where the same tree defines + ``Klass.name``, or ``def name`` in such a class: a name collision, not a use. +* **internal** -- a hit inside the body of another deprecated core method + (``draw_rain`` calling ``draw_cloud``): it keeps the method only as long as + that caller is kept. + +Only calls and overrides make a method "still used"; review hits make it +"needs review"; hits in test files are listed but never block removal (a test +that mocks a method does not need it to exist). + + python3 scripts/plugin_api_usage.py # clone everything, print Markdown + python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins + python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md + python3 scripts/plugin_api_usage.py --format json + +Nothing is ever written to the repositories it scans: the monorepo path is only +read, and clones live in ``--cache-dir``. +""" +from __future__ import annotations + +import argparse +import ast +import json +import os +import re +import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep +import sys +import tempfile +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +MONOREPO_URL = "https://github.com/ChuckBuilds/ledmatrix-plugins" +MONOREPO_SLUG = "chuckbuilds/ledmatrix-plugins" + +#: Receiver names that mean "this is the owning core object". Compared against +#: the last name in the receiver (``self.plugin_manager.cache_manager`` -> +#: ``cache_manager``), lower-cased with leading underscores stripped. +OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = { + "CacheManager": ("cache_manager", "cache_mgr", "cachemanager", "cache", "cm"), + "DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"), + "FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"), + "PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"), +} + +#: Directories never scanned (vendored environments, VCS metadata, caches). +SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "env", + "site-packages", ".tox", ".mypy_cache", ".pytest_cache"} + +CORE_DIRS = ("src", "web_interface", "scripts") +CORE_TEST_DIRS = ("test",) + + +# -------------------------------------------------------------------------- +# Markers + + +@dataclass +class Marker: + owner: str # class name, e.g. "CacheManager" + method: str + removal: str + alternative: Optional[str] + module: str # e.g. "src.cache_manager" + line: int + + @property + def key(self) -> str: + return f"{self.owner}.{self.method}" + + +def _decorator_name(node: ast.expr) -> Optional[str]: + target = node.func if isinstance(node, ast.Call) else node + if isinstance(target, ast.Name): + return target.id + if isinstance(target, ast.Attribute): + return target.attr + return None + + +def find_markers(core_root: Path) -> List[Marker]: + """Every ``@deprecated(...)`` method under ``core_root/src``.""" + markers: List[Marker] = [] + for path in sorted((core_root / "src").rglob("*.py")): + if path.name == "deprecation.py": + continue + tree = _parse(path) + if tree is None: + continue + module = ".".join(path.relative_to(core_root).with_suffix("").parts) + for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)): + for fn in cls.body: + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for dec in fn.decorator_list: + if _decorator_name(dec) != "deprecated" or not isinstance(dec, ast.Call): + continue + args = [a.value if isinstance(a, ast.Constant) else None for a in dec.args] + kw = {k.arg: k.value.value for k in dec.keywords + if isinstance(k.value, ast.Constant)} + removal = args[0] if args else kw.get("removal") + alternative = args[1] if len(args) > 1 else kw.get("alternative") + markers.append(Marker(cls.name, fn.name, str(removal), alternative, + module, fn.lineno)) + return markers + + +# -------------------------------------------------------------------------- +# Scanning + + +@dataclass +class Hit: + kind: str # call | override | review | unrelated | internal + path: str + line: int + code: str + test: bool + via: Optional[str] = None # internal: the deprecated core method it sits in + + +@dataclass +class Source: + """One plugin (or core) tree to scan.""" + name: str + group: str # core | core-tests | monorepo | third-party + root: Optional[Path] + error: Optional[str] = None + hits: Dict[str, List[Hit]] = field(default_factory=lambda: defaultdict(list)) + files: int = 0 # Python files scanned + + +def _parse(path: Path) -> Optional[ast.AST]: + try: + return ast.parse(path.read_text(encoding="utf-8", errors="replace"), str(path)) + except (SyntaxError, ValueError): + return None + + +def _iter_py(root: Path) -> Iterator[Path]: + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in filenames: + if name.endswith(".py"): + yield Path(dirpath) / name + + +def _is_test_path(rel: Path) -> bool: + parts = [p.lower() for p in rel.parts] + return (any(p in ("test", "tests") for p in parts[:-1]) + or parts[-1].startswith("test_") or parts[-1].endswith("_test.py") + or parts[-1] == "conftest.py") + + +def _terminal(node: ast.expr) -> Optional[str]: + """The last name in a receiver expression, or None if it has none.""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + if isinstance(node, ast.Call): + return _terminal(node.func) + if isinstance(node, ast.Subscript): + return _terminal(node.value) + return None + + +def _norm(name: Optional[str]) -> str: + return (name or "").lstrip("_").lower() + + +def _base_names(cls: ast.ClassDef) -> List[str]: + return [t for t in (_terminal(b) for b in cls.bases) if t] + + +class _Scanner(ast.NodeVisitor): + """Collect hits for every marked method name in one file.""" + + def __init__(self, markers: Dict[str, List[Marker]], lines: List[str], + rel: str, test: bool, core_modules: Dict[str, str], module: Optional[str], + local_definers: Dict[str, Set[str]], built: Dict[str, str]): + self.markers = markers # method name -> markers with that name + self.local_definers = local_definers # method name -> this tree's own classes/modules defining it + self.built = built # ``x``/``self.x`` -> class it was built from in this file + self.lines = lines + self.rel = rel + self.test = test + self.core_modules = core_modules # owner class -> defining module (core only) + self.module = module # this file's module when scanning core + self.classes: List[ast.ClassDef] = [] + self.scope: List[ast.AST] = [] # enclosing classes and functions + self.aliases: List[Dict[str, str]] = [{}] # local name -> owner class + self.out: Dict[str, List[Hit]] = defaultdict(list) + + # -- helpers + def _code(self, node: ast.AST) -> str: + line = self.lines[node.lineno - 1] if 0 < node.lineno <= len(self.lines) else "" + return line.strip()[:160] + + def _add(self, marker: Marker, kind: str, node: ast.AST) -> None: + via = self._inside_deprecated() + if via and kind != "unrelated": + # Only reached through another deprecated method: goes when that does. + kind = "internal" + self.out[marker.key].append(Hit(kind, self.rel, node.lineno, self._code(node), + self.test, via if kind == "internal" else None)) + + def _inside_deprecated(self) -> Optional[str]: + """``Owner.method`` when this node sits in a deprecated core method's body.""" + for i in range(len(self.scope) - 2, -1, -1): + cls, fn = self.scope[i], self.scope[i + 1] + if isinstance(cls, ast.ClassDef): + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + for m in self.markers.get(fn.name, ()): + if self._is_owner_class(cls, m.owner): + return m.key + return None + return None + + def _owner_for_receiver(self, name: Optional[str]) -> Optional[str]: + n = _norm(name) + for scope in reversed(self.aliases): + if name in scope: + return scope[name] + for owner, receivers in OWNER_RECEIVERS.items(): + if n in receivers: + return owner + return None + + def _is_owner_class(self, cls: ast.ClassDef, owner: str) -> bool: + """True for the real core class (only when scanning its own module).""" + return (self.module is not None and cls.name == owner + and self.core_modules.get(owner) == self.module) + + def _subclasses(self, cls: ast.ClassDef, owner: str) -> bool: + return owner in _base_names(cls) + + def _class_defines(self, cls: ast.ClassDef, name: str) -> bool: + return any(isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name + for n in cls.body) + + # -- scopes + def visit_ClassDef(self, node: ast.ClassDef) -> None: + for fn in node.body: + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name in self.markers: + for m in self.markers[fn.name]: + if self._is_owner_class(node, m.owner): + continue # the definition itself + kind = "override" if self._subclasses(node, m.owner) else "unrelated" + self._add(m, kind, fn) + self.classes.append(node) + self.scope.append(node) + self.generic_visit(node) + self.scope.pop() + self.classes.pop() + + def _visit_function(self, node) -> None: + self.aliases.append({}) + self.scope.append(node) + self.generic_visit(node) + self.scope.pop() + self.aliases.pop() + + visit_FunctionDef = _visit_function + visit_AsyncFunctionDef = _visit_function + + def visit_Assign(self, node: ast.Assign) -> None: + # ``dm = self.display_manager`` makes ``dm.draw_sun()`` a call. + owner = self._owner_for_receiver(_terminal(node.value)) + for target in node.targets: + if isinstance(target, ast.Name) and owner: + self.aliases[-1][target.id] = owner + self.generic_visit(node) + + # -- uses + def visit_Attribute(self, node: ast.Attribute) -> None: + if node.attr in self.markers: + for m in self.markers[node.attr]: + self._add(m, self._classify(node, m), node) + self.generic_visit(node) + + def _classify(self, node: ast.Attribute, m: Marker) -> str: + recv = node.value + cls = self.classes[-1] if self.classes else None + is_self = isinstance(recv, ast.Name) and recv.id in ("self", "cls") + is_super = (isinstance(recv, ast.Call) and isinstance(recv.func, ast.Name) + and recv.func.id == "super") + if is_self or is_super: + if cls is not None and (self._is_owner_class(cls, m.owner) or self._subclasses(cls, m.owner)): + return "call" + if cls is not None and self._class_defines(cls, m.method): + return "unrelated" + return "review" + name = _terminal(recv) + if self._owner_for_receiver(name) == m.owner: + return "call" + definers = self.local_definers.get(m.method, ()) + if name in definers or self.built.get(name or "") in definers: + # e.g. the weather plugin's WeatherIcons.draw_sun, or + # self._strategy_component = CacheStrategy(); ...get_sport_live_interval() + return "unrelated" + return "review" + + def visit_Call(self, node: ast.Call) -> None: + func = node.func + if (isinstance(func, ast.Name) and func.id in ("getattr", "hasattr", "setattr", "delattr") + and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant) + and node.args[1].value in self.markers): + for m in self.markers[node.args[1].value]: + owner = self._owner_for_receiver(_terminal(node.args[0])) + self._add(m, "call" if owner == m.owner else "review", node) + self.generic_visit(node) + + +def _built_from(tree: ast.AST) -> Dict[str, str]: + """``{name: Class}`` for every ``name = Class(...)`` / ``self.name = Class(...)``.""" + built: Dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call): + cls = _terminal(node.value.func) + for target in node.targets: + name = _terminal(target) if isinstance(target, (ast.Name, ast.Attribute)) else None + if name and cls: + built[name] = cls + return built + + +def scan_tree(source: Source, roots: Iterable[Path], base: Path, markers: List[Marker], + core: bool, test_override: Optional[bool] = None, + definer_roots: Iterable[Path] = ()) -> None: + by_name: Dict[str, List[Marker]] = defaultdict(list) + for m in markers: + by_name[m.method].append(m) + core_modules = {m.owner: m.module for m in markers} + owners = {m.owner for m in markers} + files: List[Tuple[Path, str, Optional[ast.AST]]] = [] + for root in roots: + if not root.exists(): + continue + for path in ([root] if root.is_file() else sorted(_iter_py(root))): + source.files += 1 + text = path.read_text(encoding="utf-8", errors="replace") + if any(name in text for name in by_name): + files.append((path, text, _parse(path))) + + # Classes (and modules) in this tree with their own method of a marked + # name, so ``WeatherIcons.draw_sun()`` is recognised as theirs. + local_definers: Dict[str, Set[str]] = defaultdict(set) + definer_files = list(files) + for root in definer_roots: + for path in (sorted(_iter_py(root)) if root.is_dir() else ()): + text = path.read_text(encoding="utf-8", errors="replace") + if any(name in text for name in by_name): + definer_files.append((path, text, _parse(path))) + for path, _, tree in definer_files: + for node in (tree.body if tree is not None else ()): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in by_name: + local_definers[node.name].add(path.stem) + for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)) if tree else (): + if cls.name in owners and core: + continue + if owners & set(_base_names(cls)): + continue + for fn in cls.body: + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name in by_name: + local_definers[fn.name].add(cls.name) + + for path, text, tree in files: + rel = path.relative_to(base) + test = _is_test_path(rel) if test_override is None else test_override + if tree is None: + # Unparseable (Python 2, a template ...): fall back to text, as review. + for no, line in enumerate(text.splitlines(), 1): + for name in by_name: + if re.search(rf"{re.escape(name)}", line): + for m in by_name[name]: + source.hits[m.key].append( + Hit("review", rel.as_posix(), no, line.strip()[:160], test)) + continue + module = ".".join(rel.with_suffix("").parts) if core else None + scanner = _Scanner(by_name, text.splitlines(), rel.as_posix(), test, + core_modules, module, local_definers, _built_from(tree)) + scanner.visit(tree) + for key, hits in scanner.out.items(): + source.hits[key].extend(hits) + + +# -------------------------------------------------------------------------- +# Fetching plugin trees (read-only) + + +def _git(*args: str, cwd: Optional[Path] = None) -> subprocess.CompletedProcess: + # Never stop to ask for credentials: a deleted or private plugin repo + # should be reported as not scanned, not hang the scan. + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + return subprocess.run( # nosec B603 B607 - list-form git argv, no shell; URLs follow "--" # nosemgrep + ["git", *args], cwd=cwd, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=300, env=env) + + +def _rmtree(path: Path) -> None: + """Delete a clone; git marks pack files read-only, which Windows refuses to delete.""" + import shutil + import stat + + def retry(func, target, _exc): + os.chmod(target, stat.S_IWRITE) + func(target) + + if sys.version_info >= (3, 12): + shutil.rmtree(path, onexc=retry) + else: + shutil.rmtree(path, onerror=retry) + + +def shallow_clone(url: str, branch: Optional[str], dest: Path, reuse: bool) -> Optional[str]: + """Clone ``url`` into ``dest`` (depth 1), replacing any earlier clone. + + Returns an error string, or None on success. + """ + if reuse and (dest / ".git").exists(): + return None + if dest.exists(): + _rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + args = ["-c", "core.longpaths=true", "clone", "--quiet", "--depth", "1"] + if branch: + args += ["--branch", branch] + # "--" ends option parsing: a registry URL starting with "-" (for example + # "--upload-pack=...") is then only ever a repository argument. + result = _git(*args, "--", url, str(dest)) + if result.returncode != 0 and branch: + result = _git("-c", "core.longpaths=true", "clone", "--quiet", "--depth", "1", + "--", url, str(dest)) + if result.returncode != 0: + lines = (result.stderr or result.stdout).strip().splitlines() + return lines[-1] if lines else "git clone failed" + return None + + +def _head(path: Path, branch: bool = True) -> str: + """Commit (and branch) of a checkout, via read-only git calls.""" + rev = _git("--no-optional-locks", "rev-parse", "--short=8", "HEAD", cwd=path) + if rev.returncode != 0: + return "unknown revision" + if not branch: + return rev.stdout.strip() + ref = _git("--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD", cwd=path) + return f"{ref.stdout.strip()} @ {rev.stdout.strip()}" + + +def _plugin_id(plugin_dir: Path) -> str: + try: + return json.loads((plugin_dir / "manifest.json").read_text(encoding="utf-8"))["id"] + except (OSError, ValueError, KeyError, TypeError): + return plugin_dir.name + + +def _is_monorepo(url: str) -> bool: + return MONOREPO_SLUG in url.lower().rstrip("/").removesuffix(".git") + + +# -------------------------------------------------------------------------- +# Report + + +def verdicts(markers: List[Marker], sources: List[Source]) -> Dict[str, Tuple[str, str]]: + """``{Owner.method: (status, text)}`` across every source. + + An *internal* hit (a call from inside another deprecated method) keeps a + method only while that caller is itself kept, so statuses are resolved + until they stop changing. + """ + failed = [s.name for s in sources if s.error] + status: Dict[str, Tuple[str, str]] = {} + for _ in range(len(markers) + 1): + changed = False + for m in markers: + used, review = [], [] + for s in sources: + live = [h for h in s.hits.get(m.key, []) if not h.test] + if any(h.kind in ("call", "override") for h in live) or any( + h.kind == "internal" and status.get(h.via, ("",))[0] == "used" + for h in live): + used.append(s.name) + elif any(h.kind == "review" for h in live) or any( + h.kind == "internal" and status.get(h.via, ("",))[0] == "review" + for h in live): + review.append(s.name) + if used: + new = ("used", f"still used by {', '.join(used)} — keep or migrate first") + elif review: + new = ("review", f"needs review: possible use in {', '.join(review)}") + elif failed: + new = ("unknown", f"not proven unused: {len(failed)} plugin(s) could not be scanned") + else: + new = ("unused", f"unused — safe to remove in {m.removal}") + if status.get(m.key) != new: + status[m.key] = new + changed = True + if not changed: + break + return status + + +def _counts(hits: List[Hit]) -> Dict[str, int]: + c: Dict[str, int] = defaultdict(int) + for h in hits: + c[("test " if h.test else "") + h.kind] += 1 + return c + + +def _usage_cell(marker: Marker, sources: List[Source], kinds: Tuple[str, ...]) -> str: + parts = [] + for s in sources: + c = _counts(s.hits.get(marker.key, [])) + bits = [f"{c[k]} {k}{'s' if c[k] != 1 else ''}" for k in kinds if c[k]] + if bits: + parts.append(f"{s.name} ({', '.join(bits)})") + return "; ".join(parts) or "—" + + +def render_markdown(markers: List[Marker], sources: List[Source], meta: Dict[str, str]) -> str: + out: List[str] = [] + w = out.append + w("# Deprecated plugin APIs: usage scan") + w("") + w("Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it " + "(see [How to re-run](#how-to-re-run)).") + w("") + w(f"- Scanned: {meta['date']}, core {meta['core_version']}") + w(f"- Monorepo: {meta['monorepo']}") + w(f"- Third-party plugins: {meta['third_party']}") + failed = [s for s in sources if s.error] + if failed: + w("- **Not scanned:** " + "; ".join(f"{s.name} ({s.error})" for s in failed)) + w("") + status = verdicts(markers, sources) + tally: Dict[str, int] = defaultdict(int) + for st, _ in status.values(): + tally[st] += 1 + w(f"**{len(markers)} deprecated methods: {tally['unused']} unused, " + f"{tally['used']} still used, {tally['review']} need review" + + (f", {tally['unknown']} not proven" if tally["unknown"] else "") + ".**") + w("") + w("Counted per plugin: a *call* is `.method` on an object named like " + "the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), " + "or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass " + "of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot " + "tell. *Internal* hits sit inside another deprecated core method and go with it. " + "*Unrelated* hits are a different class's own method with the same name " + "(a name collision), and never block removal; neither do hits in test files.") + w("") + w("| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |") + w("|---|---|---|---|---|---|") + core_sources = [s for s in sources if s.group in ("core", "core-tests")] + plugin_sources = [s for s in sources if s.group not in ("core", "core-tests")] + for m in markers: + core = _usage_cell(m, core_sources, ("call", "override", "review", "internal", + "test call", "test override", "test review", + "test internal")) + plugins = _usage_cell(m, plugin_sources, ("call", "override", "review")) + other = _usage_cell(m, plugin_sources, ("unrelated", "test call", "test override", + "test review", "test unrelated")) + w(f"| `{m.key}` | {m.removal} | {core} | {plugins} | {other} | {status[m.key][1]} |") + w("") + + groups = [("unused", "Unused — safe to remove"), ("used", "Still used — keep or migrate first"), + ("review", "Needs review"), ("unknown", "Not proven unused")] + for st, title in groups: + names = [m.key for m in markers if status[m.key][0] == st] + if names: + w(f"## {title} ({len(names)})") + w("") + w(", ".join(f"`{n}`" for n in names)) + w("") + + detail = [(m, s, h) for m in markers for s in sources + for h in s.hits.get(m.key, []) if h.kind != "unrelated" or not h.test] + if detail: + w("## Every hit") + w("") + w("File paths are relative to the plugin's directory (core: the repo root).") + w("") + w("| Method | Where | File:line | Kind | Code |") + w("|---|---|---|---|---|") + for m, s, h in detail: + kind = ("test " if h.test else "") + h.kind + if h.via: + kind += f" (in `{h.via}`)" + code = h.code.replace("|", "\\|").replace("`", "'") + w(f"| `{m.key}` | {s.name} | {h.path}:{h.line} | {kind} | `{code}` |") + w("") + + w("## Sources scanned") + w("") + w("| Source | Group | Python files | Hits |") + w("|---|---|---|---|") + for s in sources: + n = sum(len(v) for v in s.hits.values()) + files = f"not scanned: {s.error}" if s.error else str(s.files) + w(f"| {s.name} | {s.group} | {files} | {n} |") + w("") + + w("## How to re-run") + w("") + w("```bash") + w("# Clones the monorepo and each third-party plugin (depth 1) into a temp cache:") + w("python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md") + w("# Or scan a local monorepo checkout (read only) instead of cloning it:") + w("python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins") + w("```") + w("") + w("Before removing a method in its release, re-run the scan against the current " + "monorepo and registry: a plugin added since this file was generated may have " + "started calling it. Remove only methods the fresh scan reports unused; move " + "the rest to a later release (the test in `test/test_deprecation.py` fails " + "while a marker names a release at or below `src.__version__`).") + w("") + return "\n".join(out) + + +def render_json(markers: List[Marker], sources: List[Source], meta: Dict[str, str]) -> str: + data = {"meta": meta, "sources": [{"name": s.name, "group": s.group, "error": s.error} + for s in sources], "methods": []} + status = verdicts(markers, sources) + for m in markers: + st, text = status[m.key] + data["methods"].append({ + "method": m.key, "module": m.module, "removal": m.removal, + "alternative": m.alternative, "status": st, "verdict": text, + "hits": [{"source": s.name, **h.__dict__} for s in sources + for h in s.hits.get(m.key, [])], + }) + return json.dumps(data, indent=2) + + +# -------------------------------------------------------------------------- + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--monorepo", type=Path, + help="local ledmatrix-plugins checkout to scan (read only); " + "default: shallow-clone its main branch") + parser.add_argument("--registry", type=Path, + help="plugins.json to read third-party plugins from " + "(default: the monorepo's)") + parser.add_argument("--cache-dir", type=Path, + default=Path(tempfile.gettempdir()) / "ledmatrix-plugin-api-usage", + help="where clones go (default: %(default)s)") + parser.add_argument("--reuse-cache", action="store_true", + help="scan clones already in --cache-dir instead of re-cloning " + "(offline re-runs; the report may then be stale)") + parser.add_argument("--no-third-party", action="store_true", + help="skip third-party plugins (the report then cannot prove anything unused)") + parser.add_argument("--format", choices=("md", "json"), default="md") + parser.add_argument("--output", type=Path, help="write the report here instead of stdout") + args = parser.parse_args(argv) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + markers = find_markers(REPO_ROOT) + if not markers: + print("No @deprecated markers found in src/.", file=sys.stderr) + return 0 + + sys.path.insert(0, str(REPO_ROOT)) + try: + from src import __version__ as core_version + except Exception: # noqa: BLE001 -- reporting only + core_version = "unknown" + + sources: List[Source] = [] + core = Source("core", "core", REPO_ROOT) + scan_tree(core, [REPO_ROOT / d for d in CORE_DIRS] + sorted(REPO_ROOT.glob("*.py")), + REPO_ROOT, markers, core=True) + core_tests = Source("core tests", "core-tests", REPO_ROOT) + scan_tree(core_tests, [REPO_ROOT / d for d in CORE_TEST_DIRS], REPO_ROOT, markers, + core=True, test_override=True, + definer_roots=[REPO_ROOT / d for d in CORE_DIRS]) + sources += [core, core_tests] + + # Monorepo + if args.monorepo: + mono = args.monorepo.resolve() + mono_desc = f"local checkout `{mono.name}` ({_head(mono)})" + else: + mono = args.cache_dir / "ledmatrix-plugins" + err = shallow_clone(MONOREPO_URL, "main", mono, args.reuse_cache) + if err: + print(f"Could not clone the monorepo: {err}", file=sys.stderr) + return 1 + mono_desc = f"[ChuckBuilds/ledmatrix-plugins]({MONOREPO_URL}) ({_head(mono)})" + plugins_dir = mono / "plugins" + mono_dirs = sorted(p for p in plugins_dir.iterdir() if p.is_dir()) if plugins_dir.is_dir() else [] + for d in mono_dirs: + s = Source(_plugin_id(d), "monorepo", d) + scan_tree(s, [d], d, markers, core=False) + sources.append(s) + mono_desc += f", {len(mono_dirs)} plugins" + + # Third-party plugins from the registry + registry = args.registry or (mono / "plugins.json") + third: List[dict] = [] + try: + reg = json.loads(registry.read_text(encoding="utf-8")) + entries = reg["plugins"] if isinstance(reg, dict) else reg + third = [e for e in entries if e.get("repo") and not _is_monorepo(e["repo"])] + except (OSError, ValueError, KeyError) as exc: + print(f"Could not read {registry}: {exc}", file=sys.stderr) + return 1 + if args.no_third_party: + tp_desc = "skipped (--no-third-party)" + else: + for e in third: + dest = args.cache_dir / "third-party" / re.sub(r"[^\w.-]", "_", e["id"]) + err = shallow_clone(e["repo"], e.get("branch") or None, dest, args.reuse_cache) + root = dest / e["plugin_path"] if e.get("plugin_path") else dest + s = Source(e["id"], "third-party", root, error=err) + if not err: + scan_tree(s, [root], root, markers, core=False) + sources.append(s) + tp_desc = (f"{len(third)} with their own repo in `plugins.json` " + f"({', '.join(e['id'] for e in third)})") + + meta = { + "date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + "core_version": core_version, + "core_rev": _head(REPO_ROOT, branch=False), + "monorepo": mono_desc, + "third_party": tp_desc, + } + report = (render_json if args.format == "json" else render_markdown)(markers, sources, meta) + if args.output: + args.output.write_text(report, encoding="utf-8", newline="\n") + print(f"Wrote {args.output}", file=sys.stderr) + else: + sys.stdout.write(report) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/cache_manager.py b/src/cache_manager.py index ee4f46a6..4068d50a 100644 --- a/src/cache_manager.py +++ b/src/cache_manager.py @@ -408,7 +408,7 @@ class CacheManager: """Get the cache directory path.""" return self.cache_dir - @deprecated("3.7.0") + @deprecated("3.8.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) @@ -514,7 +514,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()") + @deprecated("3.8.0", "use set()") def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool: """Update cache with new data.""" cache_data = { @@ -564,7 +564,7 @@ class CacheManager: cache_data['data'] = data self.save_cache(key, cache_data) - @deprecated("3.7.0") + @deprecated("3.8.0") def setup_persistent_cache(self) -> bool: """ Set up a persistent cache directory with proper permissions. @@ -776,7 +776,7 @@ class CacheManager: else: self.logger.info("Disk cache cleanup thread stopped successfully") - @deprecated("3.7.0") + @deprecated("3.8.0") def get_sport_live_interval(self, sport_key: str) -> int: """ Get the live_update_interval for a specific sport from config. @@ -798,7 +798,7 @@ class CacheManager: """ return self._strategy_component.get_data_type_from_key(key) - @deprecated("3.7.0") + @deprecated("3.8.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. @@ -838,7 +838,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()") + @deprecated("3.8.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. @@ -876,7 +876,7 @@ class CacheManager: self.record_cache_miss('background') return None - @deprecated("3.7.0", "use get()") + @deprecated("3.8.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. @@ -906,32 +906,32 @@ class CacheManager: date_str = datetime.now(pytz.utc).strftime('%Y%m%d') return f"{sport}_{date_str}" - @deprecated("3.7.0") + @deprecated("3.8.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") + @deprecated("3.8.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") + @deprecated("3.8.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") + @deprecated("3.8.0") def get_cache_metrics(self) -> Dict[str, Any]: """Get current cache performance metrics.""" return self._metrics_component.get_metrics() - @deprecated("3.7.0") + @deprecated("3.8.0") def log_cache_metrics(self) -> None: """Log current cache performance metrics.""" self._metrics_component.log_metrics() - @deprecated("3.7.0") + @deprecated("3.8.0") def get_memory_cache_stats(self) -> Dict[str, Any]: """ Get statistics about the memory cache. @@ -943,7 +943,9 @@ class CacheManager: def log_memory_cache_stats(self) -> None: """Log current memory cache statistics.""" - stats = self.get_memory_cache_stats() + # Not get_memory_cache_stats(): that is deprecated, and core must not + # trip its own deprecation warning every time memory logging runs. + stats = self._memory_cache_component.get_stats() self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} " f"({stats['usage_percent']:.1f}%), " f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago") \ No newline at end of file diff --git a/src/deprecation.py b/src/deprecation.py index 4dd966d5..e49c8984 100644 --- a/src/deprecation.py +++ b/src/deprecation.py @@ -6,6 +6,14 @@ still be called by a plugin nobody has checked. Such methods get process logs a warning naming the method and the release that removes it (visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for tooling. + +Before a release removes anything, ``scripts/plugin_api_usage.py`` scans core, +the official plugin monorepo and the registry's third-party plugins for callers +and overriders of every marked method. Its latest output is +``docs/DEPRECATIONS_3.8.md``; remove only what it reports unused, and move the +rest to a later release. ``test/test_deprecation.py`` fails while any marker +names a release at or below ``src.__version__``, so a release cannot ship with +a removal date it has already passed. """ import functools diff --git a/src/display_manager.py b/src/display_manager.py index 7086b7ba..0b6ca7a7 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -1320,7 +1320,7 @@ class DisplayManager: except Exception as e: logger.error(f"Error drawing text: {e}", exc_info=True) - @deprecated("3.7.0") + @deprecated("3.8.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) @@ -1341,7 +1341,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") + @deprecated("3.8.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 @@ -1349,7 +1349,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") + @deprecated("3.8.0") def draw_rain(self, x: int, y: int, size: int = 16): """Draw rain icon with cloud and droplets.""" # Draw cloud @@ -1364,7 +1364,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") + @deprecated("3.8.0") def draw_snow(self, x: int, y: int, size: int = 16): """Draw snow icon with cloud and snowflakes.""" # Draw cloud @@ -1485,7 +1485,7 @@ class DisplayManager: ] self.draw.polygon(bolt_points, fill=bolt_color) - @deprecated("3.7.0") + @deprecated("3.8.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']: @@ -1502,7 +1502,7 @@ class DisplayManager: self._draw_sun(x, y, size) # Note: No update_display() here - let the caller handle the update - @deprecated("3.7.0") + @deprecated("3.8.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.""" @@ -1828,7 +1828,7 @@ class DisplayManager: if removed_count > 0: logger.debug(f"Cleaned up {removed_count} expired deferred updates") - @deprecated("3.7.0") + @deprecated("3.8.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 dce63870..80ecd6a4 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -187,7 +187,7 @@ class FontManager: if removed: self.manager_fonts_version += 1 - @deprecated("3.7.0") + @deprecated("3.8.0") def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]: """ Get registered fonts for a specific manager or all managers. @@ -202,7 +202,7 @@ class FontManager: return self.manager_fonts.get(manager_id, {}) return self.manager_fonts.copy() - @deprecated("3.7.0") + @deprecated("3.8.0") def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]: """Get all detected font usage across managers.""" return self.detected_fonts.copy() @@ -433,7 +433,7 @@ class FontManager: search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))] return resolve_plugin_dir(plugin_id, search_dirs, prefix=True) - @deprecated("3.7.0") + @deprecated("3.8.0") def unregister_plugin_fonts(self, plugin_id: str) -> bool: """Unregister all fonts for a plugin.""" try: @@ -471,7 +471,7 @@ class FontManager: # Font objects someone may hold were dropped; see cache_generation. self.cache_generation += 1 - @deprecated("3.7.0") + @deprecated("3.8.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: @@ -670,7 +670,7 @@ class FontManager: # ==================== Override Management ==================== - @deprecated("3.7.0") + @deprecated("3.8.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: @@ -690,7 +690,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") + @deprecated("3.8.0") def remove_override(self, element_key: str): """Remove font override for a specific element.""" if element_key in self.font_overrides: @@ -699,7 +699,7 @@ class FontManager: self.clear_cache() logger.info(f"Font override removed for {element_key}") - @deprecated("3.7.0") + @deprecated("3.8.0") def get_overrides(self) -> Dict[str, Dict[str, str]]: """Get current font overrides.""" return self.font_overrides.copy() @@ -787,17 +787,17 @@ class FontManager: self.cache_generation += 1 logger.info("Font cache cleared") - @deprecated("3.7.0", "read font_catalog") + @deprecated("3.8.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") + @deprecated("3.8.0") def get_size_tokens(self) -> Dict[str, int]: """Get available size tokens.""" return self.size_tokens.copy() - @deprecated("3.7.0") + @deprecated("3.8.0") def get_performance_stats(self) -> Dict[str, Any]: """Get performance statistics.""" uptime = time.time() - self.performance_stats["start_time"] @@ -819,12 +819,12 @@ class FontManager: "detected_fonts": len(self.detected_fonts) } - @deprecated("3.7.0", "read font_catalog") + @deprecated("3.8.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") + @deprecated("3.8.0") def add_font(self, font_file_path: str, family_name: str) -> bool: """Add ``font_file_path`` to the catalog as ``family_name``. The file stays where it is; only assets/fonts is created if it is missing.""" @@ -852,7 +852,7 @@ class FontManager: logger.error(f"Error adding font {family_name}: {e}") return False - @deprecated("3.7.0") + @deprecated("3.8.0") def remove_font(self, family_name: str) -> bool: """Remove a font from the catalog.""" try: @@ -880,7 +880,7 @@ class FontManager: logger.error(f"Error removing font {family_name}: {e}") return False - @deprecated("3.7.0") + @deprecated("3.8.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 87ae4642..cd4372a2 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -844,7 +844,7 @@ class PluginManager: """ return self.plugins.copy() - @deprecated("3.7.0", "check each plugin's enabled flag in plugins") + @deprecated("3.8.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 index 7c18fcd0..25116ab3 100644 --- a/test/test_deprecation.py +++ b/test/test_deprecation.py @@ -1,19 +1,29 @@ """@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 ast +import importlib.util import logging import os +import sys +import textwrap import warnings +from pathlib import Path import pytest +from packaging.version import Version os.environ.setdefault("EMULATOR", "true") -from src import deprecation +from src import __version__, 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. +REPO = Path(__file__).resolve().parents[1] + +#: Everything deprecated for removal in 3.8.0 (first announced for 3.7.0, +#: which shipped with all of them still in place). docs/DEPRECATIONS_3.8.md +#: says which are unused. 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", @@ -49,7 +59,129 @@ def test_exactly_these_methods_are_deprecated(path): if hasattr(value, "__deprecated__")) assert marked == sorted(DEPRECATED[path]) for name in marked: - assert "3.7.0" in getattr(cls, name).__deprecated__ + assert "3.8.0" in getattr(cls, name).__deprecated__ + + +def _markers(): + """(file:line, removal) for every ``@deprecated(...)`` under src/.""" + found = [] + for path in sorted((REPO / "src").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), str(path)) + for fn in ast.walk(tree): + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for dec in fn.decorator_list: + target = dec.func if isinstance(dec, ast.Call) else dec + name = getattr(target, "id", None) or getattr(target, "attr", None) + if name != "deprecated": + continue + where = f"{path.relative_to(REPO).as_posix()}:{fn.lineno} {fn.name}" + arg = dec.args[0] if isinstance(dec, ast.Call) and dec.args else None + found.append((where, arg.value if isinstance(arg, ast.Constant) else None)) + return found + + +def test_markers_are_found(): + assert len(_markers()) == sum(len(v) for v in DEPRECATED.values()) + + +def test_no_marker_names_a_release_already_shipped(): + """3.7.0 shipped still warning that 35 methods are "removed in 3.7.0". + + Once ``src.__version__`` reaches a marker's release, that release is here: + remove the method (if scripts/plugin_api_usage.py reports it unused) or + move the marker to a later release. Either way, never ship a warning that + names a version the user is already running. + """ + current = Version(__version__) + stale = [f"{where} -> {removal!r}" for where, removal in _markers() + if not isinstance(removal, str) or Version(removal) <= current] + assert not stale, (f"@deprecated markers at or below src.__version__ {__version__} " + f"(or not a literal version): {stale}") + + +@pytest.fixture(scope="module") +def usage_script(): + path = REPO / "scripts" / "plugin_api_usage.py" + spec = importlib.util.spec_from_file_location("plugin_api_usage_script", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module # dataclasses resolve annotations through it + try: + spec.loader.exec_module(module) + yield module + finally: + sys.modules.pop(spec.name, None) + + +def test_usage_script_lists_exactly_the_pinned_markers(usage_script): + found = {(f"{m.module}.{m.owner}", m.method, m.removal) + for m in usage_script.find_markers(REPO)} + assert found == {(path, name, "3.8.0") + for path, names in DEPRECATED.items() for name in names} + + +def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path): + """Calls on the owning object and overrides count; same-named methods of + unrelated classes and hits in test files do not.""" + plugin = tmp_path / "demo" + (plugin / "test").mkdir(parents=True) + (plugin / "manager.py").write_text(textwrap.dedent("""\ + class Icons: + @staticmethod + def draw_sun(img): + pass + + class Plugin: + def draw_cloud(self): + return self.draw_cloud() + + def display(self): + Icons.draw_sun(None) + self.cache_manager.update_cache('k', {}) + dm = self.display_manager + dm.draw_rain(0, 0) + thing.get_scrolling_stats() + + class MyFonts(FontManager): + def add_font(self, path, name): + return super().add_font(path, name) + """), encoding="utf-8") + (plugin / "test" / "test_manager.py").write_text(textwrap.dedent("""\ + def test_x(display_manager): + display_manager.draw_snow.assert_not_called() + """), encoding="utf-8") + + markers = usage_script.find_markers(REPO) + source = usage_script.Source("demo", "monorepo", plugin) + usage_script.scan_tree(source, [plugin], plugin, markers, core=False) + kinds = {key: sorted(("test " if h.test else "") + h.kind for h in hits) + for key, hits in source.hits.items()} + + assert kinds == { + "DisplayManager.draw_sun": ["unrelated", "unrelated"], + "DisplayManager.draw_cloud": ["unrelated", "unrelated"], + "CacheManager.update_cache": ["call"], + "DisplayManager.draw_rain": ["call"], + "DisplayManager.get_scrolling_stats": ["review"], + "FontManager.add_font": ["call", "override"], + "DisplayManager.draw_snow": ["test call"], + } + + status = usage_script.verdicts(markers, [source]) + assert status["CacheManager.update_cache"][0] == "used" + assert status["DisplayManager.get_scrolling_stats"][0] == "review" + assert status["DisplayManager.draw_sun"][0] == "unused" # a collision only + assert status["DisplayManager.draw_snow"][0] == "unused" # a test mock only + + +def test_usage_script_follows_calls_between_deprecated_core_methods(usage_script): + """draw_rain calls draw_cloud; with no outside callers both are unused.""" + markers = usage_script.find_markers(REPO) + core = usage_script.Source("core", "core", REPO) + usage_script.scan_tree(core, [REPO / "src" / "display_manager.py"], REPO, markers, core=True) + kinds = {h.kind for h in core.hits["DisplayManager.draw_cloud"]} + assert kinds == {"internal"} + assert usage_script.verdicts(markers, [core])["DisplayManager.draw_cloud"][0] == "unused" @pytest.fixture