""" Every function that writes a guarded field is listed here, with why it writes it. Some fields are written from many places, and those writers drift apart: one honours a lock or skips while a print runs, a sibling added later does not. This test makes a new writer a deliberate step. Adding a write to a guarded field fails here until the function is added to ``allowed`` below, which is the moment to check the existing writers' guards and give the new one the same. A listed function that no longer writes the field fails too, so the list stays the real list of writers. Found statically: attribute assignment (``spool.weight_used = ...``, ``+=``), the model constructor with the field as a keyword or with ``**kwargs``, ``.values(field=...)``, ``setattr(obj, "field", ...)`` and raw ``UPDATE `` SQL that names the field. Not found: ``setattr`` with a variable name (generic PATCH loops), so those are listed by hand where they reach the field. """ import ast import re from pathlib import Path BACKEND_DIR = Path(__file__).parent.parent.parent / "app" REPO_DIR = BACKEND_DIR.parent.parent GUARDED_FIELDS = [ { "field": "weight_used", "model": "Spool", "table": "spool", "allowed": { ("backend/app/main.py", "on_ams_change"): "AMS remain% sync while idle", ("backend/app/api/routes/inventory.py", "sync_weights_from_ams"): "manual Sync AMS weights", ("backend/app/api/routes/spoolbuddy.py", "update_spool_weight"): "SpoolBuddy scale reading", ("backend/app/services/usage_tracker.py", "_charge_spool"): ( "a print's usage (3MF, 3MF split, AMS remain% delta), added in SQL" ), ("backend/app/services/spool_tag_matcher.py", "create_spool_from_tray"): "new spool from an RFID tray", ("backend/app/core/database.py", "_migrate_repair_rfid_core_weight"): "one-shot #2909 backfill", ("backend/app/api/routes/inventory.py", "create_spool"): "Spool(**payload) from the add form", ("backend/app/api/routes/inventory.py", "bulk_create_spools"): "Spool(**payload) per bulk-added spool", ("backend/app/api/routes/inventory.py", "import_spools_csv"): "Spool(**row) from CSV import", }, # Reached through setattr loops or Spool(**payload) built from user input; # the static scan cannot see the field name, so these are listed by hand. "dynamic": { ("backend/app/api/routes/inventory.py", "update_spool"): "PATCH /spools/{id}", ("backend/app/api/routes/inventory.py", "bulk_update_spools"): "POST /spools/bulk-update", ("backend/app/services/github_restore.py", "_restore_spools"): "backup restore", }, }, ] class _WriterVisitor(ast.NodeVisitor): def __init__(self, rel_path: str, field: str, model: str, table: str): self.rel_path = rel_path self.field = field self.model = model self.sql = re.compile(rf"update\s+{table}\b.*\b{field}\b", re.IGNORECASE | re.DOTALL) self.scope: list[str] = [] self.hits: list[tuple[str, str, int]] = [] def _function(self) -> str: # Report the outermost function, so helpers nested inside it stay covered # by its entry. return self.scope[0] if self.scope else "" def _hit(self, node: ast.AST) -> None: self.hits.append((self.rel_path, self._function(), node.lineno)) def _enter(self, node) -> None: self.scope.append(node.name) self.generic_visit(node) self.scope.pop() def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self._enter(node) def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self._enter(node) def visit_ClassDef(self, node: ast.ClassDef) -> None: # Methods are reported by name only; the class adds nothing for the list. self.generic_visit(node) def visit_Assign(self, node: ast.Assign) -> None: for target in node.targets: for sub in ast.walk(target): if isinstance(sub, ast.Attribute) and sub.attr == self.field: self._hit(node) self.generic_visit(node) def visit_AugAssign(self, node: ast.AugAssign) -> None: if isinstance(node.target, ast.Attribute) and node.target.attr == self.field: self._hit(node) self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: func = node.func name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None if name in (self.model, "values") and any(kw.arg == self.field for kw in node.keywords): self._hit(node) if name == self.model and any(kw.arg is None for kw in node.keywords): self._hit(node) if ( name == "setattr" and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant) and node.args[1].value == self.field ): self._hit(node) self.generic_visit(node) def visit_Expr(self, node: ast.Expr) -> None: # A bare string statement is a docstring, not SQL. if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): return self.generic_visit(node) def visit_Constant(self, node: ast.Constant) -> None: if isinstance(node.value, str) and self.sql.search(node.value): self._hit(node) def _find_writers(field: str, model: str, table: str) -> dict[tuple[str, str], list[int]]: writers: dict[tuple[str, str], list[int]] = {} for path in sorted(BACKEND_DIR.rglob("*.py")): rel_path = path.relative_to(REPO_DIR).as_posix() visitor = _WriterVisitor(rel_path, field, model, table) visitor.visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))) for file, function, line in visitor.hits: writers.setdefault((file, function), []).append(line) return writers def _function_exists(rel_path: str, function: str) -> bool: tree = ast.parse((REPO_DIR / rel_path).read_text(encoding="utf-8")) return any( isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function for node in ast.walk(tree) ) class TestGuardedFieldWriters: def test_no_unlisted_writers(self): problems = [] for guard in GUARDED_FIELDS: found = _find_writers(guard["field"], guard["model"], guard["table"]) for (file, function), lines in sorted(found.items()): if (file, function) not in guard["allowed"] and (file, function) not in guard["dynamic"]: problems.append( f"{file}:{lines[0]} {function}() writes {guard['model']}.{guard['field']}, " "but is not listed. Compare its guards with the listed writers, then add it." ) assert not problems, "\n".join(problems) def test_listed_writers_still_write(self): problems = [] for guard in GUARDED_FIELDS: found = _find_writers(guard["field"], guard["model"], guard["table"]) for file, function in sorted(guard["allowed"]): if (file, function) not in found: problems.append( f"{file} {function}() is listed as a writer of {guard['model']}.{guard['field']} " "but no longer writes it. Remove it from the list." ) for file, function in sorted(guard["dynamic"]): if not _function_exists(file, function): problems.append(f"{file} {function}() is listed but no longer exists. Remove it from the list.") assert not problems, "\n".join(problems)