Files
goodwe-addon/goodwe_controller/tools/main_files.py
T
glenn schrooyenandClaude 0d49027daa 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>
2026-11-09 00:27:07 +01:00

99 lines
3.1 KiB
Python

"""MAINT-21: the Controller lives in app/main.py and the files split from it.
A test that reads `app/main.py` for a call, a comment or a def reads
`source()` instead; a test that replaces a global that `main` used to read
calls `patch_global()`, which sets it in each file whose code reads it. A name
no file reads is an error: a stale patch cannot pass unnoticed.
"""
import ast
import glob
import importlib
import os
import symtable
APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app")
def files():
"""main.py, rows.py and ctl_*.py, in that order, as bare file names."""
split = ["rows.py"] + sorted(os.path.basename(p)
for p in glob.glob(os.path.join(APP, "ctl_*.py")))
return ["main.py"] + [f for f in split
if os.path.exists(os.path.join(APP, f))]
def text(name):
with open(os.path.join(APP, name), encoding="utf-8") as fh:
return fh.read()
def source():
"""All the files' text, one after the other."""
return "\n".join(text(f) for f in files())
def tree(src=None):
"""`ast.parse(source())` with every `*Mixin` class folded into
`Controller`, so `Controller` has all its methods. Positions refer to
`source()`."""
src = source() if src is None else src
mod = ast.parse(src)
ctl = next(n for n in mod.body
if isinstance(n, ast.ClassDef) and n.name == "Controller")
keep = []
for n in mod.body:
if isinstance(n, ast.ClassDef) and n.name.endswith("Mixin"):
ctl.body.extend(n.body)
else:
keep.append(n)
mod.body = keep
return mod
def _function_reads(table, acc):
for s in table.get_symbols():
if (table.get_type() == "function" and s.is_global()
and s.is_referenced()):
acc.add(s.get_name())
for child in table.get_children():
_function_reads(child, acc)
def reading(name):
"""The bare module names (`main`, `ctl_cycle` ...) whose functions read
`name` as a global."""
out = []
for f in files():
acc = set()
_function_reads(symtable.symtable(text(f), f, "exec"), acc)
if name in acc:
out.append(f[:-3])
return out
class _Live:
"""The globals the Controller's code reads, wherever they are read.
`live.NAME = value` sets NAME in every file whose functions read it;
`live.NAME` reads it. A name no file reads is an AttributeError, so a
patch that changes nothing cannot pass unnoticed.
"""
def __getattr__(self, name):
mods = reading(name)
if not mods:
raise AttributeError(f"no file under app/ reads the global "
f"{name!r}")
return getattr(importlib.import_module("app." + mods[0]), name)
def __setattr__(self, name, value):
mods = reading(name)
if not mods:
raise AttributeError(f"no file under app/ reads the global "
f"{name!r}: a patch of it changes nothing")
for stem in mods:
setattr(importlib.import_module("app." + stem), name, value)
live = _Live()