1052 lines
40 KiB
Python
1052 lines
40 KiB
Python
"""
|
|
Prove the pipeline, offline, on a tree it builds itself.
|
|
|
|
python3 selftest.py # or: make check
|
|
|
|
No network, nothing installed. The render steps skip with a message when
|
|
graphviz is absent rather than failing — a machine without `dot` can still check
|
|
extraction, the IR and the style layer, and saying so is more useful than a red
|
|
mark about the host.
|
|
|
|
**A check that only ever proves things work is not worth running.** So the
|
|
negative cases carry equal weight, and the four checks that are the *design*
|
|
rather than a regression are marked below. If anything ever gets cut, those are
|
|
the ones to keep:
|
|
|
|
the IR carries no visual field extractors cannot decide appearance
|
|
an emitter never reads a source file the layering, from the other side
|
|
style names slots, not colours one colour language, not three
|
|
ids are stable across runs without it, diffing is noise
|
|
|
|
## Golden tests go on the IR, never on the SVG
|
|
|
|
Graphviz measures label text with the host's fonts to size nodes, so identical
|
|
input produces different geometry on a machine with different fontconfig. The IR
|
|
is deterministic; the SVG is not. Pinning the wrong one gives a suite that fails
|
|
on someone else's laptop for no reason anyone can act on.
|
|
"""
|
|
|
|
import ast
|
|
import json
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE.parent))
|
|
|
|
PKG = HERE.name
|
|
ir_mod = __import__(f"{PKG}.ir", fromlist=["*"])
|
|
ir_validate = __import__(f"{PKG}.ir.validate", fromlist=["*"])
|
|
py_ex = __import__(f"{PKG}.extractors.python", fromlist=["*"])
|
|
style_mod = __import__(f"{PKG}.style", fromlist=["*"])
|
|
dot_mod = __import__(f"{PKG}.emitters.dot", fromlist=["*"])
|
|
index_mod = __import__(f"{PKG}.emitters.index", fromlist=["*"])
|
|
ops_mod = __import__(f"{PKG}.ops", fromlist=["*"])
|
|
erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"])
|
|
nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"])
|
|
spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"])
|
|
db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"])
|
|
usage_ex = __import__(f"{PKG}.extractors.usage", fromlist=["*"])
|
|
|
|
check_ir = ir_mod.check
|
|
Style, StyleError = style_mod.Style, style_mod.StyleError
|
|
|
|
PASS, FAIL, SKIP = [], [], []
|
|
|
|
# A docstring that could not plausibly be a style value or a module name, so
|
|
# finding it somewhere it should not be means something really read it.
|
|
MARKER = "Quarterly Revenue Ledger"
|
|
|
|
FIXTURE = {
|
|
"app/__init__.py": '"""The app package."""\nfrom .db import Base\n',
|
|
"app/db.py": '"""Database plumbing."""\n\n\nclass Base:\n """Declarative base."""\n',
|
|
"app/models.py": (
|
|
'"""Domain models."""\n'
|
|
"from .db import Base\n"
|
|
"import json\n"
|
|
"from third_party.orm import Mixin\n"
|
|
"\n\n"
|
|
"class User(Base):\n"
|
|
f' """{MARKER}."""\n'
|
|
"\n"
|
|
" def save(self):\n"
|
|
" pass\n"
|
|
"\n\n"
|
|
"class Admin(User, Mixin):\n"
|
|
" pass\n"
|
|
),
|
|
"app/sub/deep.py": "from ..db import Base\n\n\nclass Deep(Base):\n class Inner:\n pass\n",
|
|
"app/broken.py": "def oops(:\n", # must not cost the run
|
|
}
|
|
|
|
|
|
def check(name, condition, detail=""):
|
|
(PASS if condition else FAIL).append(name)
|
|
print(f" {'ok ' if condition else 'FAIL'} {name}"
|
|
+ (f"\n {detail}" if detail and not condition else ""))
|
|
return condition
|
|
|
|
|
|
def _err(fn):
|
|
"""The message from a call that is expected to fail."""
|
|
try:
|
|
fn()
|
|
except Exception as e: # noqa: BLE001 - the message is the assertion
|
|
return str(e)
|
|
return ""
|
|
|
|
|
|
def skip(name, why):
|
|
SKIP.append(name)
|
|
print(f" -- {name} ({why})")
|
|
|
|
|
|
def build_tree(root: Path):
|
|
for rel, text in FIXTURE.items():
|
|
p = root / rel
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(text)
|
|
|
|
|
|
tmp = tempfile.TemporaryDirectory(prefix="docgen-selftest-")
|
|
ROOT = Path(tmp.name) / "fx"
|
|
build_tree(ROOT)
|
|
|
|
ir = py_ex.extract(ROOT).to_dict()
|
|
ids = {n["id"] for n in ir["nodes"]}
|
|
edges = {(e["source"], e["target"], e["kind"]) for e in ir["edges"]}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n1. extraction — is it analysis, or is it guessing")
|
|
|
|
check("the IR validates", check_ir(ir) == [], str(check_ir(ir)[:3]))
|
|
|
|
# The claim the whole thing rests on: `ast` hands over the string "Base", and
|
|
# two-pass resolution turns it into the id of the thing it actually names.
|
|
check(
|
|
"a relative import resolves to the real id",
|
|
("app.models.User", "app.db.Base", "inherits") in edges,
|
|
f"got {sorted(e for e in edges if e[2] == 'inherits')}",
|
|
)
|
|
check(
|
|
"a relative import two levels up resolves",
|
|
("app.sub.deep.Deep", "app.db.Base", "inherits") in edges,
|
|
)
|
|
check(
|
|
"a base defined in the same module resolves",
|
|
("app.models.Admin", "app.models.User", "inherits") in edges,
|
|
)
|
|
check(
|
|
"a package's own __init__ resolves against itself",
|
|
("app", "app.db", "imports") in edges,
|
|
"from .db inside app/__init__.py must mean app.db, not db",
|
|
)
|
|
check(
|
|
"no raw source name leaks in as an id",
|
|
"Base" not in ids and "User" not in ids,
|
|
f"unresolved names present as bare ids: {sorted(ids & {'Base', 'User'})}",
|
|
)
|
|
|
|
# Dropping an edge you cannot resolve is the worse failure: the diagram looks
|
|
# complete and is quietly missing a dependency.
|
|
externals = {n["id"] for n in ir["nodes"] if n["kind"] == "external"}
|
|
check(
|
|
"an unresolvable name survives as `external`",
|
|
"third_party.orm.Mixin" in externals,
|
|
f"got {sorted(externals)}",
|
|
)
|
|
check(
|
|
"and it keeps its edge",
|
|
("app.models.Admin", "third_party.orm.Mixin", "inherits") in edges,
|
|
)
|
|
|
|
check(
|
|
"nested definitions are reached",
|
|
"app.sub.deep.Deep.Inner" in ids and "app.models.User.save" in ids,
|
|
"a missing generic_visit() silently loses everything below a definition",
|
|
)
|
|
check(
|
|
"containment is recorded",
|
|
next(n for n in ir["nodes"] if n["id"] == "app.models.User.save")["parent"]
|
|
== "app.models.User",
|
|
)
|
|
# Spans, not just a start line. "This class is 400 lines" is structure, and a
|
|
# density map sizes a block by it — `ast` carries end_lineno, so recording only
|
|
# where something begins throws away half the fact for nothing.
|
|
spans = [n for n in ir["nodes"] if n["kind"] in ("class", "function")]
|
|
check(
|
|
"a construct records how many lines it spans",
|
|
all((n.get("attrs") or {}).get("lines", 0) >= 1 for n in spans),
|
|
f"missing on {[n['id'] for n in spans if not (n.get('attrs') or {}).get('lines')][:3]}",
|
|
)
|
|
check(
|
|
"a multi-line class spans more than one",
|
|
any((n.get("attrs") or {}).get("lines", 0) > 1 for n in spans),
|
|
)
|
|
check(
|
|
"a module records its length",
|
|
all((n.get("attrs") or {}).get("lines", 0) >= 1
|
|
for n in ir["nodes"] if n["kind"] == "module"
|
|
and not (n.get("attrs") or {}).get("error")),
|
|
)
|
|
check(
|
|
"a span is not a visual field",
|
|
check_ir(ir) == [],
|
|
"`lines` is a fact about the code, not about how it is drawn",
|
|
)
|
|
|
|
check(
|
|
"source anchors are carried",
|
|
all(
|
|
(n.get("attrs") or {}).get("file")
|
|
for n in ir["nodes"]
|
|
if n["kind"] in ("class", "function")
|
|
),
|
|
)
|
|
|
|
broken = [n for n in ir["nodes"] if (n.get("attrs") or {}).get("error")]
|
|
check(
|
|
"an unparseable file is recorded, not fatal",
|
|
len(broken) == 1 and "broken.py" in broken[0]["attrs"]["file"],
|
|
f"got {[(n['id'], n['attrs'].get('error')) for n in broken]}",
|
|
)
|
|
check("...and the rest of the tree still extracted", len(ids) > 10)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n2. the design rules")
|
|
|
|
# (design) Without this the diff emitter reports noise and nobody trusts it.
|
|
again = py_ex.extract(ROOT).to_dict()
|
|
check(
|
|
"ids are stable — the same tree twice is the same bytes",
|
|
json.dumps(ir, sort_keys=True) == json.dumps(again, sort_keys=True),
|
|
)
|
|
|
|
# (design) An extractor that sets a colour decides how the graph looks forever.
|
|
visual = set()
|
|
for n in ir["nodes"]:
|
|
visual |= set(n.get("attrs") or {}) & ir_validate.VISUAL_KEYS
|
|
for e in ir["edges"]:
|
|
visual |= set(e.get("attrs") or {}) & ir_validate.VISUAL_KEYS
|
|
check("no visual field reaches the IR", not visual, f"found {sorted(visual)}")
|
|
|
|
check(
|
|
"the IR is readable without this package",
|
|
isinstance(json.loads(json.dumps(ir)), dict),
|
|
"it must be plain JSON, not something that needs a library to open",
|
|
)
|
|
|
|
# (design) The layering, checked from the emitter side. Parsed, not grepped:
|
|
# a docstring mentioning `ast` is prose, an import is a dependency.
|
|
offenders = []
|
|
for src in sorted((HERE / "emitters").glob("*.py")):
|
|
tree = ast.parse(src.read_text(), str(src))
|
|
for node in ast.walk(tree):
|
|
names = []
|
|
if isinstance(node, ast.Import):
|
|
names = [a.name for a in node.names]
|
|
elif isinstance(node, ast.ImportFrom):
|
|
names = [node.module or ""]
|
|
for name in names:
|
|
if name.split(".")[0] in {"ast", "sqlalchemy", "griffe"}:
|
|
offenders.append(f"{src.name}:{node.lineno} imports {name}")
|
|
check(
|
|
"no emitter reads source — it has never heard of Python or SQL",
|
|
not offenders,
|
|
"; ".join(offenders),
|
|
)
|
|
|
|
extractor_offenders = []
|
|
for src in sorted((HERE / "extractors").rglob("*.py")):
|
|
tree = ast.parse(src.read_text(), str(src))
|
|
for node in ast.walk(tree):
|
|
names = []
|
|
if isinstance(node, ast.Import):
|
|
names = [a.name for a in node.names]
|
|
elif isinstance(node, ast.ImportFrom):
|
|
names = [node.module or ""]
|
|
if any(n and ("emitters" in n or "style" in n) for n in names):
|
|
extractor_offenders.append(f"{src.name}:{node.lineno}")
|
|
check(
|
|
"no extractor knows about style or emitters",
|
|
not extractor_offenders,
|
|
"; ".join(extractor_offenders),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n3. style — one colour language")
|
|
|
|
styles = Style.available()
|
|
check("a style ships", "lucid" in styles, f"found {styles}")
|
|
|
|
lucid = Style.load("lucid") # the default: dark
|
|
print_theme = Style.load("lucid", theme="lucid") # the print palette
|
|
|
|
check("it carries more than one theme", set(lucid.themes()) >= {"lucid", "dark"})
|
|
check(
|
|
"an unknown kind falls back rather than crashing",
|
|
lucid.node("no-such-kind-at-all") == lucid.node("default") != {},
|
|
)
|
|
|
|
# (design) A rule naming a hex is a rule outside the shared vocabulary.
|
|
raw = json.loads((HERE / "style" / "lucid.json").read_text())
|
|
leaked = []
|
|
for section in ("nodes", "groups", "edges", "graph"):
|
|
for kind, rule in (raw.get(section) or {}).items():
|
|
if not isinstance(rule, dict):
|
|
continue
|
|
for key, value in rule.items():
|
|
if isinstance(value, str) and re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value):
|
|
leaked.append(f"{section}.{kind}.{key}={value}")
|
|
check(
|
|
"style rules name slots, never colours",
|
|
not leaked,
|
|
"; ".join(leaked) + " <- the colour language has stopped being one language",
|
|
)
|
|
|
|
# A theme that defines only some of the slots renders half a diagram in the
|
|
# right colours and the rest in whatever DOT does with an empty string.
|
|
half_bound = []
|
|
for theme in lucid.themes():
|
|
try:
|
|
Style.load("lucid", theme=theme)
|
|
except StyleError as e:
|
|
half_bound.append(f"{theme}: {e}")
|
|
check("every slot resolves in every theme", not half_bound, "; ".join(half_bound))
|
|
|
|
# The point of binding to spr's models: a diagram and the page around it must
|
|
# not drift apart. docs/graphs/README.md states the rule.
|
|
SYSTEM_ACCENTS = {
|
|
"artery": ("artery/index.html", "#b91c1c"),
|
|
"atlas": ("atlas/index.html", "#15803d"),
|
|
"station": ("station/index.html", "#1d4ed8"),
|
|
}
|
|
spr_root = HERE.parent.parent
|
|
mismatched = []
|
|
for slot, (page, expected) in SYSTEM_ACCENTS.items():
|
|
got = lucid.slot(slot).lower()
|
|
if got != expected:
|
|
mismatched.append(f"{slot}: style has {got}, {page} sets {expected}")
|
|
src = spr_root / page
|
|
if src.exists() and expected not in src.read_text():
|
|
mismatched.append(f"{slot}: {page} no longer sets {expected}")
|
|
check(
|
|
"the dark theme's slots are spr's own --system-accent values",
|
|
not mismatched,
|
|
"; ".join(mismatched),
|
|
)
|
|
|
|
check(
|
|
"domain -> slot is a mapping, not a colour choice",
|
|
lucid.domain_slot("atlas") == "atlas" and lucid.domain_slot("station") == "station",
|
|
)
|
|
check(
|
|
"an undomained group still gets a deterministic slot",
|
|
[lucid.domain_slot(None, i) for i in range(4)]
|
|
== [lucid.domain_slot(None, i) for i in range(4)],
|
|
)
|
|
|
|
check(
|
|
"the style records where DOT runs out",
|
|
{"header-bar", "dasharray", "sequence-badge"} <= set(lucid.limits()),
|
|
f"got {sorted(lucid.limits())}",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n4. emitters")
|
|
|
|
dot_text = dot_mod.emit(ir, lucid)
|
|
check("DOT is produced", dot_text.startswith("digraph ir {"))
|
|
check(
|
|
"every IR edge reaches the DOT",
|
|
dot_text.count("->") == len(ir["edges"]),
|
|
f"{dot_text.count('->')} of {len(ir['edges'])} — cluster endpoints need lhead/ltail",
|
|
)
|
|
check("the profile's colours are applied", lucid.node("class")["border"] in dot_text)
|
|
check(
|
|
"ids and kinds reach the output for a front end to bind to",
|
|
'id="app.models.User"' in dot_text and 'class="class"' in dot_text,
|
|
)
|
|
check("source anchors become links", "href=" in dot_text)
|
|
|
|
print_text = dot_mod.emit(ir, print_theme)
|
|
check(
|
|
"one IR, two looks, no re-extraction",
|
|
print_text != dot_text and print_theme.slot("surface-0") in print_text,
|
|
)
|
|
|
|
md = index_mod.to_markdown(ir)
|
|
check("the index names what exists", "app.models" in md or "models" in md)
|
|
check("the index carries the docstrings", MARKER in md)
|
|
check(
|
|
"the index reports the dependency surface",
|
|
"third_party.orm.Mixin" in md,
|
|
"externals are the thing a reader most often wants and a diagram scatters",
|
|
)
|
|
check("the index reports what could not be parsed", "Not parsed" in md)
|
|
|
|
sidebar = index_mod.to_sidebar(ir)
|
|
check("the sidebar is nested, not flat", any(i.get("children") for i in sidebar["items"]))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n5. views")
|
|
|
|
no_std = ops_mod.drop_stdlib(ir)
|
|
check(
|
|
"dropping the stdlib removes json and keeps third-party",
|
|
"json" not in {n["id"] for n in no_std["nodes"]}
|
|
and "third_party.orm.Mixin" in {n["id"] for n in no_std["nodes"]},
|
|
)
|
|
check("a view still validates", check_ir(no_std) == [], str(check_ir(no_std)[:3]))
|
|
|
|
classes = ops_mod.only_kinds(ir, {"class"})
|
|
check(
|
|
"keeping one kind keeps its ancestors, so containment survives",
|
|
check_ir(classes) == [] and "app.models" in {n["id"] for n in classes["nodes"]},
|
|
)
|
|
|
|
around = ops_mod.neighbourhood(ir, "app.db.Base", hops=1)
|
|
check(
|
|
"a neighbourhood keeps what points at it",
|
|
"app.models.User" in {n["id"] for n in around["nodes"]} and check_ir(around) == [],
|
|
)
|
|
|
|
shallow = ops_mod.collapse_to_depth(ir, 1)
|
|
check(
|
|
"collapsing to a depth drops what is inside",
|
|
"app.models.User.save" not in {n["id"] for n in shallow["nodes"]}
|
|
and check_ir(shallow) == [],
|
|
)
|
|
|
|
# The difference between a simpler picture and a wrong one. `app.models.User`
|
|
# inherits from `app.db.Base`; look at the module level and that is a dependency
|
|
# of app.models on app.db. Dropping the edge says they are unrelated.
|
|
mods = ops_mod.only_kinds(ir, {"module"})
|
|
mod_edges = {(e["source"], e["target"], e["kind"]) for e in mods["edges"]}
|
|
check(
|
|
"an edge is lifted to the surviving node, not dropped",
|
|
("app.models", "app.db", "inherits") in mod_edges,
|
|
f"got {sorted(mod_edges)}",
|
|
)
|
|
check(
|
|
"a lifted edge says how many it stands for",
|
|
any((e.get("attrs") or {}).get("weight") for e in mods["edges"]),
|
|
)
|
|
check(
|
|
"lifting never leaves a self-loop",
|
|
not [e for e in mods["edges"] if e["source"] == e["target"]],
|
|
"both ends inside one node is a fact about that node, not a line",
|
|
)
|
|
check(
|
|
"a label that no longer applies is dropped with the lift",
|
|
not any(
|
|
(e.get("attrs") or {}).get("label")
|
|
for e in mods["edges"]
|
|
if (e.get("attrs") or {}).get("weight")
|
|
),
|
|
)
|
|
|
|
# The default view: what you get without knowing which filters to ask for.
|
|
ov = ops_mod.overview(ir)
|
|
ov_kinds = {n["kind"] for n in ov["nodes"]}
|
|
check(
|
|
"the python overview is modules and what is outside",
|
|
ov_kinds <= {"module", "external"} and check_ir(ov) == [],
|
|
f"got {sorted(ov_kinds)}",
|
|
)
|
|
check(
|
|
"it drops the stdlib but keeps third-party",
|
|
"json" not in {n["id"] for n in ov["nodes"]}
|
|
and "third_party.orm.Mixin" in {n["id"] for n in ov["nodes"]},
|
|
)
|
|
# Depth is the tempting knob and the wrong one: a directory without
|
|
# `__init__.py` is not a package, so its modules have no parent and sit at
|
|
# depth 0. soleprint has 173 such roots.
|
|
fragmented = ops_mod.overview(
|
|
{"meta": {"source": "python", "root": "x", "schema_version": "1"},
|
|
"nodes": [{"id": "loose", "kind": "module", "label": "loose", "parent": None, "attrs": {}},
|
|
{"id": "loose.C", "kind": "class", "label": "C", "parent": "loose", "attrs": {}}],
|
|
"edges": []}
|
|
)
|
|
check(
|
|
"a parentless module still reduces to its module",
|
|
[n["id"] for n in fragmented["nodes"]] == ["loose"],
|
|
"depth-based collapsing would have kept the class; kind-based does not care",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n5b. shape — knowing the picture before rendering it")
|
|
|
|
# Aspect ratio is a property of the graph, not of the renderer. A layered
|
|
# engine puts one dependency level in one row, so the widest level is the
|
|
# width. Measured on soleprint: 7 levels x 109 nodes rendered 14:1, and no
|
|
# layout flag helped — LR merely rotated it (1818x11183), ratio=compress
|
|
# squashed it to an unreadable 1008x75.
|
|
wide = {
|
|
"meta": {"source": "python", "root": "x", "schema_version": "1"},
|
|
"nodes": [{"id": f"n{i}", "kind": "module", "label": f"n{i}", "parent": None, "attrs": {}}
|
|
for i in range(40)] +
|
|
[{"id": "hub", "kind": "module", "label": "hub", "parent": None, "attrs": {}}],
|
|
"edges": [{"source": f"n{i}", "target": "hub", "kind": "imports", "attrs": {}}
|
|
for i in range(40)],
|
|
}
|
|
sh = ops_mod.shape(wide)
|
|
check("shape counts the widest level", sh["widest_level"] == 40, str(sh))
|
|
check("shape counts levels", sh["levels"] == 2, str(sh))
|
|
check("shape finds isolated nodes", ops_mod.shape(ir)["isolated"] >= 0)
|
|
check(
|
|
"shape needs no renderer",
|
|
isinstance(sh["nodes"], int) and "width" not in sh,
|
|
"it is computed from the IR, so it can warn before writing a 375 KB image",
|
|
)
|
|
|
|
parts = ops_mod.split(ir)
|
|
check("split produces one document per subsystem", len(parts) >= 1, str(list(parts)))
|
|
check("each part still validates", all(check_ir(p) == [] for p in parts.values()))
|
|
check(
|
|
"a crossing edge is kept on the side that reaches out",
|
|
any(p["edges"] for p in parts.values()),
|
|
"dropping it would understate the coupling",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n6. render")
|
|
|
|
if not dot_mod.have_graphviz():
|
|
skip("render", "graphviz not installed — sudo apt install graphviz")
|
|
else:
|
|
svg = dot_mod.render(dot_text)
|
|
check("SVG comes out", svg.startswith(b"<?xml") and b"</svg>" in svg)
|
|
check("the SVG is addressable", b'id="app.models.User"' in svg)
|
|
check(
|
|
"kinds survive as classes for CSS to reach",
|
|
b'class="node class"' in svg and b'class="edge inherits"' in svg,
|
|
)
|
|
print_svg = dot_mod.render(print_text)
|
|
check("the two themes really differ", print_svg != svg)
|
|
|
|
# `fontname="Arial Bold"` passes through as a family no browser has, so it
|
|
# renders neither Arial nor bold. Only the PostScript spelling produces a
|
|
# real weight — and a correctly measured box to hold it.
|
|
check(
|
|
"labels are actually bold, not nominally bold",
|
|
b'font-weight="bold"' in svg,
|
|
"no font-weight in the SVG — the face name did not map to a weight",
|
|
)
|
|
check(
|
|
"the font resolves to a real stack",
|
|
b'font-family="Helvetica,sans-Serif"' in svg,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n7. a second domain — the schema checkpoint")
|
|
|
|
# The brief makes this a checkpoint: if tables and foreign keys need the schema
|
|
# contorted, the schema is wrong and must be fixed before more extractors.
|
|
SCHEMA = {
|
|
"models": {
|
|
"Customer": {"doc": "A buyer.", "fields": {"id": {"type": "int", "pk": True},
|
|
"email": {"type": "str"}}},
|
|
"Invoice": {"fields": {"id": {"type": "int", "pk": True},
|
|
"customer_id": {"type": "FK:Customer"},
|
|
"due_at": {"type": "datetime", "nullable": True}}},
|
|
"Tag": {"fields": {"id": {"type": "int", "pk": True},
|
|
"invoices": {"type": "M2M:Invoice"}}},
|
|
"Orphan": {"fields": {"ref": {"type": "FK:NotDefinedHere"}}},
|
|
}
|
|
}
|
|
db_ir = db_ex.from_schema_dict(SCHEMA, root="fixture").to_dict()
|
|
db_ids = {n["id"] for n in db_ir["nodes"]}
|
|
db_edges = {(e["source"], e["target"], e["kind"]) for e in db_ir["edges"]}
|
|
db_kinds = {n["kind"] for n in db_ir["nodes"]}
|
|
|
|
check("the db IR validates", check_ir(db_ir) == [], str(check_ir(db_ir)[:3]))
|
|
check(
|
|
"it needed no new top-level field",
|
|
set(db_ir) == set(ir),
|
|
f"{sorted(set(db_ir) ^ set(ir))} — the schema did not survive the second domain",
|
|
)
|
|
check("tables and columns are kinds, not fields", db_kinds >= {"table", "column"})
|
|
check(
|
|
"a column is contained by its table",
|
|
next(n for n in db_ir["nodes"] if n["id"] == "Invoice.customer_id")["parent"] == "Invoice",
|
|
)
|
|
check("a foreign key is an edge", ("Invoice", "Customer", "foreign_key") in db_edges)
|
|
check("a many-to-many is a different edge kind", ("Tag", "Invoice", "references") in db_edges)
|
|
check(
|
|
"domain detail lives in attrs, not in new columns",
|
|
next(n for n in db_ir["nodes"] if n["id"] == "Customer.id")["attrs"].get("pk") is True,
|
|
)
|
|
check(
|
|
"a key naming a table the schema does not define survives as external",
|
|
"NotDefinedHere" in db_ids
|
|
and next(n for n in db_ir["nodes"] if n["id"] == "NotDefinedHere")["kind"] == "external",
|
|
)
|
|
|
|
# "Done means: adding a source type costs one adapter and every existing emitter
|
|
# works on it unchanged." This is that claim, executed.
|
|
db_md = index_mod.to_markdown(db_ir)
|
|
check("the index emitter needed no change for a new domain", "Customer" in db_md)
|
|
db_dot = dot_mod.emit(db_ir, lucid)
|
|
check("the dot emitter needed no change either", db_dot.startswith("digraph ir {"))
|
|
check(
|
|
"an unstyled kind still renders, via `default`",
|
|
lucid.node("table") == lucid.node("default"),
|
|
"no `table` rule exists yet, and the diagram is still legible",
|
|
)
|
|
check(
|
|
"the same view filters both domains",
|
|
check_ir(ops_mod.only_kinds(db_ir, {"table"})) == [],
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n8. the structure picks the drawing")
|
|
|
|
# The lesson this encodes: a diagram that fights its layout engine is usually
|
|
# the wrong kind of diagram. The same 24-table schema rendered 32034x136 through
|
|
# `dot` (235:1) and 1740x1860 through `erd` (0.9:1) — not because one engine is
|
|
# better, but because a schema is peer entities with references, and drawing it
|
|
# in dependency ranks was never its shape.
|
|
verdict_db = ops_mod.classify(db_ir)
|
|
check("a schema is classified as entities", verdict_db["kind"] == "erd", str(verdict_db))
|
|
check("...and routed to the card emitter", verdict_db["emitter"] == "erd")
|
|
|
|
verdict_code = ops_mod.classify(ops_mod.overview(ir))
|
|
check(
|
|
"a small module graph still goes to ranks",
|
|
verdict_code["emitter"] == "dot",
|
|
str(verdict_code),
|
|
)
|
|
|
|
sheet = {
|
|
"meta": {"source": "python", "root": "x", "schema_version": "1"},
|
|
"nodes": [{"id": f"n{i}", "kind": "module", "label": f"n{i}", "parent": None, "attrs": {}}
|
|
for i in range(40)] +
|
|
[{"id": "hub", "kind": "module", "label": "hub", "parent": None, "attrs": {}}],
|
|
"edges": [{"source": f"n{i}", "target": "hub", "kind": "imports", "attrs": {}}
|
|
for i in range(40)],
|
|
}
|
|
verdict_sheet = ops_mod.classify(sheet)
|
|
check(
|
|
"a 40-wide level is called a sheet, not a diagram",
|
|
verdict_sheet["kind"] == "sheet" and verdict_sheet["emitter"] == "index",
|
|
str(verdict_sheet),
|
|
)
|
|
check("every verdict explains itself", all(
|
|
ops_mod.classify(g)["why"] for g in (db_ir, sheet, ir)
|
|
), "advice without a reason gets overridden")
|
|
|
|
# The property that makes the card layout work, and the one dot cannot offer.
|
|
svg = erd_mod.emit(db_ir, lucid)
|
|
import re as _re
|
|
m = _re.search(r'width="(\d+)pt" height="(\d+)pt"', svg)
|
|
w, h = int(m.group(1)), int(m.group(2))
|
|
check(
|
|
f"the ER layout stays near-square ({w}x{h})",
|
|
0.25 < w / h < 4,
|
|
"sqrt(n) columns should bound the aspect ratio whatever the table count",
|
|
)
|
|
|
|
wide_schema = {
|
|
"meta": {"source": "db", "root": "x", "schema_version": "1"},
|
|
"nodes": [{"id": f"t{i}", "kind": "table", "label": f"t{i}", "parent": None, "attrs": {}}
|
|
for i in range(60)] +
|
|
[{"id": f"t{i}.c", "kind": "column", "label": "c", "parent": f"t{i}",
|
|
"attrs": {"type": "int"}} for i in range(60)],
|
|
"edges": [],
|
|
}
|
|
svg60 = erd_mod.emit(wide_schema, lucid)
|
|
m = _re.search(r'width="(\d+)pt" height="(\d+)pt"', svg60)
|
|
w60, h60 = int(m.group(1)), int(m.group(2))
|
|
check(
|
|
f"...and still does at 60 tables ({w60}x{h60})",
|
|
0.25 < w60 / h60 < 4,
|
|
"this is the whole reason it is not a rank-based layout",
|
|
)
|
|
check(
|
|
"the ER geometry is deterministic",
|
|
erd_mod.emit(db_ir, lucid) == svg,
|
|
"nothing here measures a font, so it renders identically on any machine",
|
|
)
|
|
check(
|
|
"it draws no tables only when there are none",
|
|
"no tables" in (
|
|
_err(lambda: erd_mod.emit(ops_mod.overview(ir), lucid))
|
|
),
|
|
"pointing it at a code graph should say so, not emit an empty canvas",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n9. the notebook is a build artifact, not a source file")
|
|
|
|
# The whole argument: a hand-confected notebook drifts from whatever it
|
|
# documents and there is no way to tell by looking. Generated from the spec the
|
|
# server is built from, "is this current" becomes a question about the build.
|
|
SPEC_IR = {
|
|
"meta": {"source": "openapi", "root": "petstore.yaml", "schema_version": "1"},
|
|
"nodes": [
|
|
{"id": "Pet", "kind": "table", "label": "Pet", "parent": None, "attrs": {}},
|
|
{"id": "Pet.id", "kind": "column", "label": "id", "parent": "Pet",
|
|
"attrs": {"type": "int", "pk": True}},
|
|
{"id": "Pet.name", "kind": "column", "label": "name", "parent": "Pet",
|
|
"attrs": {"type": "str"}},
|
|
{"id": "Pet.neutered", "kind": "column", "label": "neutered", "parent": "Pet",
|
|
"attrs": {"type": "bool"}},
|
|
{"id": "GET /pets", "kind": "endpoint", "label": "GET /pets", "parent": None,
|
|
"attrs": {"method": "GET", "path": "/pets", "summary": "List pets",
|
|
"status": 200, "response_model": "Pet", "returns_list": True}},
|
|
{"id": "POST /pets", "kind": "endpoint", "label": "POST /pets", "parent": None,
|
|
"attrs": {"method": "POST", "path": "/pets", "summary": "Create a pet",
|
|
"status": 201, "request_model": "Pet", "response_model": "Pet"}},
|
|
],
|
|
"edges": [{"source": "POST /pets", "target": "Pet", "kind": "accepts", "attrs": {}}],
|
|
}
|
|
check("the spec IR validates", check_ir(SPEC_IR) == [], str(check_ir(SPEC_IR)[:2]))
|
|
|
|
base = spec_mod.from_ir(SPEC_IR)
|
|
book_spec, _ = spec_mod.merge(base, None)
|
|
book = nb_mod.build(book_spec)
|
|
text = nb_mod.emit(book_spec)
|
|
check("it is nbformat 4", book["nbformat"] == 4 and book["nbformat_minor"] == 5)
|
|
check(
|
|
"regenerating gives byte-identical bytes",
|
|
nb_mod.emit(book_spec) == text,
|
|
"a notebook that changes every build cannot be reviewed",
|
|
)
|
|
check("nothing has run in it", all(
|
|
c["outputs"] == [] and c["execution_count"] is None
|
|
for c in book["cells"] if c["cell_type"] == "code"
|
|
))
|
|
intro = "".join(book["cells"][0]["source"]).lower()
|
|
check(
|
|
"it says not to edit it, and where to put changes instead",
|
|
"not be edited" in intro and "overlay" in intro,
|
|
f"the frame only holds if the artifact states it — got: {intro[:120]!r}",
|
|
)
|
|
body = "".join("".join(c["source"]) for c in book["cells"])
|
|
check("every endpoint in the spec has a section", "GET `/pets`" in body and "POST `/pets`" in body)
|
|
check("the facts come from the spec", "returns **Pet**" in body and "`201`" in body)
|
|
check(
|
|
"no credential is written into it",
|
|
"Bearer sk-" not in body and "password" not in body.lower(),
|
|
)
|
|
check(
|
|
"it needs nothing installed",
|
|
"pip install" not in body and "import requests" not in body,
|
|
)
|
|
|
|
# Compiling is not enough. `json.dumps` writes `false`/`true`/`null`, which are
|
|
# valid *identifiers* in Python — a generated body full of them compiles and
|
|
# then raises NameError on the first run. This is the check that caught it.
|
|
import io as _io
|
|
import contextlib as _ctx
|
|
|
|
ns, ran, failure = {}, 0, None
|
|
with _ctx.redirect_stdout(_io.StringIO()):
|
|
for c in book["cells"]:
|
|
if c["cell_type"] != "code":
|
|
continue
|
|
src = "".join(c["source"])
|
|
if "urlopen" in src or "call(" in src and "def call" not in src:
|
|
# Anything that would reach the network is compiled, not run.
|
|
try:
|
|
compile(src, c["id"], "exec")
|
|
ran += 1
|
|
except SyntaxError as e:
|
|
failure = f"{c['id']}: {e}"
|
|
break
|
|
continue
|
|
try:
|
|
exec(compile(src, c["id"], "exec"), ns)
|
|
ran += 1
|
|
except Exception as e: # noqa: BLE001 - any failure is the finding
|
|
failure = f"{c['id']}: {type(e).__name__}: {e}"
|
|
break
|
|
check(f"its cells run, not merely compile ({ran})", failure is None, failure or "")
|
|
|
|
literal = next(
|
|
("".join(c["source"]) for c in book["cells"]
|
|
if c["cell_type"] == "code" and "BODY" in "".join(c["source"])), ""
|
|
)
|
|
check(
|
|
"a generated body is a Python literal, not JSON",
|
|
"False" in literal and "false" not in literal,
|
|
f"got: {literal[:120]}",
|
|
)
|
|
check(
|
|
"the primary key is left out of a create body",
|
|
"'id'" not in literal,
|
|
"the server assigns it",
|
|
)
|
|
|
|
# The chain, if modelgen is next door: spec -> IR -> notebook, nothing by hand.
|
|
try:
|
|
oa = __import__(f"{PKG}.extractors.openapi", fromlist=["*"])
|
|
spec = HERE.parent.parent / "station/tools/shuntgen/fixtures/petstore.yaml"
|
|
if not spec.exists():
|
|
raise ImportError("no petstore fixture")
|
|
real = oa.extract(spec).to_dict()
|
|
except (ImportError, Exception) as e: # noqa: BLE001
|
|
skip("openapi -> IR", str(e).splitlines()[0][:60])
|
|
else:
|
|
check("a real spec extracts", check_ir(real) == [], str(check_ir(real)[:2]))
|
|
eps = [n for n in real["nodes"] if n["kind"] == "endpoint"]
|
|
check(f"its endpoints become nodes ({len(eps)})", len(eps) >= 3)
|
|
check(
|
|
"its schemas reuse the db vocabulary",
|
|
{"table", "column"} <= {n["kind"] for n in real["nodes"]},
|
|
"so the ER emitter draws an API's data model without knowing it is one",
|
|
)
|
|
check(
|
|
"and the ER emitter does draw it",
|
|
erd_mod.emit(ops_mod.only_kinds(real, {"table"}), lucid).startswith("<?xml"),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n8b. usage — what a spec cannot say")
|
|
|
|
# A spec is a set; usage is a sequence. Everything asserted here is something no
|
|
# amount of reading the OpenAPI document harder would recover.
|
|
def _entry(t, method, url, status, body=None):
|
|
req = {"method": method, "url": url,
|
|
"headers": [{"name": "Authorization", "value": "Bearer LEAK-ME-IF-BROKEN"}]}
|
|
if body is not None:
|
|
req["postData"] = {"mimeType": "application/json", "text": json.dumps(body)}
|
|
return {"startedDateTime": f"2026-01-01T00:00:{t:02d}Z",
|
|
"request": req, "response": {"status": status, "content": {}}}
|
|
|
|
|
|
HAR = {"log": {"version": "1.2", "entries": [
|
|
_entry(1, "POST", "https://x.test/auth", 200, {"user": "a", "secret": "b"}),
|
|
_entry(2, "GET", "https://x.test/pets?status=available&limit=20", 200),
|
|
_entry(3, "GET", "https://x.test/pets/1042", 200),
|
|
_entry(4, "GET", "https://x.test/pets/7f3e9b2a-1c4d-4a5b-9e8f-0a1b2c3d4e5f", 200),
|
|
_entry(5, "POST", "https://x.test/graphql", 200,
|
|
{"operationName": "PetWithTags", "query": "query PetWithTags($id: ID!) { pet }",
|
|
"variables": {"id": "1042"}}),
|
|
_entry(6, "POST", "https://x.test/pets", 422, {"name": "Rex"}),
|
|
_entry(7, "POST", "https://x.test/pets", 201, {"name": "Rex", "status": "available"}),
|
|
_entry(8, "GET", "https://x.test/pets?status=available&limit=20", 200),
|
|
]}}
|
|
har_path = ROOT.parent / "session.har"
|
|
har_path.write_text(json.dumps(HAR))
|
|
use = usage_ex.extract(har_path).to_dict()
|
|
by = {n["id"]: n for n in use["nodes"]}
|
|
raw_json = json.dumps(use)
|
|
|
|
check("the usage IR validates", check_ir(use) == [], str(check_ir(use)[:2]))
|
|
check(
|
|
"**no credential reaches the IR**",
|
|
"LEAK-ME-IF-BROKEN" not in raw_json and "Authorization" not in raw_json,
|
|
"a HAR is full of live tokens and a document gets committed",
|
|
)
|
|
check(
|
|
"paths are templated into one route",
|
|
"GET /pets/{id}" in by and "GET /pets/1042" not in by,
|
|
)
|
|
check(
|
|
"...and both id formats stay on that one route",
|
|
by["GET /pets/{id}"]["attrs"]["id_formats"] == ["numeric", "uuid"]
|
|
and by["GET /pets/{id}"]["attrs"]["calls"] == 2,
|
|
"splitting by format invents an endpoint that does not exist",
|
|
)
|
|
check(
|
|
"what is always sent is distinguished from what is sometimes",
|
|
by["GET /pets"]["attrs"]["params_always"] == ["limit", "status"],
|
|
"a spec calls these optional; traffic says otherwise",
|
|
)
|
|
check(
|
|
"the statuses that really happen are recorded",
|
|
by["POST /pets"]["attrs"]["statuses"] == [201, 422],
|
|
"the 422 everyone hits is not in the spec",
|
|
)
|
|
check(
|
|
"a body field seen once out of twice is not marked always",
|
|
[f["name"] for f in by["POST /pets"]["attrs"]["body_fields"] if f["always"]] == ["name"],
|
|
)
|
|
check(
|
|
"a GraphQL operation is found and named",
|
|
"query PetWithTags" in by and by["query PetWithTags"]["kind"] == "operation",
|
|
"one endpoint carries many operations; the path alone says nothing",
|
|
)
|
|
check(
|
|
"only the shape of a payload is kept, never a value",
|
|
'"a"' not in raw_json and '"b"' not in raw_json,
|
|
)
|
|
seq = {(e["source"], e["target"]) for e in use["edges"]}
|
|
check(
|
|
"the sequence is recorded as edges",
|
|
("POST /auth", "GET /pets") in seq and all(e["kind"] == "follows" for e in use["edges"]),
|
|
"this is the half a spec structurally cannot contain",
|
|
)
|
|
check(
|
|
"a repeated call is not a step in the sequence",
|
|
not [e for e in use["edges"] if e["source"] == e["target"]],
|
|
)
|
|
check(
|
|
"usage is a pipeline, and drawn as one",
|
|
ops_mod.classify(use)["kind"] == "pipeline",
|
|
)
|
|
|
|
# The payoff: a walkthrough in the order people actually call things.
|
|
u_spec, _ = spec_mod.merge(spec_mod.from_ir(use), None)
|
|
ids = [st["id"] for st in u_spec["steps"] if st["kind"] == "call"]
|
|
check(
|
|
"the notebook keeps the observed order",
|
|
ids[0] == "POST /auth" and ids.index("GET /pets") < ids.index("POST /pets"),
|
|
f"got {ids}",
|
|
)
|
|
u_body = "".join("".join(c["source"]) for c in nb_mod.build(u_spec)["cells"])
|
|
check(
|
|
"it sends the parameters that are always sent",
|
|
"PARAMS = " in u_body and "'status'" in u_body,
|
|
)
|
|
check(
|
|
"a templated path becomes a variable, not a literal",
|
|
'ID = "<id>"' in u_body and '"/pets/{id}"' not in u_body,
|
|
"a literal {id} in the URL would 404",
|
|
)
|
|
check("the GraphQL operation gets a query cell", "QUERY = " in u_body)
|
|
har_path.unlink()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n9b. generated base + hand-written overlay")
|
|
|
|
# The arrangement that gets both halves: generation alone says nothing a parser
|
|
# could not work out; hand-authoring alone rots. The base is never edited, the
|
|
# overlay is the only file anyone touches, and it is re-applied every build.
|
|
OVERLAY = {
|
|
"steps": {
|
|
"GET /pets": {
|
|
"note": "always called with status=available first",
|
|
"code": 'show(call("GET", "/pets", params={"status": "available"}))',
|
|
},
|
|
"setup": {"_kind": "params", "_title": ""}, # scaffold hints, not edits
|
|
},
|
|
"insert": [
|
|
{"id": "graphql", "after": "client", "kind": "md",
|
|
"title": "The GraphQL endpoint",
|
|
"text": "Not in the OpenAPI document at all."},
|
|
],
|
|
"drop": ["shapes"],
|
|
}
|
|
|
|
merged, drift = spec_mod.merge(spec_mod.from_ir(SPEC_IR), OVERLAY)
|
|
ids = [st["id"] for st in merged["steps"]]
|
|
body_o = "".join("".join(c["source"]) for c in nb_mod.build(merged)["cells"])
|
|
|
|
check("no drift against a matching base", drift == [], str(drift))
|
|
check("an inserted step lands where it was asked to", ids.index("graphql") == ids.index("client") + 1)
|
|
check("a dropped step is gone", "shapes" not in ids)
|
|
check(
|
|
"replaced code wins over the generated call",
|
|
'params={"status": "available"}' in body_o,
|
|
"this is how real usage gets in, and a spec cannot supply it",
|
|
)
|
|
check("an annotation reaches the prose", "always called with status=available" in body_o)
|
|
check(
|
|
"scaffold hints are not mistaken for edits",
|
|
"_kind" not in body_o and "_title" not in body_o,
|
|
)
|
|
|
|
# The two properties the whole arrangement rests on.
|
|
again, _ = spec_mod.merge(spec_mod.from_ir(SPEC_IR), OVERLAY)
|
|
check(
|
|
"regenerating re-applies the overlay, byte for byte",
|
|
nb_mod.emit(again) == nb_mod.emit(merged),
|
|
"a regeneration that loses someone's work will not be run twice",
|
|
)
|
|
|
|
moved = dict(SPEC_IR, nodes=[n for n in SPEC_IR["nodes"] if n["id"] != "GET /pets"])
|
|
shrunk, drift2 = spec_mod.merge(spec_mod.from_ir(moved), OVERLAY)
|
|
check(
|
|
"when the base moves, the overlay says so",
|
|
any("GET /pets" in d for d in drift2),
|
|
"silently dropping it is how an overlay goes stale without anyone noticing",
|
|
)
|
|
check("...and a document is still produced", len(shrunk["steps"]) > 1)
|
|
|
|
bare, drift3 = spec_mod.merge(spec_mod.from_ir(SPEC_IR), None)
|
|
check(
|
|
"extraction works with the overlay absent",
|
|
drift3 == [] and len(bare["steps"]) > 1,
|
|
"the overlay is an addition, never a dependency",
|
|
)
|
|
|
|
blank = spec_mod.scaffold(spec_mod.from_ir(SPEC_IR))
|
|
check(
|
|
"a scaffold hands over every step id",
|
|
set(blank["steps"]) == {st["id"] for st in spec_mod.from_ir(SPEC_IR)["steps"]},
|
|
"listing the ids is the difference between an overlay being written and meant to be",
|
|
)
|
|
check(
|
|
"a blank scaffold changes nothing",
|
|
nb_mod.emit(spec_mod.merge(spec_mod.from_ir(SPEC_IR), blank)[0]) == nb_mod.emit(bare),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
print("\n10. defaults")
|
|
|
|
check(
|
|
"dark is the default theme",
|
|
Style.load("lucid").theme == "dark",
|
|
"a diagram lands in a dark docs page far more often than in print",
|
|
)
|
|
check("print is one flag away", Style.load("lucid", theme="lucid").theme == "lucid")
|
|
|
|
pipeline = {
|
|
"meta": {"source": "airflow", "root": "etl", "schema_version": "1"},
|
|
"nodes": [{"id": x, "kind": "task", "label": x, "parent": None, "attrs": {}}
|
|
for x in ("extract", "transform", "load", "notify")],
|
|
"edges": [{"source": a, "target": b, "kind": "depends", "attrs": {}}
|
|
for a, b in (("extract", "transform"), ("transform", "load"),
|
|
("load", "notify"))],
|
|
}
|
|
verdict = ops_mod.classify(pipeline)
|
|
check(
|
|
"a DAG is recognised as a pipeline",
|
|
verdict["kind"] == "pipeline",
|
|
str(verdict),
|
|
)
|
|
check(
|
|
"...and is drawn left to right",
|
|
verdict.get("options", {}).get("rankdir") == "LR",
|
|
"a sequence reads across, which is how every scheduler's own UI draws it",
|
|
)
|
|
check(
|
|
"a task has its own style, not a module's",
|
|
lucid.node("task") != lucid.node("module"),
|
|
"a schedule should not render as if it were code structure",
|
|
)
|
|
check(
|
|
"the emitter honours the rankdir it is given",
|
|
"rankdir=LR" in dot_mod.emit(pipeline, lucid, rankdir="LR"),
|
|
)
|
|
check("every verdict carries options", "options" in ops_mod.classify(ir))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
tmp.cleanup()
|
|
print()
|
|
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
|
|
if FAIL:
|
|
print("\nfailed:")
|
|
for name in FAIL:
|
|
print(f" {name}")
|
|
sys.exit(1 if FAIL else 0)
|