MAINT-21: tests read and patch the Controller through tools/main_files.py

Prepares the split of app/main.py's Controller into files by job, with no app
change. tools/main_files.py gives a test the text and the AST of main.py plus
the files split from it, and `live`, which sets a global in every file whose
functions read it (and raises when none does). Tests that read main.py's
source, and tests and replay tools that patch a main global (datetime,
compute, resolve, _mono, WRITE_BUDGET_S, ...), go through it, so they pass on
this layout and on the split one. test_split_patches.py fails when a test or
tool patches an app.main attribute that main.py no longer reads, or that
another file also reads. tools/maint21_same_ast.py proves every function,
method and module-level assignment of main.py at the base is found unchanged
in its new file.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-09 00:27:07 +01:00
co-authored by Claude
parent 56fc41fffa
commit 0d49027daa
50 changed files with 707 additions and 179 deletions
+30 -9
View File
@@ -241,6 +241,27 @@ check("the output-clamp alarm quotes asked before sent",
# ============================================================================== #
print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
class _AppFile:
"""An app/*.py file. `main.py` stands for itself and the files split from
it (MAINT-21), so every scan below still sees the Controller whole."""
def __init__(self, path):
self.name, self.stem, self._path = path.name, path.stem, path
def read_text(self, encoding="utf-8"):
if self.name == "main.py":
return main_files.source()
return self._path.read_text(encoding=encoding)
def _app_py():
split = set(main_files.files()) - {"main.py"}
return [_AppFile(f) for f in sorted(APP.glob("*.py"))
if f.name not in split]
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
@@ -318,7 +339,7 @@ def notification_calls(tree):
bad_calls, n_calls = [], 0
for f in sorted(APP.glob("*.py")):
for f in _app_py():
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
n_calls += 1
payload = call.args[2] if len(call.args) > 2 else None
@@ -368,13 +389,13 @@ def _module_constants(tree):
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
for f in _app_py()}
def collect():
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
out = []
for f in sorted(APP.glob("*.py")):
for f in _app_py():
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
@@ -543,7 +564,7 @@ for key, text in messages.hint_samples():
p = row_problems(messages.GREEN, text)
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
_calls, _bare = 0, []
for f in sorted(APP.glob("*.py")):
for f in _app_py():
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if (isinstance(node, ast.Call)
and ast.unparse(node.func).endswith("log_event")):
@@ -1012,7 +1033,7 @@ def _static(spec):
the named file (placeholders count as one word, as in part 3)."""
fname, kind, name = spec
out = []
for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))):
for f in ([_AppFile(APP / fname)] if fname != "*" else _app_py()):
tree = ast.parse(f.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if kind == "call" and isinstance(node, ast.Call) and node.args \
@@ -1201,7 +1222,7 @@ def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))]
for f in _app_py()]
_long_seen = set()
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
for k, sample, p in _bad:
@@ -1415,7 +1436,7 @@ def _captured(logger_name, fn):
def _level_of(fname, words_in_line):
"""The level of the log call in `fname` whose literal holds the words."""
tree = ast.parse((APP / fname).read_text(encoding="utf-8"))
tree = ast.parse(_AppFile(APP / fname).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.args \
and re.search(r"_LOG\.\w+$", ast.unparse(node.func)):
@@ -1561,7 +1582,7 @@ check(f"a socket Home Assistant closed is a plain ConnectionResetError, which "
# words. The keys are found in the code: each `Claim.set(source, ..., reason)`
# through main.why_key(), plus main.py's own refusal to split ("unsplit").
_SOURCES = {}
for _f in sorted(APP.glob("*.py")):
for _f in _app_py():
_tree = ast.parse(_f.read_text(encoding="utf-8"))
_consts = _module_constants(_tree)
for _n in ast.walk(_tree):
@@ -1781,7 +1802,7 @@ check(f"...and in the action log, which an installer reads at the default "
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
_main_src = (APP / "main.py").read_text(encoding="utf-8")
_main_src = _AppFile(APP / "main.py").read_text(encoding="utf-8")
check("the action log counts 'engagements' with a real plural and the "
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src