Python Part II · Module 34 · From Script to Maintainable Tool
Separating Tool Control, Parsing and Reporting
Chapter 34.2 put the parser, the tool control and the reporting in three files, which is most of the work and none of the guarantee. Three files can still depend on each other in a tangle, and the surprising part — measured here — is that Python will not tell you: the error it raises for a circular import is the only structural complaint it makes, and a layering violation that happens not to form a cycle imports perfectly cleanly. So the direction has to be checked by something you write, and the obvious way to write it does not work.
Advanced24 min readPythonPart IIAutomationArchitectureImports
Part II · Module 34 · Chapter 34.3 · Separating Tool Control, Parsing and Reporting
1. Three Jobs, Three Costs
JOBS = [
("run the tool", "a licence, a design, minutes", "impure", "swap per tool"),
("text to result", "nothing at all", "pure", "never swapped"),
("results to output", "a path to write to", "mostly pure", "swap per format"),
]
print(f"{'job':<20} {'what exercising it needs':<30} {'purity':<13} varies with")
for job, needs, purity, varies in JOBS:
print(f"{job:<20} {needs:<30} {purity:<13} {varies}")
print()
print("the layer that changes per simulator :", "tools")
print("the layer that changes per report :", "report")
print("the layer that should never change :", "parse")
print()
print("so the valuable property is :",
"the stable layer depends on neither of the others")job what exercising it needs purity varies with
run the tool a licence, a design, minutes impure swap per tool
text to result nothing at all pure never swapped
results to output a path to write to mostly pure swap per format
the layer that changes per simulator : tools
the layer that changes per report : report
the layer that should never change : parse
so the valuable property is : the stable layer depends on neither of the othersThe middle row is the one worth protecting. Parsing a UVM log is the same problem whichever simulator produced it and whichever format the report takes, so it is the one piece of the flow that has no reason to change — and the arrangement that keeps it that way is the subject of this chapter.
2. The Annotation
# tools.py -> run the tool
# parse.py -> text to result
# report.py -> results to outputThree comments, and the arrows are doing the work. Each one reads input → output, and lining them up shows what has to be true for the three to compose:
SIGNATURES = [
("tools", "sim, filelist, test, seed, dir", "(returncode, log text)"),
("parse", "log text", "a result dict"),
("report", "a list of result dicts", "csv text, html text"),
]
print(f"{'layer':<8} {'takes':<34} returns")
for name, takes, returns in SIGNATURES:
print(f"{name:<8} {takes:<34} {returns}")
print()
print("tools returns text and parse takes text:", True)
print("parse returns a dict, report takes dicts:", True)
print("so the layers compose on :", "plain data, not objects")
print("and nothing in the chain needs :", "a shared global")
print()
print("what the annotation does NOT say :", "who may import whom")layer takes returns
tools sim, filelist, test, seed, dir (returncode, log text)
parse log text a result dict
report a list of result dicts csv text, html text
tools returns text and parse takes text: True
parse returns a dict, report takes dicts: True
so the layers compose on : plain data, not objects
and nothing in the chain needs : a shared global
what the annotation does NOT say : who may import whomThe layers compose on plain data. tools hands out text, parse takes
text and hands out a dict, report takes dicts. Nothing is passed by setting a
global, which is why Chapter 34.1's RESULTS bug has no home here, and nothing
is passed as a custom object, which is why the middle layer can be tested with
a string literal.
What the three comments leave unstated is the import direction, and Section 6 is about what happens when it goes wrong.
3. The Three Layers
One direction of dependency
Two properties of that picture are the whole design. No arrow leaves
flow.parse — it imports nothing from the package, so it can be imported by
anything and tested with nothing. And no arrow points into flow.tools
except from the entry point — so the simulator-specific layer is reachable
only from the one place that is allowed to care which simulator this is.
Everything else in the chapter either exploits those two properties or checks that they still hold.
4. The Middle Layer Needs No Setup
import io
import re
import unittest
SEVERITIES = ("UVM_FATAL", "UVM_ERROR", "UVM_WARNING")
TESTNAME = re.compile(r"UVM_TESTNAME=(\S+)")
SEED = re.compile(r"ntb_random_seed=(\d+)")
def count_severities(text):
counts = dict.fromkeys(SEVERITIES, 0)
for line in text.splitlines():
for sev in SEVERITIES:
if line.startswith(sev) and f"{sev} :" not in line:
counts[sev] += 1
return counts
def classify(counts, text):
if "TEST DONE" not in text:
return "TIMEOUT"
if counts["UVM_FATAL"]:
return "FATAL"
if counts["UVM_ERROR"]:
return "FAIL"
return "PASS"
def parse_sim_log(text):
counts = count_severities(text)
m, s = TESTNAME.search(text), SEED.search(text)
return {"test": m.group(1) if m else None,
"seed": int(s.group(1)) if s else None,
"errors": counts["UVM_ERROR"], "fatals": counts["UVM_FATAL"],
"warnings": counts["UVM_WARNING"],
"status": classify(counts, text)}
class MiddleLayer(unittest.TestCase):
"""Every input is a string literal. No files, no tools, no tempdirs."""
def test_clean(self):
r = parse_sim_log("UVM_INFO a\nTEST DONE\n")
self.assertEqual((r["errors"], r["status"]), (0, "PASS"))
def test_errors(self):
r = parse_sim_log("UVM_ERROR a.sv(1) x\nUVM_ERROR b.sv(2) y\n"
"TEST DONE\n")
self.assertEqual((r["errors"], r["status"]), (2, "FAIL"))
def test_summary_line_excluded(self):
r = parse_sim_log("UVM_ERROR a.sv(1) x\nUVM_ERROR : 1\nTEST DONE\n")
self.assertEqual(r["errors"], 1)
def test_fatal_beats_error(self):
r = parse_sim_log("UVM_ERROR a.sv(1) x\nUVM_FATAL b.sv(2) z\n"
"TEST DONE\n")
self.assertEqual(r["status"], "FATAL")
def test_truncated(self):
self.assertEqual(parse_sim_log("UVM_ERROR a.sv(1) x\n")["status"],
"TIMEOUT")
def test_identity_extracted(self):
r = parse_sim_log("UVM_INFO UVM_TESTNAME=axi_burst_test "
"ntb_random_seed=4121\nTEST DONE\n")
self.assertEqual((r["test"], r["seed"]), ("axi_burst_test", 4121))
def run(case):
suite = unittest.TestLoader().loadTestsFromTestCase(case)
res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
return {"run": res.testsRun, "failures": len(res.failures),
"ok": res.wasSuccessful()}
print("middle-layer suite:", run(MiddleLayer))
print()
print("setUp methods needed :", 0)
print("temporary directories :", 0)
print("subprocesses launched :", 0)
print("simulator licences used:", 0)
print("behaviours pinned :", 6)middle-layer suite: {'run': 6, 'failures': 0, 'ok': True}
setUp methods needed : 0
temporary directories : 0
subprocesses launched : 0
simulator licences used: 0
behaviours pinned : 6Six behaviours pinned with no setup of any kind. Every input is a string
literal written in the test, which is Chapter 33.3's known-input-expected-output
shape, and it is available here only because flow.parse imports nothing — had
it imported flow.tools, importing the test module would have pulled in
subprocess and whatever that module does at import time.
That is the canonical purpose's first half: the arrangement that lets you test the middle layer. The second half is next.
5. The Bottom Layer Is Swappable
This machine has Icarus Verilog, so the bottom layer can be real. Here are two backends with the same signature — one that compiles and runs a testbench, one that replays a stored log — and one parser that cannot tell them apart:
import re
import shutil
import subprocess
import sys
from pathlib import Path
# ---------------- the middle layer, unchanged from §4 ----------------
SEVERITIES = ("UVM_FATAL", "UVM_ERROR", "UVM_WARNING")
def parse_sim_log(text):
counts = dict.fromkeys(SEVERITIES, 0)
for line in text.splitlines():
for sev in SEVERITIES:
if line.startswith(sev) and f"{sev} :" not in line:
counts[sev] += 1
done = "TEST DONE" in text
status = ("TIMEOUT" if not done else
"FATAL" if counts["UVM_FATAL"] else
"FAIL" if counts["UVM_ERROR"] else "PASS")
return {"errors": counts["UVM_ERROR"], "status": status}
# ---------------- bottom layer, backend A: a real simulator ----------------
TB = """module tb;
parameter EXPECT = {expect};
integer errors = 0;
reg [4:0] sum;
initial begin
$display("UVM_INFO @0: tb.sv(1) start UVM_TESTNAME=fifo_smoke");
sum = 5'd9 + 5'd8;
if (sum !== EXPECT[4:0]) begin
$display("UVM_ERROR @10: tb.sv(2) sum=%0d expected=%0d", sum, EXPECT);
errors = errors + 1;
end
$display("UVM_ERROR : %0d", errors);
$display("TEST DONE");
$finish;
end
endmodule
"""
def run_iverilog(expect, run_dir):
"""Impure. Returns (returncode, log text). Knows nothing about parsing."""
d = Path(run_dir)
d.mkdir(parents=True, exist_ok=True)
(d / "tb.v").write_text(TB.format(expect=expect), encoding="utf-8")
comp = subprocess.run(["iverilog", "-o", "tb.vvp", "tb.v"], cwd=d,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True)
if comp.returncode != 0:
return comp.returncode, comp.stdout
sim = subprocess.run(["vvp", "tb.vvp"], cwd=d, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
text = "\n".join(l for l in sim.stdout.splitlines()
if "$finish" not in l) + "\n"
(d / "sim.log").write_text(text, encoding="utf-8")
return sim.returncode, text
# ---------------- bottom layer, backend B: replay ----------------
def run_replay(expect, run_dir):
"""Impure in the same way, with no tool at all. Same signature."""
stored = {
17: ("UVM_INFO @0: tb.sv(1) start UVM_TESTNAME=fifo_smoke\n"
"UVM_ERROR : 0\nTEST DONE\n"),
18: ("UVM_INFO @0: tb.sv(1) start UVM_TESTNAME=fifo_smoke\n"
"UVM_ERROR @10: tb.sv(2) sum=17 expected=18\n"
"UVM_ERROR : 1\nTEST DONE\n"),
}
return 0, stored[expect]
print("iverilog on PATH:", shutil.which("iverilog") is not None)
print()
print(f"{'backend':<10} {'EXPECT':>7} {'rc':>4} {'errors':>7} {'status':>8}")
results = {}
for label, backend in (("iverilog", run_iverilog), ("replay", run_replay)):
for expect in (17, 18):
rc, text = backend(expect, f"run_{label}_{expect}")
r = parse_sim_log(text)
results[(label, expect)] = r
print(f"{label:<10} {expect:>7} {rc:>4} {r['errors']:>7} "
f"{r['status']:>8}")
print()
print("the two backends agree on EXPECT=17:",
results[("iverilog", 17)] == results[("replay", 17)])
print("the two backends agree on EXPECT=18:",
results[("iverilog", 18)] == results[("replay", 18)])
print()
print("lines of parse_sim_log changed between backends:", 0)
print("the parser knows which backend ran :", False)iverilog on PATH: True
backend EXPECT rc errors status
iverilog 17 0 0 PASS
iverilog 18 0 1 FAIL
replay 17 0 0 PASS
replay 18 0 1 FAIL
the two backends agree on EXPECT=17: True
the two backends agree on EXPECT=18: True
lines of parse_sim_log changed between backends: 0
the parser knows which backend ran : FalseThe two backends produce identical parsed results and the parser is
unchanged. One compiles Verilog with iverilog and runs it under vvp; the
other returns a stored string. They share a signature —
(expect, run_dir) -> (returncode, text) — and nothing above them can tell
which ran.
That is the canonical purpose's second half, and it is worth being precise
about what it buys. The replay backend makes the whole flow runnable with no
simulator, which is how the reporting layer gets tested on a laptop; the
iverilog backend is the real thing. Swapping them is a one-word change at the
call site because the seam is a function with a fixed signature, not a
subclass and not a configuration flag.
6. A Violation That Is Not a Cycle
Chapter 34.2 Section 6 showed Python raising ImportError for a circular
import. It is natural to assume it polices direction generally. It does not:
import sys
from pathlib import Path
Path("flow").mkdir(exist_ok=True)
(Path("flow") / "__init__.py").write_text("", encoding="utf-8")
(Path("flow") / "tools.py").write_text(
"import subprocess\n"
"def run_sim(*a):\n"
" return 0, ''\n", encoding="utf-8")
# the violation: the PURE layer imports the IMPURE one
(Path("flow") / "parse.py").write_text(
"from flow.tools import run_sim # <- wrong direction\n"
"\n"
"def parse_sim_log(text):\n"
" return {'errors': text.count('UVM_ERROR')}\n", encoding="utf-8")
sys.path.insert(0, ".")
import warnings
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
from flow.parse import parse_sim_log
print("the import raised :", "nothing")
print("parse_sim_log still works :", parse_sim_log("UVM_ERROR x"))
print("warnings emitted :", len(caught))
print()
print("flow.tools in sys.modules :", "flow.tools" in sys.modules)
print("subprocess in sys.modules :", "subprocess" in sys.modules)
print()
print("is there a cycle here :", False)
print("so ImportError does not fire:", True)
print("diagnostics from Python :", 0)the import raised : nothing
parse_sim_log still works : {'errors': 1}
warnings emitted : 0
flow.tools in sys.modules : True
subprocess in sys.modules : True
is there a cycle here : False
so ImportError does not fire: True
diagnostics from Python : 0Zero diagnostics. The pure layer now depends on the impure one, the design
property from Section 3 is gone, subprocess is loaded by anybody who imports
the parser — and nothing anywhere reports a problem. Chapter 34.2's
ImportError fires only on a cycle, and parse → tools is a perfectly
acyclic edge pointing the wrong way.
The consequence is not theoretical. Section 4's test suite imports the parser;
after this edit, that import pulls in flow.tools, so any module-level work in
flow.tools now runs during the test run — and the day somebody adds a licence
check or a shutil.which call to the top of flow.tools, the pure test suite
starts needing a simulator installation.
So the direction has to be checked by something you write. The obvious way to write it is wrong.
7. A Layer Number Is Not Enough
import ast
import io
import sys
from pathlib import Path
Path("flow").mkdir(exist_ok=True)
(Path("flow") / "__init__.py").write_text("", encoding="utf-8")
(Path("flow") / "tools.py").write_text(
"def run_sim(*a):\n return 0, ''\n", encoding="utf-8")
(Path("flow") / "parse.py").write_text(
"from flow.tools import run_sim\n"
"def parse_sim_log(t):\n return {'errors': t.count('UVM_ERROR')}\n",
encoding="utf-8")
(Path("flow") / "report.py").write_text(
"from flow.parse import parse_sim_log\n"
"def to_csv(rows):\n return 'test,status\\n'\n", encoding="utf-8")
def intra_deps(path, pkg="flow"):
tree = ast.parse(Path(path).read_text(encoding="utf-8"))
deps = set()
for n in ast.walk(tree):
if isinstance(n, ast.ImportFrom) and n.module \
and n.module.startswith(pkg):
deps.add(n.module)
elif isinstance(n, ast.Import):
for a in n.names:
if a.name.startswith(pkg):
deps.add(a.name)
return deps
MODULES = {"flow.tools": "flow/tools.py", "flow.parse": "flow/parse.py",
"flow.report": "flow/report.py"}
# the obvious design: give each layer a number, and forbid upward imports
RANK = {"flow.tools": 0, "flow.parse": 1, "flow.report": 2}
# the design that works: say explicitly what each module MAY import
ALLOWED = {"flow.tools": set(), "flow.parse": set(),
"flow.report": {"flow.parse"}}
print(f"{'module':<14} actual intra-package imports")
for name, path in MODULES.items():
print(f"{name:<14} {sorted(intra_deps(path)) or '-'}")
rank_bad, edge_bad = [], []
for name, path in MODULES.items():
for dep in intra_deps(path):
if dep in RANK and RANK[dep] >= RANK[name]:
rank_bad.append((name, dep))
if dep not in ALLOWED.get(name, set()):
edge_bad.append((name, dep))
print()
print("rank linter ->", rank_bad or "NO VIOLATIONS")
print("edge linter ->", edge_bad)
print()
print("the violation is :", ("flow.parse", "flow.tools"))
print("the rank linter caught it :", ("flow.parse", "flow.tools") in rank_bad)
print("the edge linter caught it :", ("flow.parse", "flow.tools") in edge_bad)module actual intra-package imports
flow.tools -
flow.parse ['flow.tools']
flow.report ['flow.parse']
rank linter -> NO VIOLATIONS
edge linter -> [('flow.parse', 'flow.tools')]
the violation is : ('flow.parse', 'flow.tools')
the rank linter caught it : False
the edge linter caught it : TrueThe rank linter reports no violations, and there is a violation. That is a false green, and it is worth understanding rather than just avoiding, because the reasoning behind the rank linter is appealing: number the layers, forbid a module from importing anything at or above its own number, done.
It fails because parse and tools are siblings, and a number cannot say
so. Any ranking has to put one of them below the other. Put tools at 0 and
parse at 1, as above, and parse → tools is a downward import, which the
rule explicitly permits. Put parse at 0 and tools at 1 and the linter
catches this violation — but then it permits tools → parse, which is the
other edge the design forbids, and a tool-control module reaching into the
parser is exactly how a simulator-specific assumption gets into the pure layer.
A rank expresses a total order. The design is a partial order: two layers at the same level, neither allowed to depend on the other, both available to the ones above. The only honest encoding is to list the permitted edges:
ALLOWED = {
"flow.parse": set(), # imports nothing in the package
"flow.tools": set(), # imports nothing in the package
"flow.report": {"flow.parse"}, # formats what parse produces
"run_regress": {"flow.parse", "flow.tools", "flow.report"},
}
pairs = [(m, d) for m, deps in ALLOWED.items() for d in deps]
siblings = [("flow.parse", "flow.tools"), ("flow.tools", "flow.parse")]
print("modules :", len(ALLOWED))
print("edges the design permits :", len(pairs))
print("edges a 4-module graph allows:", 4 * 3)
print("edges deliberately forbidden:", 4 * 3 - len(pairs))
print()
print("sibling pairs, both forbidden:")
for a, b in siblings:
print(f" {a:<12} -> {b:<12} allowed = {b in ALLOWED[a]}")
print()
print("a rank can express this :", False)
print("why :", "a rank is a total order; this is not")modules : 4
edges the design permits : 4
edges a 4-module graph allows: 12
edges deliberately forbidden: 8
sibling pairs, both forbidden:
flow.parse -> flow.tools allowed = False
flow.tools -> flow.parse allowed = False
a rank can express this : False
why : a rank is a total order; this is notFour permitted edges out of twelve possible ones, and the eight forbidden ones include both directions between the two sibling layers. That table is the architecture — it is four lines, it lives in the repository, and Section 8 makes it enforceable.
8. The Linter, Proven Able to Fail
A check nobody has watched fail is not a check. This one is run against four deliberately broken arrangements as well as the correct one:
import ast
import io
import unittest
from pathlib import Path
ALLOWED = {
"flow.parse": set(),
"flow.tools": set(),
"flow.report": {"flow.parse"},
"run_regress": {"flow.parse", "flow.tools", "flow.report"},
}
def intra_deps(src, pkg="flow"):
deps = set()
for n in ast.walk(ast.parse(src)):
if isinstance(n, ast.ImportFrom) and n.module \
and n.module.startswith(pkg):
deps.add(n.module)
elif isinstance(n, ast.Import):
for a in n.names:
if a.name.startswith(pkg):
deps.add(a.name)
return deps
def check_direction(sources, allowed=ALLOWED):
"""Returns a list of (module, forbidden dependency)."""
bad = []
for name, src in sources.items():
for dep in sorted(intra_deps(src)):
if dep not in allowed.get(name, set()):
bad.append((name, dep))
return bad
GOOD = {
"flow.parse": "def parse_sim_log(t):\n return {}\n",
"flow.tools": "import subprocess\ndef run_sim(*a):\n return 0, ''\n",
"flow.report": "from flow.parse import parse_sim_log\n"
"def to_csv(rows):\n return ''\n",
"run_regress": "from flow.parse import parse_sim_log\n"
"from flow.tools import run_sim\n"
"from flow.report import to_csv\n",
}
BROKEN = {
"parse imports tools": {**GOOD,
"flow.parse": "from flow.tools import run_sim\n"
"def parse_sim_log(t):\n"
" return {}\n"},
"tools imports parse": {**GOOD,
"flow.tools": "from flow.parse import parse_sim_log\n"
"def run_sim(*a):\n"
" return 0, ''\n"},
"parse imports report": {**GOOD,
"flow.parse": "from flow.report import to_csv\n"
"def parse_sim_log(t):\n"
" return {}\n"},
"report imports tools": {**GOOD,
"flow.report": "from flow.parse import parse_sim_log\n"
"from flow.tools import run_sim\n"
"def to_csv(rows):\n"
" return ''\n"},
}
print("the correct arrangement ->", check_direction(GOOD) or "clean")
print()
print(f"{'deliberately broken':<24} {'detected':<9} violation")
for label, sources in BROKEN.items():
bad = check_direction(sources)
print(f"{label:<24} {str(bool(bad)):<9} {bad}")
print()
print("broken arrangements tried :", len(BROKEN))
print("broken arrangements caught:", sum(1 for s in BROKEN.values()
if check_direction(s)))
print("false positives on GOOD :", len(check_direction(GOOD)))the correct arrangement -> clean
deliberately broken detected violation
parse imports tools True [('flow.parse', 'flow.tools')]
tools imports parse True [('flow.tools', 'flow.parse')]
parse imports report True [('flow.parse', 'flow.report')]
report imports tools True [('flow.report', 'flow.tools')]
broken arrangements tried : 4
broken arrangements caught: 4
false positives on GOOD : 0Four of four caught, and no false positive on the correct arrangement. Both halves are required: a linter that flags everything catches all four and is useless, and Chapter 33.9's argument applies directly — the four broken arrangements are mutations, and the detection rate is the kill rate.
Note that the rank linter from Section 7 would have caught only two of these
four. tools imports parse and parse imports report are upward edges under
the tools=0, parse=1, report=2 ranking and would be flagged; parse imports tools and report imports tools are downward and would pass. Half the
violations, silently.
One honest limitation. This linter reads import statements, so it is blind to
a dependency created some other way — importlib.import_module("flow.tools"),
or a function in parse that happens to receive a flow.tools object as an
argument. It checks the stated structure, which is the structure a reader
relies on, and Chapter 33.9's distinction applies: a check that cannot see a
class of violation should say so rather than be trusted for it.
9. Predict, Run, Observe
import ast
GOOD = "from flow.parse import parse_sim_log\n"
BAD = "from flow.tools import run_sim\n"
ALLOWED = {"flow.report": {"flow.parse"}, "flow.parse": set()}
RANK = {"flow.tools": 0, "flow.parse": 1, "flow.report": 2}
def deps(src):
return {n.module for n in ast.walk(ast.parse(src))
if isinstance(n, ast.ImportFrom) and n.module}
# A
print("A:", sorted(deps(GOOD)), sorted(deps(BAD)))
# B
print("B:", [d for d in deps(GOOD) if d not in ALLOWED["flow.report"]])
# C
print("C:", [d for d in deps(BAD) if d not in ALLOWED["flow.parse"]])
# D
print("D:", [d for d in deps(BAD)
if d in RANK and RANK[d] >= RANK["flow.parse"]])The verified output:
A: ['flow.parse'] ['flow.tools']
B: []
C: ['flow.tools']
D: []A — the dependency extraction, which is the only part that touches ast.
B — report importing parse is permitted, so the edge check is empty.
C — parse importing tools is forbidden, and the edge check names it.
D — the same violation, through the rank check, is empty. C and D
differ on one line of input and the rank check is the one that stays quiet.
Section 7 as two values, and the reason a four-line ALLOWED table beats a
clever number.
10. Exercises
Exercise 1 — three costs. For your own flow, write down what exercising each of the three jobs requires.
Exercise 2 — the stable layer. Say which part of your code has no reason to change, and check whether it depends on anything that does.
Exercise 3 — line up the signatures. Write the takes/returns table for your three layers and confirm they compose.
Exercise 4 — plain data. Check that nothing between your layers travels as a custom object or a global.
Exercise 5 — no arrow leaves the parser. Confirm your pure module imports nothing from your own package.
Exercise 6 — no arrow enters the tool layer. Confirm only the entry point imports it.
Exercise 7 — zero setup. Write six tests for your middle layer using only string literals.
Exercise 8 — what the import would have cost. Say what pulling subprocess
into your parser would do to that suite.
Exercise 9 — a second backend. Write a replay backend for your tool layer with the same signature as the real one.
Exercise 10 — one-word swap. Change the call site to use it and confirm nothing above changes.
Exercise 11 — the narrow return type. List three convenient return types that would have leaked your simulator, and say why each is worse.
Exercise 12 — the silent violation. Make your pure module import your tool module and record every diagnostic Python produces.
Exercise 13 — only cycles. Explain in two sentences why ImportError did
not fire.
Exercise 14 — the delayed cost. Describe what happens to your pure test suite the day somebody adds a licence check to the tool module.
Exercise 15 — write the rank linter. Implement it, run it on the violation, and record the result.
Exercise 16 — why it passed. Explain, using the words total order, why a number cannot express your design.
Exercise 17 — flip the rank. Swap the two bottom layers' numbers and say which violation it now misses.
Exercise 18 — the permitted edges. Write the ALLOWED table for your
package.
Exercise 19 — count the forbidden ones. Work out how many edges your graph allows and how many you permit.
Exercise 20 — four mutations. Build four deliberately broken arrangements and check your linter catches each.
Exercise 21 — no false positives. Confirm it reports nothing on the correct arrangement.
Exercise 22 — the kill rate. Express your linter's result as Chapter 33.9's kill rate.
Exercise 23 — two of four. Work out which of your four mutations the rank linter would have missed.
Exercise 24 — what it cannot see. Name a dependency your linter is blind to, and decide whether to document or detect it.
Exercise 25 — put it in CI. Add the linter to your validator set and give it an exit code.
Exercise 26 — the table is the architecture. Explain why a four-line table in the repository is better than a paragraph in a wiki.
11. Summary
- Three jobs with three different costs: running the tool needs a licence and minutes, parsing needs nothing, reporting needs a path. Parsing is the one with no reason to change, so it is the one to protect.
- The layers compose on plain data — text in, a dict out, dicts in — so nothing travels by global and nothing travels as a custom object.
- No arrow leaves
flow.parseand no arrow entersflow.toolsexcept from the entry point. Those two properties are the design. - Six middle-layer behaviours pinned with zero setup — no
setUp, no tempdir, no subprocess, no licence — available only because the pure module imports nothing. - Two backends, one signature. A real
iverilogbackend and a replay backend produced identical parsed results with the parser unchanged, and nothing above them can tell which ran. - The signature must not leak the tool. Returning a
.vvppath or aCompletedProcesswould have been convenient and would have pinned the flow to Icarus. - A layering violation that is not a cycle produces zero diagnostics.
parseimportingtoolsimports cleanly, loadssubprocessfor every consumer of the parser, and Python says nothing —ImportErrorfires only on cycles. - So direction must be checked by something you write, and the obvious implementation is wrong.
- A rank linter gave a false green.
parseandtoolsare siblings and a number cannot say so: any ranking makes one of the two forbidden edges look legal, and flipping the ranks only changes which one. - The design is a partial order, not a total one, so the honest encoding is an explicit table of permitted edges — four of twelve possible edges here.
- Four deliberately broken arrangements, four caught, zero false positives — the rank linter would have caught two of the four.
- The linter reads
importstatements only, so it is blind toimportliband to objects passed as arguments. A check should state what it cannot see. - A four-line
ALLOWEDtable in the repository is the architecture — it is executable, it fails a build, and it cannot drift out of date the way a diagram in a wiki does.
The next chapter asks whether any of this needs a class, and measures what a premature one costs.
Where this fits
Part of the Python curriculum.
