chore(deprecation): retarget the 35 deprecations to 3.8.0 and add a usage scan (#681)

Moves the 35 @deprecated markers from 3.7.0 (already shipped with them in
place) to 3.8.0, and adds scripts/plugin_api_usage.py plus the generated
docs/DEPRECATIONS_3.8.md: who still calls or overrides each deprecated
method across core, the monorepo and third-party plugins. Removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:11:36 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9fe23af432
commit c8a0ddcf7b
15 changed files with 1168 additions and 64 deletions
+19 -1
View File
@@ -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 this release rewrites. CI runs it against the monorepo's main as a
report-only job ("Sports drift report"; never fails the build). 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 ## 3.7.0
Sports consolidation stage 3 (#672). No behaviour change: nothing in core 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.register_plugin_fonts()` takes an optional `plugin_dir`, and
`FontManager.forget_manager_fonts()` is new (see Fonts). `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 `docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
core, the monorepo or the registry's third-party plugins calls them: core, the monorepo or the registry's third-party plugins calls them:
+3 -3
View File
@@ -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()`, The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` — `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 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 icon images with the plugin. The weather plugin's `WeatherIcons` class is an
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis). example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
@@ -194,7 +194,7 @@ def update(self):
sport_key = "nhl" sport_key = "nhl"
cache_key = f"{self.plugin_id}_{sport_key}_games" 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) cached = self.cache_manager.get(cache_key, max_age=60)
if cached: if cached:
@@ -596,7 +596,7 @@ def update(self):
```python ```python
def update(self): 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 # instance's `enabled` flag instead
weather_plugin = self.plugin_manager.get_plugin("weather") weather_plugin = self.plugin_manager.get_plugin("weather")
if weather_plugin is not None and weather_plugin.enabled: if weather_plugin is not None and weather_plugin.enabled:
+162
View File
@@ -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 `<receiver>.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__`).
+3 -3
View File
@@ -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_fit("12:34", rows[0]) # largest crisp font that fits
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True) 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) # draw your own icons (the weather plugin ships WeatherIcons)
# Scrolling state # Scrolling state
@@ -78,7 +78,7 @@ strategy = cache_manager.get_cache_strategy("weather")
``` ```
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()` `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). [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
## Plugin Manager Quick Methods ## Plugin Manager Quick Methods
@@ -87,7 +87,7 @@ are deprecated, removed in 3.7.0. See
# Get plugins # Get plugins
plugin = plugin_manager.get_plugin("plugin-id") plugin = plugin_manager.get_plugin("plugin-id")
all_plugins = plugin_manager.get_all_plugins() 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 # on the entries in plugin_manager.plugins
# Get info # Get info
+2 -2
View File
@@ -13,7 +13,7 @@
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
which plugin uses which font so the web UI can show it. 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 log a warning on first call. They are listed in
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in [Deprecated methods](#deprecated-methods) below, and the full set is pinned in
[`test/test_deprecation.py`](../test/test_deprecation.py). [`test/test_deprecation.py`](../test/test_deprecation.py).
@@ -209,7 +209,7 @@ Current methods:
### Deprecated 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 | | Method | Use instead |
|---|---| |---|---|
+15 -12
View File
@@ -485,7 +485,7 @@ This is the canonical way to render arbitrary images.
### Weather Icons (deprecated) ### 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). > ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition - `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` #### `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. 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]]` #### `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. 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` #### `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. 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]` #### `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. 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]` #### `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. 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]` #### `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. Get memory cache statistics.
@@ -905,7 +905,7 @@ for plugin_id, plugin in all_plugins.items():
#### `get_enabled_plugins() -> List[str]` #### `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. Get list of enabled plugin IDs.
@@ -1076,10 +1076,13 @@ if weather is not None and weather.enabled:
## Deprecated APIs ## Deprecated APIs
These still work in 3.6 but log a warning the first time they are called These still work but log a warning the first time they are called
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.7.0**. (`journalctl -u ledmatrix` shows which one), and are **removed in 3.8.0**
Nothing in core, the official plugins or the third-party plugins in the (first announced for 3.7.0, which shipped with them still in place).
registry calls them. [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 | | Object | Methods | Instead |
|---|---|---| |---|---|---|
+3 -3
View File
@@ -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()`; `display_manager.image` (a PIL Image) and call `update_display()`;
there is no `draw_image()` helper method. there is no `draw_image()` helper method.
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons - `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 - `get_text_width()`, `get_font_height()` - Text utilities
- `set_scrolling_state()`, `defer_update()` - Scrolling state management - `set_scrolling_state()`, `defer_update()` - Scrolling state management
**Cache Manager** (`self.cache_manager`): **Cache Manager** (`self.cache_manager`):
- `get()`, `set()`, `delete()` - Basic caching - `get()`, `set()`, `delete()` - Basic caching
- `get_cached_data_with_strategy()` - Advanced caching with strategies - `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`): **Plugin Manager** (`self.plugin_manager`):
- `get_plugin()`, `get_all_plugins()` - Access other plugins - `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 See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete
documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis) 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 ## 3rd Party Plugin Development
+1
View File
@@ -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) | | `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_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 | | `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 | | `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_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 | | `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
+778
View File
@@ -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** -- ``<receiver>.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** -- ``<receiver>.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 `<receiver>.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())
+16 -14
View File
@@ -408,7 +408,7 @@ class CacheManager:
"""Get the cache directory path.""" """Get the cache directory path."""
return self.cache_dir 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: def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
"""Check if data has changed from cached version.""" """Check if data has changed from cached version."""
cached_data = self.load_cache(data_type) cached_data = self.load_cache(data_type)
@@ -514,7 +514,7 @@ class CacheManager:
"""Check if the US stock market is currently open.""" """Check if the US stock market is currently open."""
return self._strategy_component.is_market_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: def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
"""Update cache with new data.""" """Update cache with new data."""
cache_data = { cache_data = {
@@ -564,7 +564,7 @@ class CacheManager:
cache_data['data'] = data cache_data['data'] = data
self.save_cache(key, cache_data) self.save_cache(key, cache_data)
@deprecated("3.7.0") @deprecated("3.8.0")
def setup_persistent_cache(self) -> bool: def setup_persistent_cache(self) -> bool:
""" """
Set up a persistent cache directory with proper permissions. Set up a persistent cache directory with proper permissions.
@@ -776,7 +776,7 @@ class CacheManager:
else: else:
self.logger.info("Disk cache cleanup thread stopped successfully") 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: def get_sport_live_interval(self, sport_key: str) -> int:
""" """
Get the live_update_interval for a specific sport from config. 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) 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]: def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
""" """
Extract sport key from cache key to determine appropriate live_update_interval. 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) data_type = self.get_data_type_from_key(key)
return self.get_cached_data_with_strategy(key, data_type) 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]]: 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. Get data from background service cache with appropriate strategy.
@@ -876,7 +876,7 @@ class CacheManager:
self.record_cache_miss('background') self.record_cache_miss('background')
return None 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: def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
""" """
Check if background service has fresh data available. Check if background service has fresh data available.
@@ -906,32 +906,32 @@ class CacheManager:
date_str = datetime.now(pytz.utc).strftime('%Y%m%d') date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
return f"{sport}_{date_str}" return f"{sport}_{date_str}"
@deprecated("3.7.0") @deprecated("3.8.0")
def record_cache_hit(self, cache_type: str = 'regular') -> None: def record_cache_hit(self, cache_type: str = 'regular') -> None:
"""Record a cache hit for performance monitoring.""" """Record a cache hit for performance monitoring."""
self._metrics_component.record_hit(cache_type) 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: def record_cache_miss(self, cache_type: str = 'regular') -> None:
"""Record a cache miss for performance monitoring.""" """Record a cache miss for performance monitoring."""
self._metrics_component.record_miss(cache_type) self._metrics_component.record_miss(cache_type)
@deprecated("3.7.0") @deprecated("3.8.0")
def record_fetch_time(self, duration: float) -> None: def record_fetch_time(self, duration: float) -> None:
"""Record fetch operation duration for performance monitoring.""" """Record fetch operation duration for performance monitoring."""
self._metrics_component.record_fetch_time(duration) self._metrics_component.record_fetch_time(duration)
@deprecated("3.7.0") @deprecated("3.8.0")
def get_cache_metrics(self) -> Dict[str, Any]: def get_cache_metrics(self) -> Dict[str, Any]:
"""Get current cache performance metrics.""" """Get current cache performance metrics."""
return self._metrics_component.get_metrics() return self._metrics_component.get_metrics()
@deprecated("3.7.0") @deprecated("3.8.0")
def log_cache_metrics(self) -> None: def log_cache_metrics(self) -> None:
"""Log current cache performance metrics.""" """Log current cache performance metrics."""
self._metrics_component.log_metrics() self._metrics_component.log_metrics()
@deprecated("3.7.0") @deprecated("3.8.0")
def get_memory_cache_stats(self) -> Dict[str, Any]: def get_memory_cache_stats(self) -> Dict[str, Any]:
""" """
Get statistics about the memory cache. Get statistics about the memory cache.
@@ -943,7 +943,9 @@ class CacheManager:
def log_memory_cache_stats(self) -> None: def log_memory_cache_stats(self) -> None:
"""Log current memory cache statistics.""" """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']} " self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
f"({stats['usage_percent']:.1f}%), " f"({stats['usage_percent']:.1f}%), "
f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago") f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago")
+8
View File
@@ -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 process logs a warning naming the method and the release that removes it
(visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for (visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for
tooling. 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 import functools
+7 -7
View File
@@ -1320,7 +1320,7 @@ class DisplayManager:
except Exception as e: except Exception as e:
logger.error(f"Error drawing text: {e}", exc_info=True) 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): def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines.""" """Draw a sun icon using yellow circles and lines."""
center = (x + size//2, y + size//2) center = (x + size//2, y + size//2)
@@ -1341,7 +1341,7 @@ class DisplayManager:
end_y = center[1] + ((radius + ray_length) * math.sin(rad)) 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) 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)): def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
"""Draw a cloud icon.""" """Draw a cloud icon."""
# Draw multiple circles to form a cloud shape # 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//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) 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): def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets.""" """Draw rain icon with cloud and droplets."""
# Draw cloud # Draw cloud
@@ -1364,7 +1364,7 @@ class DisplayManager:
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size], self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
fill=drop_color, width=2) fill=drop_color, width=2)
@deprecated("3.7.0") @deprecated("3.8.0")
def draw_snow(self, x: int, y: int, size: int = 16): def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes.""" """Draw snow icon with cloud and snowflakes."""
# Draw cloud # Draw cloud
@@ -1485,7 +1485,7 @@ class DisplayManager:
] ]
self.draw.polygon(bolt_points, fill=bolt_color) 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: def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition.""" """Draw a weather icon based on the condition."""
if condition.lower() in ['clear', 'sunny']: if condition.lower() in ['clear', 'sunny']:
@@ -1502,7 +1502,7 @@ class DisplayManager:
self._draw_sun(x, y, size) self._draw_sun(x, y, size)
# Note: No update_display() here - let the caller handle the update # 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, def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
color: tuple = (255, 255, 255)): color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions.""" """Draw text with weather icons at specified positions."""
@@ -1828,7 +1828,7 @@ class DisplayManager:
if removed_count > 0: if removed_count > 0:
logger.debug(f"Cleaned up {removed_count} expired deferred updates") logger.debug(f"Cleaned up {removed_count} expired deferred updates")
@deprecated("3.7.0") @deprecated("3.8.0")
def get_scrolling_stats(self) -> dict: def get_scrolling_stats(self) -> dict:
"""Get current scrolling statistics for debugging.""" """Get current scrolling statistics for debugging."""
return { return {
+14 -14
View File
@@ -187,7 +187,7 @@ class FontManager:
if removed: if removed:
self.manager_fonts_version += 1 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]: def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
""" """
Get registered fonts for a specific manager or all managers. 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.get(manager_id, {})
return self.manager_fonts.copy() return self.manager_fonts.copy()
@deprecated("3.7.0") @deprecated("3.8.0")
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]: def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
"""Get all detected font usage across managers.""" """Get all detected font usage across managers."""
return self.detected_fonts.copy() return self.detected_fonts.copy()
@@ -433,7 +433,7 @@ class FontManager:
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))] search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True) 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: def unregister_plugin_fonts(self, plugin_id: str) -> bool:
"""Unregister all fonts for a plugin.""" """Unregister all fonts for a plugin."""
try: try:
@@ -471,7 +471,7 @@ class FontManager:
# Font objects someone may hold were dropped; see cache_generation. # Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1 self.cache_generation += 1
@deprecated("3.7.0") @deprecated("3.8.0")
def get_plugin_fonts(self, plugin_id: str) -> List[str]: def get_plugin_fonts(self, plugin_id: str) -> List[str]:
"""Get list of font families registered by a plugin.""" """Get list of font families registered by a plugin."""
if plugin_id in self.plugin_font_catalogs: if plugin_id in self.plugin_font_catalogs:
@@ -670,7 +670,7 @@ class FontManager:
# ==================== Override Management ==================== # ==================== Override Management ====================
@deprecated("3.7.0") @deprecated("3.8.0")
def set_override(self, element_key: str, family: str = None, size_px: int = None): def set_override(self, element_key: str, family: str = None, size_px: int = None):
"""Set font override for a specific element.""" """Set font override for a specific element."""
if element_key not in self.font_overrides: if element_key not in self.font_overrides:
@@ -690,7 +690,7 @@ class FontManager:
self.clear_cache() self.clear_cache()
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}") 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): def remove_override(self, element_key: str):
"""Remove font override for a specific element.""" """Remove font override for a specific element."""
if element_key in self.font_overrides: if element_key in self.font_overrides:
@@ -699,7 +699,7 @@ class FontManager:
self.clear_cache() self.clear_cache()
logger.info(f"Font override removed for {element_key}") 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]]: def get_overrides(self) -> Dict[str, Dict[str, str]]:
"""Get current font overrides.""" """Get current font overrides."""
return self.font_overrides.copy() return self.font_overrides.copy()
@@ -787,17 +787,17 @@ class FontManager:
self.cache_generation += 1 self.cache_generation += 1
logger.info("Font cache cleared") 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]: def get_available_fonts(self) -> Dict[str, str]:
"""Get dictionary of available font families and their paths.""" """Get dictionary of available font families and their paths."""
return self.font_catalog.copy() return self.font_catalog.copy()
@deprecated("3.7.0") @deprecated("3.8.0")
def get_size_tokens(self) -> Dict[str, int]: def get_size_tokens(self) -> Dict[str, int]:
"""Get available size tokens.""" """Get available size tokens."""
return self.size_tokens.copy() return self.size_tokens.copy()
@deprecated("3.7.0") @deprecated("3.8.0")
def get_performance_stats(self) -> Dict[str, Any]: def get_performance_stats(self) -> Dict[str, Any]:
"""Get performance statistics.""" """Get performance statistics."""
uptime = time.time() - self.performance_stats["start_time"] uptime = time.time() - self.performance_stats["start_time"]
@@ -819,12 +819,12 @@ class FontManager:
"detected_fonts": len(self.detected_fonts) "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]: def get_font_catalog(self) -> Dict[str, str]:
"""Get the current font catalog.""" """Get the current font catalog."""
return self.font_catalog.copy() 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: def add_font(self, font_file_path: str, family_name: str) -> bool:
"""Add ``font_file_path`` to the catalog as ``family_name``. The file """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.""" 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}") logger.error(f"Error adding font {family_name}: {e}")
return False return False
@deprecated("3.7.0") @deprecated("3.8.0")
def remove_font(self, family_name: str) -> bool: def remove_font(self, family_name: str) -> bool:
"""Remove a font from the catalog.""" """Remove a font from the catalog."""
try: try:
@@ -880,7 +880,7 @@ class FontManager:
logger.error(f"Error removing font {family_name}: {e}") logger.error(f"Error removing font {family_name}: {e}")
return False return False
@deprecated("3.7.0") @deprecated("3.8.0")
def validate_font(self, font_path: str) -> Dict[str, Any]: def validate_font(self, font_path: str) -> Dict[str, Any]:
"""Validate a font file.""" """Validate a font file."""
try: try:
+1 -1
View File
@@ -844,7 +844,7 @@ class PluginManager:
""" """
return self.plugins.copy() 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]: def get_enabled_plugins(self) -> List[str]:
""" """
Get list of enabled plugin IDs. Get list of enabled plugin IDs.
+136 -4
View File
@@ -1,19 +1,29 @@
"""@deprecated: plugin-facing APIs nothing in core, the monorepo or the """@deprecated: plugin-facing APIs nothing in core, the monorepo or the
registry's third-party plugins calls, kept for one release with a warning.""" registry's third-party plugins calls, kept for one release with a warning."""
import ast
import importlib.util
import logging import logging
import os import os
import sys
import textwrap
import warnings import warnings
from pathlib import Path
import pytest import pytest
from packaging.version import Version
os.environ.setdefault("EMULATOR", "true") os.environ.setdefault("EMULATOR", "true")
from src import deprecation from src import __version__, deprecation
from src.deprecation import deprecated from src.deprecation import deprecated
#: Everything deprecated for removal in 3.7.0. Removing one of these, or REPO = Path(__file__).resolve().parents[1]
#: deprecating another, should be a deliberate edit here too.
#: 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 = { DEPRECATED = {
"src.cache_manager.CacheManager": [ "src.cache_manager.CacheManager": [
"has_data_changed", "update_cache", "setup_persistent_cache", "has_data_changed", "update_cache", "setup_persistent_cache",
@@ -49,7 +59,129 @@ def test_exactly_these_methods_are_deprecated(path):
if hasattr(value, "__deprecated__")) if hasattr(value, "__deprecated__"))
assert marked == sorted(DEPRECATED[path]) assert marked == sorted(DEPRECATED[path])
for name in marked: 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 @pytest.fixture