mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-03 11:32:37 +00:00
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.
116 lines
4.7 KiB
Python
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)
|