mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
+19
-1
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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__`).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user