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:
co-authored by
Claude
parent
56fc41fffa
commit
0d49027daa
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user