"""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()