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
+6 -7
View File
@@ -109,6 +109,8 @@ from app.validate import validate_startup_config
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
@@ -779,9 +781,7 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
"mutation back to a bare `engaged_now` is red rather than silent",
"arbitrage.guard_state(self.peak_guard)" in
ast.get_source_segment(
open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(),
_fn(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read(), "cycle")))
main_files.source(), _fn(main_files.source(), "cycle")))
# --------------------------------------------------------------------------- #
@@ -907,8 +907,8 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
# above calls decide() itself - and a required keyword-only parameter that no
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
_S45_MAIN = main_files.source()
_S45_MAIN_TREE = main_files.tree(_S45_MAIN)
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
@@ -2215,8 +2215,7 @@ print()
print("8. the background refresh - the tick must never await a fetch")
_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_MAIN_SRC = derived(main_files.source)
_cycle = derived(lambda: _fn(_MAIN_SRC, "cycle"))
_refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh"))
_read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs"))