Python Part II · Module 22 · Building Command-Line VLSI Tools
A Command-Line Contract a Whole Team Shares
Everything in this module so far makes one script well-behaved. A flow is twenty of them, written by six people over four years, and being individually well-behaved is not enough — one tool uses --test and another uses --testname, one exits 1 for a failing test and another exits 1 for a missing file, one prints a result and another prints a result with a banner above it. Each is defensible alone and together they do not compose, so every pair of tools acquires an adapter and the adapters are where the flow's real bugs live. The fix is three agreements and about thirty lines.
Intermediate18 min readPythonPart IIAutomationVerification FlowTooling
Part II · Module 22 · Chapter 22.6 · A Command-Line Contract a Whole Team Shares
1. Three Tools, Three Dialects
Here is a flow that grew one tool at a time:
run_sim.py --test axi_smoke --seed 4 --sim vcs
check_log.py --logfile sim.log --simulator vcs
summarise.py -f results.csv --tool=vcsThree names for the simulator, three conventions for a flag, and that is the visible half. The invisible half is worse:
TOOLS = {
"run_sim.py": {"sim flag": "--sim", "exit 1 means": "test failed",
"stdout": "JSON record"},
"check_log.py": {"sim flag": "--simulator", "exit 1 means": "could not read the log",
"stdout": "a banner, then PASS or FAIL"},
"summarise.py": {"sim flag": "--tool", "exit 1 means": "any problem at all",
"stdout": "a human table"},
}
print(f"{'tool':<15} {'sim flag':<13} {'exit 1 means':<26} stdout")
for name, facts in TOOLS.items():
print(f"{name:<15} {facts['sim flag']:<13} {facts['exit 1 means']:<26} {facts['stdout']}")tool sim flag exit 1 means stdout
run_sim.py --sim test failed JSON record
check_log.py --simulator could not read the log a banner, then PASS or FAIL
summarise.py --tool any problem at all a human tableThe --sim row is annoying. The exit 1 means row is dangerous, because a wrapper that treats non-zero as "a test failed" will report a missing log file as a design bug — and nothing anywhere will contradict it.
Each tool is individually reasonable. Chapter 22.2 would approve of all three parsers, Chapter 22.3 of all three error messages. What is missing is not quality; it is agreement.
2. Three Things to Agree
The canonical annotation states the middle one as a comment and the left one by example:
# every tool in the flow: 0 pass, 1 fail, 2 infrastructure error
run_sim.py --test T --seed N
run_regress.py --suite nightlyNote what those two commands share and what they do not. Both tools take --sim, --json and --quiet because every tool in the flow does; --test belongs to one and --suite to the other, because they are different jobs. A contract is about the flags every tool has, not about making every tool take the same arguments.
3. The Contract as a Module
An agreement written in a wiki is one people have to remember. An agreement written as thirty lines of Python is one a new tool inherits:
"""The command-line contract every tool in this flow keeps."""
import argparse
import json
import sys
EXIT_OK = 0 # ran, and everything passed
EXIT_FAILED = 1 # ran, and something failed
EXIT_INFRA = 2 # could not run at all
SUPPORTED_SIMS = ["vcs", "xrun", "vsim"]
def make_parser(prog, description):
"""A parser carrying the flags every tool in the flow accepts."""
parser = argparse.ArgumentParser(prog=prog, description=description)
parser.add_argument("--sim", default="vcs", choices=SUPPORTED_SIMS,
help="simulator (default: %(default)s)")
parser.add_argument("--json", action="store_true",
help="emit the result as JSON on stdout")
parser.add_argument("--quiet", action="store_true",
help="suppress progress on stderr")
return parser
def emit(record, as_json):
"""The result, on stdout, in one of the two agreed shapes."""
if as_json:
print(json.dumps(record, sort_keys=True))
else:
print(f"{record['status']:<6} {record.get('name', '')}")
def note(text, quiet):
"""Commentary, on stderr, unless the caller asked for quiet."""
if not quiet:
print(text, file=sys.stderr)import flowcli
print("exit codes :", flowcli.EXIT_OK, flowcli.EXIT_FAILED, flowcli.EXIT_INFRA)
print("sims :", flowcli.SUPPORTED_SIMS)
parser = flowcli.make_parser("demo.py", "A tool in this flow.")
args = parser.parse_args(["--json", "--sim", "xrun"])
print("parsed :", vars(args))
flowcli.emit({"name": "axi_smoke", "status": "FAIL"}, as_json=False)
flowcli.emit({"name": "axi_smoke", "status": "FAIL", "errors": 2}, as_json=True)exit codes : 0 1 2
sims : ['vcs', 'xrun', 'vsim']
parsed : {'sim': 'xrun', 'json': True, 'quiet': False}
FAIL axi_smoke
{"errors": 2, "name": "axi_smoke", "status": "FAIL"}Four pieces, each enforcing one part of the agreement.
EXIT_OK, EXIT_FAILED, EXIT_INFRA are Chapter 22.4's three codes with names. sys.exit(EXIT_INFRA) reads as a decision; sys.exit(2) reads as a number somebody chose, and the next person wonders whether 2 was significant.
make_parser gives every tool the common flags. A tool that forgets --json cannot be composed with one that expects it, and this is what makes forgetting impossible.
emit puts the result on stdout in one of two agreed shapes, and sort_keys=True makes the JSON diffable — Chapter 21.4 Section 5.
note puts commentary on stderr and honours --quiet, which is Chapter 22.5 Section 7's promise that --quiet silences the commentary and never the result.
parse_args([...]) with an explicit list is worth noticing too: it takes arguments from a list instead of sys.argv, which is what makes a parser testable without a subprocess. Chapter 22.1's device becomes unnecessary for testing the declaration itself.
4. Two Tools Built on It
"""The command-line contract every tool in this flow keeps."""
import argparse
import json
import sys
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_INFRA = 2
SUPPORTED_SIMS = ["vcs", "xrun", "vsim"]
def make_parser(prog, description):
parser = argparse.ArgumentParser(prog=prog, description=description)
parser.add_argument("--sim", default="vcs", choices=SUPPORTED_SIMS,
help="simulator (default: %(default)s)")
parser.add_argument("--json", action="store_true", help="emit JSON on stdout")
parser.add_argument("--quiet", action="store_true", help="suppress progress")
return parser
def emit(record, as_json):
if as_json:
print(json.dumps(record, sort_keys=True))
else:
print(f"{record['status']:<6} {record.get('name', '')}")
def note(text, quiet):
if not quiet:
print(text, file=sys.stderr)import sys
from flowcli import make_parser, emit, note, EXIT_OK, EXIT_FAILED, EXIT_INFRA
parser = make_parser("run_sim.py", "Run one simulation.")
parser.add_argument("--test", required=True, metavar="NAME")
parser.add_argument("--seed", type=int, default=1, metavar="N")
args = parser.parse_args()
note(f"running {args.test} seed {args.seed} on {args.sim}", args.quiet)
if args.test == "missing_test":
print(f"run_sim.py: error: no such test {args.test!r}", file=sys.stderr)
sys.exit(EXIT_INFRA)
errors = 2 if args.test == "axi_smoke" else 0
record = {"name": args.test, "seed": args.seed, "errors": errors,
"status": "FAIL" if errors else "PASS"}
emit(record, args.json)
sys.exit(EXIT_FAILED if errors else EXIT_OK)import subprocess
import sys
for extra in (["--test", "axi_reset"], ["--test", "axi_smoke", "--json"],
["--test", "missing_test"], ["--test", "axi_reset", "--quiet"]):
done = subprocess.run([sys.executable, "run_sim.py"] + extra,
capture_output=True, text=True)
print(f"$ run_sim.py {' '.join(extra)}")
print(f" rc={done.returncode} out={done.stdout.strip()!r}")
print(f" err={done.stderr.strip()!r}")$ run_sim.py --test axi_reset
rc=0 out='PASS axi_reset'
err='running axi_reset seed 1 on vcs'
$ run_sim.py --test axi_smoke --json
rc=1 out='{"errors": 2, "name": "axi_smoke", "seed": 1, "status": "FAIL"}'
err='running axi_smoke seed 1 on vcs'
$ run_sim.py --test missing_test
rc=2 out=''
err="running missing_test seed 1 on vcs\nrun_sim.py: error: no such test 'missing_test'"
$ run_sim.py --test axi_reset --quiet
rc=0 out='PASS axi_reset'
err=''The tool's own code is eleven lines and none of them is about the contract — make_parser supplied the common flags, emit chose the stream and shape, note honoured --quiet, and the three constants named the statuses.
Row four is the one to check: --quiet removed the commentary and left the result exactly as it was. A --quiet that silenced stdout would have broken the next section.
5. Composition
"""The command-line contract every tool in this flow keeps."""
import argparse
import json
import sys
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_INFRA = 2
SUPPORTED_SIMS = ["vcs", "xrun", "vsim"]
def make_parser(prog, description):
parser = argparse.ArgumentParser(prog=prog, description=description)
parser.add_argument("--sim", default="vcs", choices=SUPPORTED_SIMS,
help="simulator (default: %(default)s)")
parser.add_argument("--json", action="store_true", help="emit JSON on stdout")
parser.add_argument("--quiet", action="store_true", help="suppress progress")
return parser
def emit(record, as_json):
if as_json:
print(json.dumps(record, sort_keys=True))
else:
print(f"{record['status']:<6} {record.get('name', '')}")
def note(text, quiet):
if not quiet:
print(text, file=sys.stderr)import sys
from flowcli import make_parser, emit, note, EXIT_OK, EXIT_FAILED, EXIT_INFRA
parser = make_parser("run_sim.py", "Run one simulation.")
parser.add_argument("--test", required=True, metavar="NAME")
parser.add_argument("--seed", type=int, default=1, metavar="N")
args = parser.parse_args()
note(f"running {args.test} seed {args.seed} on {args.sim}", args.quiet)
if args.test == "missing_test":
print(f"run_sim.py: error: no such test {args.test!r}", file=sys.stderr)
sys.exit(EXIT_INFRA)
errors = 2 if args.test == "axi_smoke" else 0
record = {"name": args.test, "seed": args.seed, "errors": errors,
"status": "FAIL" if errors else "PASS"}
emit(record, args.json)
sys.exit(EXIT_FAILED if errors else EXIT_OK)import json
import subprocess
import sys
from flowcli import make_parser, emit, note, EXIT_OK, EXIT_FAILED, EXIT_INFRA
SUITES = {"nightly": ["axi_smoke", "axi_reset"], "empty": []}
parser = make_parser("run_regress.py", "Run a suite of simulations.")
parser.add_argument("--suite", required=True, choices=sorted(SUITES))
args = parser.parse_args()
tests = SUITES[args.suite]
if not tests:
print(f"run_regress.py: error: suite {args.suite!r} has no tests", file=sys.stderr)
sys.exit(EXIT_INFRA)
results = []
worst = EXIT_OK
for test in tests:
note(f"-> {test}", args.quiet)
done = subprocess.run(
[sys.executable, "run_sim.py", "--test", test, "--sim", args.sim, "--json", "--quiet"],
capture_output=True, text=True)
if done.returncode == EXIT_INFRA:
print(done.stderr.strip(), file=sys.stderr)
sys.exit(EXIT_INFRA)
results.append(json.loads(done.stdout))
worst = max(worst, done.returncode)
emit({"name": args.suite, "status": "FAIL" if worst else "PASS",
"tests": len(results),
"failed": sum(1 for r in results if r["status"] == "FAIL")}, args.json)
sys.exit(worst)import subprocess
import sys
for extra in (["--suite", "nightly", "--json"], ["--suite", "nightly"], ["--suite", "empty"]):
done = subprocess.run([sys.executable, "run_regress.py"] + extra,
capture_output=True, text=True)
print(f"$ run_regress.py {' '.join(extra)}")
print(f" rc={done.returncode} out={done.stdout.strip()!r}")
print(f" err={done.stderr.strip().replace(chr(10), ' | ')!r}")$ run_regress.py --suite nightly --json
rc=1 out='{"failed": 1, "name": "nightly", "status": "FAIL", "tests": 2}'
err='-> axi_smoke | -> axi_reset'
$ run_regress.py --suite nightly
rc=1 out='FAIL nightly'
err='-> axi_smoke | -> axi_reset'
$ run_regress.py --suite empty
rc=2 out=''
err="run_regress.py: error: suite 'empty' has no tests"run_regress.py calls run_sim.py and relies on all three agreements at once:
Flag names. It passes --sim straight through by name. If the two tools spelled it differently, the suite runner would need a translation table — and the table would have to be updated every time a tool was added.
Output format. It passes --json --quiet and parses done.stdout with no filtering, because the contract guarantees stdout is exactly the record. Chapter 22.5 Section 3's four-character difference is what makes json.loads(done.stdout) safe to write without a try.
Exit codes. This is the one doing the most work. EXIT_INFRA from a child aborts the whole suite immediately, because a missing test means the suite is misconfigured and the remaining runs would be meaningless. EXIT_OK and EXIT_FAILED are reduced with max, so the suite's status is the worst of its children's. Three distinct behaviours from one number, and none of them requires reading a message.
The max reduction works precisely because the codes are ordered by severity — Chapter 22.4's ladder. That ordering is not an accident of the numbers; it is a property worth preserving if a flow ever adds a fourth.
6. What the Caller Can Rely On
Every tool in the flow promises the same five things:
| Promise | Enforced by |
|---|---|
--sim, --json, --quiet exist and mean the same thing | make_parser |
| rc 0 passed, 1 failed, 2 could not run | the three constants |
| stdout is the result; empty when rc is 2 | emit, and never printing to stdout on the error path |
| stderr is ignorable commentary | note |
--quiet silences commentary only | note taking the flag |
A caller that knows those five can use a tool it has never seen. That is the actual deliverable of this module — not any one well-behaved script, but the property that a new tool is usable without reading it.
It also makes a whole class of wrapper disappear. A nightly job becomes:
RESULTS = [
{"tool": "run_regress.py --suite nightly", "rc": 1},
{"tool": "run_regress.py --suite smoke", "rc": 0},
{"tool": "check_coverage.py --db cov.ucdb", "rc": 2},
]
worst = max(r["rc"] for r in RESULTS)
MEANING = {0: "all passed", 1: "something failed", 2: "the flow is broken"}
for result in RESULTS:
print(f" rc={result['rc']} {result['tool']}")
print()
print("nightly exit:", worst, "-", MEANING[worst])
print("who to call :", "nobody" if worst == 0 else ("design/verification" if worst == 1 else "flow owner")) rc=1 run_regress.py --suite nightly
rc=0 run_regress.py --suite smoke
rc=2 check_coverage.py --db cov.ucdb
nightly exit: 2 - the flow is broken
who to call : flow ownerThe nightly does not know what any of those tools do. It knows the contract, and the contract is enough to route the morning's email to the right person — which is the whole point of distinguishing 1 from 2 rather than "it worked" and "it did not".
7. Without the Contract
What the same nightly looks like against Section 1's three dialects:
RAW = [
{"tool": "run_sim.py", "rc": 1, "stdout": '{"status": "FAIL"}'},
{"tool": "check_log.py", "rc": 1, "stdout": "=== check_log 2.1 ===\nFAIL"},
{"tool": "summarise.py", "rc": 1, "stdout": "2 of 40 failed"},
]
def adapt(entry):
"""Every tool needs its own translation. This is the layer the contract removes."""
if entry["tool"] == "run_sim.py":
return "failed" if entry["rc"] == 1 else "ok"
if entry["tool"] == "check_log.py":
return "could not run" if entry["rc"] == 1 else "ok"
if entry["tool"] == "summarise.py":
return "something, unclear what"
return "unknown tool"
for entry in RAW:
print(f" {entry['tool']:<15} rc={entry['rc']} -> {adapt(entry)}") run_sim.py rc=1 -> failed
check_log.py rc=1 -> could not run
summarise.py rc=1 -> something, unclear whatThree identical exit statuses, three different meanings. The adapter is the only thing that knows, and it knows by having a branch per tool.
That layer has three properties that make it the worst code in a flow:
It is invisible to every validator. Nothing type-checks a mapping from a tool name to a meaning. An adapter that is wrong about check_log.py reports an infrastructure failure as a design bug, forever, and the only symptom is engineers occasionally being sent to the wrong place.
It must be updated when any tool changes, and the tool's author has no reason to know the adapter exists. Chapter 22.3's drifting usage string, at the scale of a flow.
It grows quadratically. Each new tool needs a branch, and each tool that wants to call another needs its own copy of the knowledge.
The contract removes the layer entirely. Not by making it shorter — by making it unnecessary, because there is nothing to translate.
8. Changing a Contract
A contract that cannot change is one people work around. Three rules make changing it survivable:
Add before you remove. A new flag is backwards compatible; a renamed flag breaks every caller. To rename --sim to --simulator, add the new spelling as an alias, let both work for a release, then remove the old one:
import argparse
parser = argparse.ArgumentParser(prog="run_sim.py")
parser.add_argument("--simulator", "--sim", dest="sim", default="vcs")
for argv in (["--sim", "xrun"], ["--simulator", "xrun"], []):
args = parser.parse_args(argv)
print(f"{str(argv):<22} -> sim={args.sim}")['--sim', 'xrun'] -> sim=xrun
['--simulator', 'xrun'] -> sim=xrun
[] -> sim=vcsTwo spellings, one dest, no caller broken — the same dest mechanism Chapter 22.2 Section 6 used for an on/off flag pair.
Never redefine an exit code. Adding a meaning for 3 is safe because no caller expects 3 today. Changing what 1 means is not, and the breakage is silent: every existing caller keeps doing what it did and is now wrong. If a code's meaning must change, it is a new code.
The output format is the hardest to change, which is why --json exists as a flag rather than as the default. A tool can change its human-readable output freely — nothing parses it — and its JSON record is a published interface governed by Chapter 21.1's rules. Adding a key to the record is safe; renaming or removing one is not.
9. Testing the Contract
Because the contract is a module, it can be tested without running any tool:
"""The command-line contract every tool in this flow keeps."""
import argparse
import json
import sys
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_INFRA = 2
SUPPORTED_SIMS = ["vcs", "xrun", "vsim"]
def make_parser(prog, description):
parser = argparse.ArgumentParser(prog=prog, description=description)
parser.add_argument("--sim", default="vcs", choices=SUPPORTED_SIMS,
help="simulator (default: %(default)s)")
parser.add_argument("--json", action="store_true", help="emit JSON on stdout")
parser.add_argument("--quiet", action="store_true", help="suppress progress")
return parser
def emit(record, as_json):
if as_json:
print(json.dumps(record, sort_keys=True))
else:
print(f"{record['status']:<6} {record.get('name', '')}")
def note(text, quiet):
if not quiet:
print(text, file=sys.stderr)import flowcli
CASES = []
def check(label, got, want):
CASES.append((label, got == want, got))
parser = flowcli.make_parser("t.py", "t")
check("codes are 0 1 2",
(flowcli.EXIT_OK, flowcli.EXIT_FAILED, flowcli.EXIT_INFRA), (0, 1, 2))
check("codes are ordered by severity",
flowcli.EXIT_OK < flowcli.EXIT_FAILED < flowcli.EXIT_INFRA, True)
check("--sim defaults to vcs", parser.parse_args([]).sim, "vcs")
check("--json defaults off", parser.parse_args([]).json, False)
check("--quiet defaults off", parser.parse_args([]).quiet, False)
check("every common flag present",
sorted(vars(parser.parse_args([]))), ["json", "quiet", "sim"])
rejected = False
try:
parser.parse_args(["--sim", "icarus"])
except SystemExit as exc:
rejected = exc.code == 2
check("--sim rejects unknown with rc 2", rejected, True)
for label, ok, got in CASES:
print(f"{'ok ' if ok else 'FAIL'} {label:<34} {got!r}")
print("failures:", sum(1 for _, ok, _ in CASES if not ok))usage: t.py [-h] [--sim {vcs,xrun,vsim}] [--json] [--quiet]
t.py: error: argument --sim: invalid choice: 'icarus' (choose from vcs, xrun, vsim)
ok codes are 0 1 2 (0, 1, 2)
ok codes are ordered by severity True
ok --sim defaults to vcs 'vcs'
ok --json defaults off False
ok --quiet defaults off False
ok every common flag present ['json', 'quiet', 'sim']
ok --sim rejects unknown with rc 2 True
failures: 0Seven assertions, no subprocess, no files. parse_args([...]) takes a list, so the parser's declarations are testable directly — which is the thing Chapter 22.1's hand-written parser could also have offered and a sys.argv-reading script cannot.
The second case is the one worth keeping. EXIT_OK < EXIT_FAILED < EXIT_INFRA is what makes Section 5's max() reduction correct, and it is exactly the kind of property somebody breaks while "tidying up" the constants. Chapter 19.18 Section 6's rule applies: the test that matters is the one that fails when somebody changes something they thought was cosmetic.
10. Predict, Run, Observe
import argparse
EXIT_OK, EXIT_FAILED, EXIT_INFRA = 0, 1, 2
parser = argparse.ArgumentParser(prog="t.py")
parser.add_argument("--simulator", "--sim", dest="sim", default="vcs")
parser.add_argument("--json", action="store_true")
# A
print("A:", parser.parse_args([]).sim)
# B
print("B:", parser.parse_args(["--sim", "xrun"]).sim)
# C
print("C:", parser.parse_args(["--simulator", "xrun"]).sim)
# D
print("D:", sorted(vars(parser.parse_args([]))))
# E
print("E:", max([EXIT_OK, EXIT_FAILED]), max([EXIT_FAILED, EXIT_INFRA]), max([EXIT_OK]))
# F
print("F:", EXIT_OK < EXIT_FAILED < EXIT_INFRA)
# G
print("G:", max([]) if False else "max of nothing raises")The verified output:
A: vcs
B: xrun
C: xrun
D: ['json', 'sim']
E: 1 2 0
F: True
G: max of nothing raisesA — the default, with neither spelling given.
B and C — both spellings reach the same dest, which is how a flag is renamed without breaking callers.
D — the attribute is sim, from dest, not from either flag name.
E — the max reduction: any failure beats a pass, and an infrastructure error beats a failure.
F — the ordering that makes E correct, and the assertion worth keeping in the contract test.
G — max([]) raises, which is why Chapter 22.4 Section 7's sweep returns 2 for an empty run rather than reducing nothing.
11. Exercises
Exercise 1 — find the dialects. List three tools in your own flow and, for each, its spelling of a shared concept, what exit 1 means, and what it prints on stdout.
Exercise 2 — the dangerous row. Identify two tools in your flow whose exit 1 means different things. Say what a wrapper treating non-zero as "test failed" would report.
Exercise 3 — three agreements. Write the one-sentence contract your flow would adopt for flag names, exit codes and output format.
Exercise 4 — common and specific. For two of your tools, separate the flags every tool should have from the ones only that tool needs.
Exercise 5 — the module. Write flowcli.py for your flow with named exit codes, a parser factory, an emitter and a note function.
Exercise 6 — named codes. Replace every bare sys.exit(2) in a tool with a named constant. Say what a reader learns from the name.
Exercise 7 — build on it. Rewrite one tool to use your factory. Count the lines that are about the contract rather than about the job.
Exercise 8 — quiet keeps the result. Confirm --quiet silences stderr and leaves stdout unchanged. Say what would break if it silenced both.
Exercise 9 — compose. Write a suite runner that calls your tool, parses its JSON, propagates an infrastructure error and reduces the rest with max.
Exercise 10 — three behaviours from one number. In your runner, point at the three distinct things the child's exit status caused.
Exercise 11 — the adapter. Write the translation layer your flow needs without a contract. Count its branches and say what happens when a tool is added.
Exercise 12 — rename safely. Add an alias for an existing flag with a shared dest. Confirm both spellings work and no caller breaks.
Exercise 13 — never redefine. Explain in two sentences why adding exit code 3 is safe and changing what 1 means is not.
Exercise 14 — test the agreement. Write seven assertions against your contract module with no subprocess. Include the severity-ordering one, then reorder the constants and confirm it fails.
12. Summary
- Individually well-behaved tools do not compose. Three reasonable scripts can use three spellings for the simulator and three meanings for exit 1.
- The flag names are annoying; the exit codes are dangerous, because a wrapper reading non-zero as "a test failed" misreports an infrastructure error with nothing to contradict it.
- Three agreements: flag names, exit-code meanings, and output format.
- A contract covers the flags every tool has, not the arguments specific to each job.
- Write the agreement as a module, not a wiki page — a parser factory, named exit codes, an emitter and a note function, in about thirty lines.
- Named constants read as decisions;
sys.exit(2)reads as a number somebody chose. --quietsilences the commentary and never the result, or composition breaks.- Composition uses all three agreements at once: pass
--simthrough by name, parse stdout with no filtering, and let the exit status drive three distinct behaviours. - An infrastructure error aborts a suite; a failure is reduced with
max— which works because the codes are ordered by severity. - Five promises make a tool usable without reading it, which is the module's real deliverable.
- Without a contract every pair of tools needs an adapter, and the adapter is invisible to every validator, must change when any tool does, and grows with every tool added.
- The adapter is written by whoever wrote neither tool, which is why it is wrong so often.
- Add before you remove. A flag alias with a shared
destrenames without breaking callers. - Never redefine an exit code — the breakage is silent, because every existing caller keeps doing what it did.
- Adding a key to the JSON record is safe; renaming or removing one is not, which is Chapter 21.1's contract applied to a tool's output.
- Test the agreement without a subprocess.
parse_args([...])takes a list, and the severity-ordering assertion is the one that catches a tidy-up.
That completes Module 22, and with it the interface half of a flow tool. A script now takes arguments somebody else can predict, refuses bad ones with a message they can act on, reports its result to a machine with a number and to a person with a line, and keeps the same promises as every other tool beside it. Module 23 turns the direction around: instead of being called, the script starts calling the simulator itself.
Where this fits
Part of the Python curriculum.
