Files
agessaman ba8e3f0229 fix(refactor): restore 24 module-level names the moves stopped binding, and guard all of them
Codex's final review found that moving code out of modules dropped imports that local plugins could import from them, for example `from modules.service_plugins.discord_bridge_service import EventType`, which would make such a plugin fail to load. Comparing every top-level name bound on dev against the branch (both trees imported, so names under TYPE_CHECKING are excluded) found 24 in 11 modules: EventType, copy and contextlib in both bridges; sha256, Iterable and hmac_mod in message_handler; RotatingFileHandler, colorlog, contextlib, contextvars and functools in core; hashlib and re in utils; contextlib in feed_manager, earthquake_service and weather_service; Any in cmd and help; time in path. Each is re-imported exactly as dev imported it.

The public-import guard only covered `from modules... import` statements, so it missed these. It now records every unconditional top-level import and assignment, regenerated from dev's tree (461 → 1,597 names), and fails if the discord bridge stops exposing EventType.
2026-10-03 22:13:19 -07:00

116 lines
4.7 KiB
Python

"""Characterization: every public name under modules/ that local plugins could use.
Local plugins and services (local/) may call any public module function,
class or method, so a refactor must not remove one. New names are fine; the
golden file only fails on removals (regenerate with UPDATE_GOLDEN=1 when a
removal is intentional).
Every other public name a module binds at top level counts too, whether it
imports it (from a project module, the standard library or a third-party
package) or assigns it: code and test patches can reach it as
``modules.<module>.<name>``. Those are recorded separately in
public_imports.json, generated from dev's tree.
"""
import ast
import json
from pathlib import Path
from tests.characterization.golden_util import GOLDEN_DIR
MODULES = Path(__file__).resolve().parents[2] / "modules"
GOLDEN = GOLDEN_DIR / "public_api.json"
IMPORTS_GOLDEN = GOLDEN_DIR / "public_imports.json"
def _public_names() -> dict[str, list[str]]:
names: dict[str, list[str]] = {}
for path in sorted(MODULES.rglob("*.py")):
rel = path.relative_to(MODULES.parent).as_posix()
found = set()
for node in ast.parse(path.read_text(encoding="utf-8")).body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_"):
found.add(node.name)
elif isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
found.add(node.name)
for member in node.body:
if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) and not member.name.startswith("_"):
found.add(f"{node.name}.{member.name}")
if found:
names[rel] = sorted(found)
return names
def _public_imports(root: Path = MODULES) -> dict[str, list[str]]:
"""Public names each module binds at top level by import or assignment.
Only unconditional top-level statements count, so names imported under
``if TYPE_CHECKING:`` (never bound at runtime) are left out.
"""
names: dict[str, list[str]] = {}
for path in sorted(root.rglob("*.py")):
rel = path.relative_to(root.parent).as_posix()
found = set()
for node in ast.parse(path.read_text(encoding="utf-8")).body:
if isinstance(node, (ast.Import, ast.ImportFrom)):
for alias in node.names:
bound = (alias.asname or alias.name).split(".")[0]
if bound != "*" and not bound.startswith("_"):
found.add(bound)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
for leaf in ast.walk(target):
if isinstance(leaf, ast.Name) and not leaf.id.startswith("_"):
found.add(leaf.id)
if found:
names[rel] = sorted(found)
return names
def _resolves(module: str, name: str) -> bool:
import importlib
obj = importlib.import_module(module[:-3].replace("/", "."))
for part in name.split("."):
if not hasattr(obj, part):
return False
obj = getattr(obj, part)
return True
def test_no_public_name_was_removed():
import os
current = _public_names()
if os.environ.get("UPDATE_GOLDEN") == "1":
GOLDEN.write_text(json.dumps(current, indent=1, sort_keys=True) + "\n", encoding="utf-8")
return
assert GOLDEN.exists(), f"{GOLDEN} missing; generate it with UPDATE_GOLDEN=1"
recorded = json.loads(GOLDEN.read_text(encoding="utf-8"))
missing = []
for module, names in recorded.items():
defined = set(current.get(module, []))
for name in names:
# A method moved to a base class, or a function re-exported, still counts.
if name not in defined and not _resolves(module, name):
missing.append(f"{module}: {name}")
assert missing == [], "public names removed:\n " + "\n ".join(missing)
def test_no_imported_public_name_was_dropped():
import os
if os.environ.get("UPDATE_GOLDEN") == "1":
IMPORTS_GOLDEN.write_text(json.dumps(_public_imports(), indent=1, sort_keys=True) + "\n", encoding="utf-8")
return
assert IMPORTS_GOLDEN.exists(), f"{IMPORTS_GOLDEN} missing; generate it with UPDATE_GOLDEN=1"
recorded = json.loads(IMPORTS_GOLDEN.read_text(encoding="utf-8"))
missing = [
f"{module}: {name}"
for module, names in recorded.items()
for name in names
if not _resolves(module, name)
]
assert missing == [], "imported public names no longer reachable:\n " + "\n ".join(missing)