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>
99 lines
3.1 KiB
Python
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()
|