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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:11:36 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9fe23af432
commit c8a0ddcf7b
15 changed files with 1168 additions and 64 deletions
+136 -4
View File
@@ -1,19 +1,29 @@
"""@deprecated: plugin-facing APIs nothing in core, the monorepo or the
registry's third-party plugins calls, kept for one release with a warning."""
import ast
import importlib.util
import logging
import os
import sys
import textwrap
import warnings
from pathlib import Path
import pytest
from packaging.version import Version
os.environ.setdefault("EMULATOR", "true")
from src import deprecation
from src import __version__, deprecation
from src.deprecation import deprecated
#: Everything deprecated for removal in 3.7.0. Removing one of these, or
#: deprecating another, should be a deliberate edit here too.
REPO = Path(__file__).resolve().parents[1]
#: Everything deprecated for removal in 3.8.0 (first announced for 3.7.0,
#: which shipped with all of them still in place). docs/DEPRECATIONS_3.8.md
#: says which are unused. Removing one of these, or deprecating another,
#: should be a deliberate edit here too.
DEPRECATED = {
"src.cache_manager.CacheManager": [
"has_data_changed", "update_cache", "setup_persistent_cache",
@@ -49,7 +59,129 @@ def test_exactly_these_methods_are_deprecated(path):
if hasattr(value, "__deprecated__"))
assert marked == sorted(DEPRECATED[path])
for name in marked:
assert "3.7.0" in getattr(cls, name).__deprecated__
assert "3.8.0" in getattr(cls, name).__deprecated__
def _markers():
"""(file:line, removal) for every ``@deprecated(...)`` under src/."""
found = []
for path in sorted((REPO / "src").rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for dec in fn.decorator_list:
target = dec.func if isinstance(dec, ast.Call) else dec
name = getattr(target, "id", None) or getattr(target, "attr", None)
if name != "deprecated":
continue
where = f"{path.relative_to(REPO).as_posix()}:{fn.lineno} {fn.name}"
arg = dec.args[0] if isinstance(dec, ast.Call) and dec.args else None
found.append((where, arg.value if isinstance(arg, ast.Constant) else None))
return found
def test_markers_are_found():
assert len(_markers()) == sum(len(v) for v in DEPRECATED.values())
def test_no_marker_names_a_release_already_shipped():
"""3.7.0 shipped still warning that 35 methods are "removed in 3.7.0".
Once ``src.__version__`` reaches a marker's release, that release is here:
remove the method (if scripts/plugin_api_usage.py reports it unused) or
move the marker to a later release. Either way, never ship a warning that
names a version the user is already running.
"""
current = Version(__version__)
stale = [f"{where} -> {removal!r}" for where, removal in _markers()
if not isinstance(removal, str) or Version(removal) <= current]
assert not stale, (f"@deprecated markers at or below src.__version__ {__version__} "
f"(or not a literal version): {stale}")
@pytest.fixture(scope="module")
def usage_script():
path = REPO / "scripts" / "plugin_api_usage.py"
spec = importlib.util.spec_from_file_location("plugin_api_usage_script", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module # dataclasses resolve annotations through it
try:
spec.loader.exec_module(module)
yield module
finally:
sys.modules.pop(spec.name, None)
def test_usage_script_lists_exactly_the_pinned_markers(usage_script):
found = {(f"{m.module}.{m.owner}", m.method, m.removal)
for m in usage_script.find_markers(REPO)}
assert found == {(path, name, "3.8.0")
for path, names in DEPRECATED.items() for name in names}
def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path):
"""Calls on the owning object and overrides count; same-named methods of
unrelated classes and hits in test files do not."""
plugin = tmp_path / "demo"
(plugin / "test").mkdir(parents=True)
(plugin / "manager.py").write_text(textwrap.dedent("""\
class Icons:
@staticmethod
def draw_sun(img):
pass
class Plugin:
def draw_cloud(self):
return self.draw_cloud()
def display(self):
Icons.draw_sun(None)
self.cache_manager.update_cache('k', {})
dm = self.display_manager
dm.draw_rain(0, 0)
thing.get_scrolling_stats()
class MyFonts(FontManager):
def add_font(self, path, name):
return super().add_font(path, name)
"""), encoding="utf-8")
(plugin / "test" / "test_manager.py").write_text(textwrap.dedent("""\
def test_x(display_manager):
display_manager.draw_snow.assert_not_called()
"""), encoding="utf-8")
markers = usage_script.find_markers(REPO)
source = usage_script.Source("demo", "monorepo", plugin)
usage_script.scan_tree(source, [plugin], plugin, markers, core=False)
kinds = {key: sorted(("test " if h.test else "") + h.kind for h in hits)
for key, hits in source.hits.items()}
assert kinds == {
"DisplayManager.draw_sun": ["unrelated", "unrelated"],
"DisplayManager.draw_cloud": ["unrelated", "unrelated"],
"CacheManager.update_cache": ["call"],
"DisplayManager.draw_rain": ["call"],
"DisplayManager.get_scrolling_stats": ["review"],
"FontManager.add_font": ["call", "override"],
"DisplayManager.draw_snow": ["test call"],
}
status = usage_script.verdicts(markers, [source])
assert status["CacheManager.update_cache"][0] == "used"
assert status["DisplayManager.get_scrolling_stats"][0] == "review"
assert status["DisplayManager.draw_sun"][0] == "unused" # a collision only
assert status["DisplayManager.draw_snow"][0] == "unused" # a test mock only
def test_usage_script_follows_calls_between_deprecated_core_methods(usage_script):
"""draw_rain calls draw_cloud; with no outside callers both are unused."""
markers = usage_script.find_markers(REPO)
core = usage_script.Source("core", "core", REPO)
usage_script.scan_tree(core, [REPO / "src" / "display_manager.py"], REPO, markers, core=True)
kinds = {h.kind for h in core.hits["DisplayManager.draw_cloud"]}
assert kinds == {"internal"}
assert usage_script.verdicts(markers, [core])["DisplayManager.draw_cloud"][0] == "unused"
@pytest.fixture