test_state_writers.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """
  2. Every function that writes a guarded field is listed here, with why it writes it.
  3. Some fields are written from many places, and those writers drift apart: one
  4. honours a lock or skips while a print runs, a sibling added later does not.
  5. This test makes a new writer a deliberate step. Adding a write to a guarded
  6. field fails here until the function is added to ``allowed`` below, which is
  7. the moment to check the existing writers' guards and give the new one the same.
  8. A listed function that no longer writes the field fails too, so the list stays
  9. the real list of writers.
  10. Found statically: attribute assignment (``spool.weight_used = ...``, ``+=``),
  11. the model constructor with the field as a keyword or with ``**kwargs``,
  12. ``.values(field=...)``, ``setattr(obj, "field", ...)`` and raw ``UPDATE <table>``
  13. SQL that names the field. Not found: ``setattr`` with a variable name (generic
  14. PATCH loops), so those are listed by hand where they reach the field.
  15. """
  16. import ast
  17. import re
  18. from pathlib import Path
  19. BACKEND_DIR = Path(__file__).parent.parent.parent / "app"
  20. REPO_DIR = BACKEND_DIR.parent.parent
  21. GUARDED_FIELDS = [
  22. {
  23. "field": "weight_used",
  24. "model": "Spool",
  25. "table": "spool",
  26. "allowed": {
  27. ("backend/app/main.py", "on_ams_change"): "AMS remain% sync while idle",
  28. ("backend/app/api/routes/inventory.py", "sync_weights_from_ams"): "manual Sync AMS weights",
  29. ("backend/app/api/routes/spoolbuddy.py", "update_spool_weight"): "SpoolBuddy scale reading",
  30. ("backend/app/services/usage_tracker.py", "on_print_complete"): "AMS remain% delta fallback",
  31. ("backend/app/services/usage_tracker.py", "_track_from_3mf"): "3MF per-filament usage",
  32. ("backend/app/services/spool_tag_matcher.py", "create_spool_from_tray"): "new spool from an RFID tray",
  33. ("backend/app/core/database.py", "_migrate_repair_rfid_core_weight"): "one-shot #2909 backfill",
  34. ("backend/app/api/routes/inventory.py", "create_spool"): "Spool(**payload) from the add form",
  35. ("backend/app/api/routes/inventory.py", "bulk_create_spools"): "Spool(**payload) per bulk-added spool",
  36. ("backend/app/api/routes/inventory.py", "import_spools_csv"): "Spool(**row) from CSV import",
  37. },
  38. # Reached through setattr loops or Spool(**payload) built from user input;
  39. # the static scan cannot see the field name, so these are listed by hand.
  40. "dynamic": {
  41. ("backend/app/api/routes/inventory.py", "update_spool"): "PATCH /spools/{id}",
  42. ("backend/app/api/routes/inventory.py", "bulk_update_spools"): "POST /spools/bulk-update",
  43. ("backend/app/services/github_restore.py", "_restore_spools"): "backup restore",
  44. },
  45. },
  46. ]
  47. class _WriterVisitor(ast.NodeVisitor):
  48. def __init__(self, rel_path: str, field: str, model: str, table: str):
  49. self.rel_path = rel_path
  50. self.field = field
  51. self.model = model
  52. self.sql = re.compile(rf"update\s+{table}\b.*\b{field}\b", re.IGNORECASE | re.DOTALL)
  53. self.scope: list[str] = []
  54. self.hits: list[tuple[str, str, int]] = []
  55. def _function(self) -> str:
  56. # Report the outermost function, so helpers nested inside it stay covered
  57. # by its entry.
  58. return self.scope[0] if self.scope else "<module>"
  59. def _hit(self, node: ast.AST) -> None:
  60. self.hits.append((self.rel_path, self._function(), node.lineno))
  61. def _enter(self, node) -> None:
  62. self.scope.append(node.name)
  63. self.generic_visit(node)
  64. self.scope.pop()
  65. def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
  66. self._enter(node)
  67. def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
  68. self._enter(node)
  69. def visit_ClassDef(self, node: ast.ClassDef) -> None:
  70. # Methods are reported by name only; the class adds nothing for the list.
  71. self.generic_visit(node)
  72. def visit_Assign(self, node: ast.Assign) -> None:
  73. for target in node.targets:
  74. for sub in ast.walk(target):
  75. if isinstance(sub, ast.Attribute) and sub.attr == self.field:
  76. self._hit(node)
  77. self.generic_visit(node)
  78. def visit_AugAssign(self, node: ast.AugAssign) -> None:
  79. if isinstance(node.target, ast.Attribute) and node.target.attr == self.field:
  80. self._hit(node)
  81. self.generic_visit(node)
  82. def visit_Call(self, node: ast.Call) -> None:
  83. func = node.func
  84. name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
  85. if name in (self.model, "values") and any(kw.arg == self.field for kw in node.keywords):
  86. self._hit(node)
  87. if name == self.model and any(kw.arg is None for kw in node.keywords):
  88. self._hit(node)
  89. if (
  90. name == "setattr"
  91. and len(node.args) >= 2
  92. and isinstance(node.args[1], ast.Constant)
  93. and node.args[1].value == self.field
  94. ):
  95. self._hit(node)
  96. self.generic_visit(node)
  97. def visit_Expr(self, node: ast.Expr) -> None:
  98. # A bare string statement is a docstring, not SQL.
  99. if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
  100. return
  101. self.generic_visit(node)
  102. def visit_Constant(self, node: ast.Constant) -> None:
  103. if isinstance(node.value, str) and self.sql.search(node.value):
  104. self._hit(node)
  105. def _find_writers(field: str, model: str, table: str) -> dict[tuple[str, str], list[int]]:
  106. writers: dict[tuple[str, str], list[int]] = {}
  107. for path in sorted(BACKEND_DIR.rglob("*.py")):
  108. rel_path = path.relative_to(REPO_DIR).as_posix()
  109. visitor = _WriterVisitor(rel_path, field, model, table)
  110. visitor.visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)))
  111. for file, function, line in visitor.hits:
  112. writers.setdefault((file, function), []).append(line)
  113. return writers
  114. def _function_exists(rel_path: str, function: str) -> bool:
  115. tree = ast.parse((REPO_DIR / rel_path).read_text(encoding="utf-8"))
  116. return any(
  117. isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function for node in ast.walk(tree)
  118. )
  119. class TestGuardedFieldWriters:
  120. def test_no_unlisted_writers(self):
  121. problems = []
  122. for guard in GUARDED_FIELDS:
  123. found = _find_writers(guard["field"], guard["model"], guard["table"])
  124. for (file, function), lines in sorted(found.items()):
  125. if (file, function) not in guard["allowed"] and (file, function) not in guard["dynamic"]:
  126. problems.append(
  127. f"{file}:{lines[0]} {function}() writes {guard['model']}.{guard['field']}, "
  128. "but is not listed. Compare its guards with the listed writers, then add it."
  129. )
  130. assert not problems, "\n".join(problems)
  131. def test_listed_writers_still_write(self):
  132. problems = []
  133. for guard in GUARDED_FIELDS:
  134. found = _find_writers(guard["field"], guard["model"], guard["table"])
  135. for file, function in sorted(guard["allowed"]):
  136. if (file, function) not in found:
  137. problems.append(
  138. f"{file} {function}() is listed as a writer of {guard['model']}.{guard['field']} "
  139. "but no longer writes it. Remove it from the list."
  140. )
  141. for file, function in sorted(guard["dynamic"]):
  142. if not _function_exists(file, function):
  143. problems.append(f"{file} {function}() is listed but no longer exists. Remove it from the list.")
  144. assert not problems, "\n".join(problems)