mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* chore(deprecation): remove the 35 APIs deprecated for 3.8.0 The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and committed here) finds no call or override of any of them in the 46 monorepo plugins or the 8 third-party plugins plugins.json lists; the only core callers were other deprecated methods removed alongside. - CacheManager: 13 methods, plus the private helpers only has_data_changed used (_has_*_changed, _is_market_open). - DisplayManager: 7 methods, plus WEATHER_COLORS and the private _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used. - FontManager: 14 methods, plus size_tokens, _save_overrides and _clear_plugin_font_cache. font_overrides and _load_overrides stay: resolve_font() still applies config/font_overrides.json. performance_stats stays: get_font() keeps it and tests read it. - PluginManager.get_enabled_plugins. test_deprecation.py pins only the two 3.9.0 markers now; the scanner tests run against a stand-in core instead of the real markers. The memory-tier tests read stats through log_memory_cache_stats() and the component, and the test of the removed _clear_plugin_font_cache goes. Docs drop the removed methods' reference entries; the Deprecated APIs table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(deprecation): drop the test harness's copies of the removed icon methods VisualTestDisplayManager still drew weather icons that DisplayManager no longer has, so a plugin's visual tests could pass on calls that raise AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/ draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the private helpers go, with the tests that exercised them. The CHANGELOG's Deprecations entries no longer say nothing is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
9.3 KiB
Python
252 lines
9.3 KiB
Python
"""@deprecated: plugin-facing APIs nothing in core, the monorepo or the
|
|
registry's third-party plugins calls, kept for one release with a warning."""
|
|
|
|
import ast
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import sys
|
|
import textwrap
|
|
import warnings
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from packaging.version import Version
|
|
|
|
os.environ.setdefault("EMULATOR", "true")
|
|
|
|
from src import __version__, deprecation
|
|
from src.deprecation import deprecated
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
#: Everything still deprecated. (The 35 methods deprecated for 3.8.0 were
|
|
#: removed in it: docs/DEPRECATIONS_3.8.md found them unused.) Removing one
|
|
#: of these, or deprecating another, should be a deliberate edit here too.
|
|
#:
|
|
#: Deprecated with Vegas participation, for removal in 3.9.0: core never read
|
|
#: them (src.plugin_system.base_plugin.VEGAS_LEGACY_REMOVAL).
|
|
DEPRECATED_3_9 = {
|
|
"src.plugin_system.base_plugin.BasePlugin": [
|
|
"get_supported_vegas_modes", "get_vegas_segment_width",
|
|
],
|
|
}
|
|
|
|
#: Every pinned marker: (class path, method) -> the release that removes it.
|
|
PINNED = {(path, name): removal
|
|
for removal, table in (("3.9.0", DEPRECATED_3_9),)
|
|
for path, names in table.items() for name in names}
|
|
|
|
|
|
def _cls(path):
|
|
import importlib
|
|
module, name = path.rsplit(".", 1)
|
|
return getattr(importlib.import_module(module), name)
|
|
|
|
|
|
@pytest.mark.parametrize("path", sorted({path for path, _ in PINNED}))
|
|
def test_exactly_these_methods_are_deprecated(path):
|
|
cls = _cls(path)
|
|
marked = sorted(name for name, value in vars(cls).items()
|
|
if hasattr(value, "__deprecated__"))
|
|
assert marked == sorted(name for owner, name in PINNED if owner == path)
|
|
for name in marked:
|
|
assert f"LEDMatrix {PINNED[(path, name)]}" 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()) == len(PINNED)
|
|
|
|
|
|
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, removal) for (path, name), removal in PINNED.items()}
|
|
|
|
|
|
#: A stand-in core for the scanner tests below, so they keep working whichever
|
|
#: real markers exist (the 3.8.0 ones they were written against are gone).
|
|
FAKE_CORE = {
|
|
"src/cache_manager.py": """\
|
|
class CacheManager:
|
|
@deprecated("9.9.0", "use set()")
|
|
def update_cache(self, key, data):
|
|
pass
|
|
""",
|
|
"src/display_manager.py": """\
|
|
class DisplayManager:
|
|
@deprecated("9.9.0")
|
|
def draw_sun(self, x, y):
|
|
pass
|
|
|
|
@deprecated("9.9.0")
|
|
def draw_cloud(self, x, y):
|
|
pass
|
|
|
|
@deprecated("9.9.0")
|
|
def draw_rain(self, x, y):
|
|
self.draw_cloud(x, y)
|
|
|
|
@deprecated("9.9.0")
|
|
def draw_snow(self, x, y):
|
|
pass
|
|
|
|
@deprecated("9.9.0")
|
|
def get_scrolling_stats(self):
|
|
return {}
|
|
""",
|
|
"src/font_manager.py": """\
|
|
class FontManager:
|
|
@deprecated("9.9.0")
|
|
def add_font(self, path, name):
|
|
return True
|
|
""",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_core(tmp_path):
|
|
root = tmp_path / "core"
|
|
for rel, source in FAKE_CORE.items():
|
|
path = root / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(textwrap.dedent(source), encoding="utf-8")
|
|
return root
|
|
|
|
|
|
def test_usage_script_tells_uses_from_name_collisions(usage_script, fake_core, 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(fake_core)
|
|
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, fake_core):
|
|
"""draw_rain calls draw_cloud; with no outside callers both are unused."""
|
|
markers = usage_script.find_markers(fake_core)
|
|
core = usage_script.Source("core", "core", fake_core)
|
|
usage_script.scan_tree(core, [fake_core / "src" / "display_manager.py"], fake_core,
|
|
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
|
|
def fresh(monkeypatch):
|
|
monkeypatch.setattr(deprecation, "_warned", set())
|
|
|
|
|
|
def test_first_call_warns_and_logs_then_stays_quiet(fresh, caplog):
|
|
@deprecated("9.9.9", "use other()")
|
|
def old(x):
|
|
"""Doc."""
|
|
return x * 2
|
|
|
|
with warnings.catch_warnings(record=True) as caught, caplog.at_level(logging.WARNING):
|
|
warnings.simplefilter("always")
|
|
assert old(2) == 4
|
|
assert old(3) == 6
|
|
|
|
assert [str(w.message) for w in caught] == [
|
|
"test_first_call_warns_and_logs_then_stays_quiet.<locals>.old() is deprecated "
|
|
"and will be removed in LEDMatrix 9.9.9; use other()"]
|
|
assert caught[0].category is DeprecationWarning
|
|
assert caught[0].filename == __file__ # points at the caller
|
|
assert sum("will be removed in LEDMatrix 9.9.9" in r.message for r in caplog.records) == 1
|
|
assert old.__name__ == "old" and old.__doc__ == "Doc."
|