Files
meshcore-bot/tests/characterization/test_public_api.py
T
agessaman 8f7a707bba refactor: restore 21 imported names earlier slices stopped exposing, and guard them
A codex review found that moving dashboard code out of app.py dropped `modules.web_viewer.app.DashboardStatsService`. The public-API golden only recorded names a module defines, so I added a second one, `public_imports.json`, recording the public names each module imports from another project module, generated from dev's tree as the baseline. It found 20 more names earlier refactor commits had stopped importing (the Nominatim and MQTT-weather helpers in wx, gwx, aqi and solarforecast; `PayloadVersion`, the region-warning verdicts and `decode_path_len_byte` in message_handler; two feed helpers and two feed limits in the web viewer). All are imported again as re-exports, so `modules.<module>.<name>` and patches of it resolve as they did on dev.
2026-10-01 19:36:29 -07:00

105 lines
4.0 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).
Public names a module imports from another project module count too: code
(and test patches) can reach them as ``modules.<module>.<name>``. Those are
recorded separately in public_imports.json.
"""
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 by importing them from a project module."""
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 not isinstance(node, ast.ImportFrom):
continue
if node.level == 0 and not (node.module or "").startswith("modules"):
continue
for alias in node.names:
bound = alias.asname or alias.name
if bound != "*" and not bound.startswith("_"):
found.add(bound)
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" or not GOLDEN.exists():
GOLDEN.write_text(json.dumps(current, indent=1, sort_keys=True) + "\n", encoding="utf-8")
return
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" or not IMPORTS_GOLDEN.exists():
IMPORTS_GOLDEN.write_text(json.dumps(_public_imports(), indent=1, sort_keys=True) + "\n", encoding="utf-8")
return
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)