Skip to content
VLSI Mentor

Python Part II · Module 34 · From Script to Maintainable Tool

The Script That Outgrew Itself

Every team has one: the script that runs the regression, that one person wrote, that nobody will touch. It started at twenty lines and it works — which is the problem, because working is the only evidence anyone has that it is correct. This chapter reads one in full and measures it rather than describing it, and the measurements turn up something nobody on that team knows: it throws away every result it collects, writes an empty report, and reports success.

Intermediate24 min readPythonPart IIAutomationRefactoringCode Structure

Part II · Module 34 · Chapter 34.1 · The Script That Outgrew Itself

1. The Script Everyone Avoids

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SIGNS = [
    ("it works", "nobody wants to change it", "working is the only evidence"),
    ("one person wrote it", "only they can change it", "no shared model of it"),
    ("it has no tests", "every change is a gamble", "33.1's argument, ignored"),
    ("it prints its results", "no other tool can use them", "§8"),
    ("it always exits 0", "CI cannot tell it failed", "§8"),
    ("paths are hard-coded", "it runs on one machine", "§5"),
]

print(f"{'what is true of it':<24} {'what follows':<34} why")
for a, b, c in SIGNS:
    print(f"{a:<24} {b:<34} {c}")

print()
print("signs that are about the CODE :", 3)
print("signs that are about the TEAM :", 3)
print("and the two reinforce each other:", True)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what is true of it       what follows                       why
it works                 nobody wants to change it          working is the only evidence
one person wrote it      only they can change it            no shared model of it
it has no tests          every change is a gamble           33.1's argument, ignored
it prints its results    no other tool can use them         §8
it always exits 0        CI cannot tell it failed           §8
paths are hard-coded     it runs on one machine             §5

signs that are about the CODE : 3
signs that are about the TEAM : 3
and the two reinforce each other: True

The first row is the one that traps a team. A script that produces plausible output every morning has accumulated months of evidence that it is fine, and none of that evidence is about its correctness — it is about its output looking the way yesterday's did. Section 6 is what that costs.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# hard-coded paths, one function, printed results, no exit code, no tests

Five defects, named in advance. The rest of this chapter checks the specimen against that list and finds all five, plus four the annotation does not mention.

Here is the script. It is the module's running example, and Chapters 34.2 through 34.7 take it apart in that order:

Azvya Education Pvt. Ltd.VLSI Mentor
run_regress.py
#!/usr/bin/env python
# run_regress.py - run the axi_fifo regression
# azhar, march 2024. do not delete, dv flow depends on this
import os, sys, re, subprocess, glob, time

PROJ = "/proj/chip_a/users/azhar/axi_fifo"
RUNS = "/proj/chip_a/users/azhar/axi_fifo/runs"
RTL = "/proj/chip_a/users/azhar/axi_fifo/rtl"
TB = "/proj/chip_a/users/azhar/axi_fifo/tb"
FILELIST = "/proj/chip_a/users/azhar/axi_fifo/rtl/files.f"
SIM = "/tools/cadence/xcelium/19.09/bin/xrun"
LICENSE = "5280@lic-server-3"
TESTS = ["axi_burst_test", "axi_lite_test", "axi_wrap_test", "axi_err_test"]
SEEDS = [4121, 7734, 9001]
UVM_HOME = "/tools/cadence/xcelium/19.09/tools/uvm/uvm-1.2"

print("=== axi_fifo regression ===")
os.environ["LM_LICENSE_FILE"] = LICENSE
os.environ["UVM_HOME"] = UVM_HOME

def run_everything():
    total = 0
    passed = 0
    failed = 0
    for t in TESTS:
        for s in SEEDS:
            total = total + 1
            rundir = RUNS + "/" + t + "_" + str(s)
            if not os.path.isdir(rundir):
                os.makedirs(rundir)
            log = rundir + "/sim.log"
            cmd = SIM + " -f " + FILELIST + " +UVM_TESTNAME=" + t + \
                  " +ntb_random_seed=" + str(s) + " -l " + log
            print("running " + t + " seed " + str(s))
            p = subprocess.Popen(cmd, shell=True, cwd=rundir,
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.PIPE)
            out, err = p.communicate()
            if not os.path.exists(log):
                print("  no log!")
                failed = failed + 1
                continue
            f = open(log)
            text = f.read()
            f.close()
            errs = 0
            fatals = 0
            warns = 0
            tname = ""
            seed = 0
            for line in text.split("\n"):
                if line[0:9] == "UVM_ERROR":
                    if line.find("UVM_ERROR :") == -1:
                        errs = errs + 1
                if line[0:9] == "UVM_FATAL":
                    if line.find("UVM_FATAL :") == -1:
                        fatals = fatals + 1
                if line[0:11] == "UVM_WARNING":
                    warns = warns + 1
                m = re.search("UVM_TESTNAME=(\\S+)", line)
                if m:
                    tname = m.group(1)
                m2 = re.search("ntb_random_seed=(\\d+)", line)
                if m2:
                    seed = int(m2.group(1))
            if text.find("TEST DONE") == -1:
                print("  TIMEOUT or crash")
                failed = failed + 1
            elif fatals > 0:
                print("  FATAL")
                failed = failed + 1
            elif errs > 0:
                print("  FAIL with " + str(errs) + " errors")
                failed = failed + 1
            else:
                print("  PASS")
                passed = passed + 1
    print("")
    print("total " + str(total))
    print("passed " + str(passed))
    print("failed " + str(failed))
    print("pass rate " + str(100 * passed / total) + "%")
    for d in glob.glob(RUNS + "/*"):
        for junk in glob.glob(d + "/*.tmp"):
            os.remove(junk)

run_everything()
print("=== done ===")

RESULTS = []

def report(rows, out=[]):
    out.append("test,seed,status,errors")
    for r in rows:
        out.append(r["test"] + "," + str(r["seed"]) + "," +
                   r["status"] + "," + str(r["errors"]))
    f = open(RUNS + "/summary.csv", "w")
    f.write("\n".join(out))
    f.close()
    print("wrote " + RUNS + "/summary.csv")
    html = "<html><body><table>"
    for r in rows:
        if r["status"] == "PASS":
            html = html + "<tr bgcolor=green>"
        else:
            html = html + "<tr bgcolor=red>"
        html = html + "<td>" + r["test"] + "</td><td>" + str(r["seed"]) + \
               "</td><td>" + r["status"] + "</td></tr>"
    html = html + "</table></body></html>"
    f2 = open(RUNS + "/summary.html", "w")
    f2.write(html)
    f2.close()
    print("wrote " + RUNS + "/summary.html")
    try:
        subprocess.call("mail -s 'axi_fifo regression' dv-team@example.com < " +
                        RUNS + "/summary.csv", shell=True)
    except:
        pass
    worst = None
    for r in rows:
        if r["status"] != "PASS":
            if worst is None:
                worst = r
            elif r["errors"] > worst["errors"]:
                worst = r
    if worst:
        print("worst: " + worst["test"] + " seed " + str(worst["seed"]))
        log = RUNS + "/" + worst["test"] + "_" + str(worst["seed"]) + "/sim.log"
        f3 = open(log)
        t = f3.read()
        f3.close()
        n = 0
        for line in t.split("\n"):
            if line[0:9] == "UVM_ERROR":
                if line.find("UVM_ERROR :") == -1:
                    n = n + 1
                    print("  " + line[0:100])
                    if n > 5:
                        break

def check_regressions():
    old = RUNS + "/summary_last.csv"
    if not os.path.exists(old):
        print("no baseline")
        return
    f = open(old)
    prev = {}
    for line in f.read().split("\n")[1:]:
        if line.strip() == "":
            continue
        p = line.split(",")
        prev[p[0] + "_" + p[1]] = p[2]
    f.close()
    for r in RESULTS:
        k = r["test"] + "_" + str(r["seed"])
        if k in prev:
            if prev[k] == "PASS" and r["status"] != "PASS":
                print("NEW FAILURE: " + k)
            if prev[k] != "PASS" and r["status"] == "PASS":
                print("FIXED: " + k)
    os.rename(RUNS + "/summary.csv", old)

report(RESULTS)
check_regressions()
print("regression finished at " + time.ctime())

Read it before going on. It is not a strawman — it compiles, it has a sensible structure in outline (run, report, compare), its parsing handles the UVM_ERROR : summary line that Chapter 24.3 warned about, and somebody competent wrote it under time pressure. That is what makes it worth measuring.

3. Measured, Not Described

Calling a script "messy" invites disagreement. ast replaces the adjective with counts: it parses Python into a tree you can walk, it ships with the interpreter, and it never runs the code it reads — which matters here, because the specimen calls xrun, emails the DV team, and writes under /proj.

Everything the rest of this chapter claims about run_regress.py is measured by this one program, reading the file above:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import ast
import re
from pathlib import Path

src = Path("run_regress.py").read_text(encoding="utf-8")
tree = ast.parse(src)
lines = src.split("\n")
funcs = [n for n in tree.body if isinstance(n, ast.FunctionDef)]

WORLD = {"open", "makedirs", "remove", "rename", "Popen", "call", "system",
         "isdir", "exists", "glob", "ctime", "communicate", "write", "read",
         "close", "print"}


def world_calls(node):
    n = 0
    for sub in ast.walk(node):
        if isinstance(sub, ast.Call):
            name = (sub.func.attr if isinstance(sub.func, ast.Attribute)
                    else sub.func.id if isinstance(sub.func, ast.Name)
                    else "?")
            if name in WORLD:
                n += 1
    return n


def branches(node):
    return sum(1 for sub in ast.walk(node)
               if isinstance(sub, (ast.If, ast.For, ast.While, ast.Try)))


print("== size and shape")
print("  lines                    :", len(lines))
print("  code lines               :", sum(1 for l in lines if l.strip()
                                          and not l.strip().startswith("#")))
print("  functions                :", len(funcs))
print("  classes                  :", sum(1 for n in ast.walk(tree)
                                          if isinstance(n, ast.ClassDef)))
print("  branch/loop statements   :", branches(tree))

print()
print("== the annotation's five defects")
# A naive "looks absolute" regex also catches concatenation FRAGMENTS like
# "/sim.log", so report both and keep only strings with a real root segment.
naive = re.findall(r'"(/[A-Za-z0-9/._@-]+)"', src)
rooted = [x for x in naive if x.count("/") > 1]
print("  path-like literals         :", len(naive))
print("  distinct, naively           :", len(set(naive)))
print("  distinct with a real root   :", len(set(rooted)))
print("  fragments the naive count   :",
      sorted(set(naive) - set(rooted)))
print("  print() calls              :", src.count("print("))
print("  sys.exit calls             :", src.count("sys.exit"))
print("  `if __name__` guard        :", "__name__" in src)
print("  module-level calls         :", sum(1 for n in tree.body
                                            if isinstance(n, ast.Expr)
                                            and isinstance(n.value, ast.Call)))

print()
print("== four it does not name")
print("  mutable default arguments  :", sum(1 for f in funcs
                                            for d in f.args.defaults
                                            if isinstance(d, (ast.List,
                                                              ast.Dict,
                                                              ast.Set))))
print("  bare `except:`             :", len(re.findall(r'except:\s*$', src,
                                                      re.M)))
print("  open() outside a with-block:", src.count("open(")
                                        - src.count("with open("))
print("  shell=True subprocesses    :", src.count("shell=True"))
print("  UPPER-CASE module globals  :", len([n for n in tree.body
                                             if isinstance(n, ast.Assign)
                                             and isinstance(n.targets[0],
                                                            ast.Name)
                                             and n.targets[0].id.isupper()]))
print("  UVM_ERROR parse written    :",
      src.count('line[0:9] == "UVM_ERROR"'), "times")

print()
print("== per function")
print("  %-20s %5s %9s %5s %8s %6s %9s"
      % ("name", "lines", "branches", "args", "returns", "world", "testable"))
for f in funcs:
    span = f.end_lineno - f.lineno + 1
    rets = sum(1 for n in ast.walk(f)
               if isinstance(n, ast.Return) and n.value)
    w = world_calls(f)
    print("  %-20s %5d %9d %5d %8d %6d %9s"
          % (f.name, span, branches(f), len(f.args.args), rets, w,
             rets > 0 and w == 0))
print("  functions callable in a test:",
      sum(1 for f in funcs if world_calls(f) == 0
          and any(isinstance(n, ast.Return) and n.value
                  for n in ast.walk(f))))
print("  lines inside functions      :",
      sum(f.end_lineno - f.lineno + 1 for f in funcs))

print()
print("== the RESULTS global")
stores = [n.lineno for n in ast.walk(tree) if isinstance(n, ast.Name)
          and n.id == "RESULTS" and isinstance(n.ctx, ast.Store)]
loads = [n.lineno for n in ast.walk(tree) if isinstance(n, ast.Name)
         and n.id == "RESULTS" and isinstance(n.ctx, ast.Load)]
muts = [(n.attr, n.lineno) for n in ast.walk(tree)
        if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name)
        and n.value.id == "RESULTS"]
print("  assigned at lines        :", stores)
print("  read at lines            :", loads)
print("  appended to / mutated    :", muts)

print()
print("== distinct rooted paths, categorised")
for pth in sorted(set(rooted)):
    kind = ("one engineer's scratch directory"
            if pth.startswith("/proj/chip_a/users/") else
            "one tool install, pinned version")
    print("  %-50s %s" % (pth, kind))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
== size and shape
  lines                    : 166
  code lines               : 154
  functions                : 3
  classes                  : 0
  branch/loop statements   : 37

== the annotation's five defects
  path-like literals         : 16
  distinct, naively           : 11
  distinct with a real root   : 7
  fragments the naive count   : ['/sim.log', '/summary.csv', '/summary.html', '/summary_last.csv']
  print() calls              : 21
  sys.exit calls             : 0
  `if __name__` guard        : False
  module-level calls         : 6

== four it does not name
  mutable default arguments  : 1
  bare `except:`             : 1
  open() outside a with-block: 6
  shell=True subprocesses    : 2
  UPPER-CASE module globals  : 11
  UVM_ERROR parse written    : 2 times

== per function
  name                 lines  branches  args  returns  world  testable
  run_everything          65        17     0        0     22     False
  report                  48        13     2        0     14     False
  check_regressions       21         7     0        0      8     False
  functions callable in a test: 0
  lines inside functions      : 134

== the RESULTS global
  assigned at lines        : [90]
  read at lines            : [154, 163]
  appended to / mutated    : []

== distinct rooted paths, categorised
  /proj/chip_a/users/azhar/axi_fifo                  one engineer's scratch directory
  /proj/chip_a/users/azhar/axi_fifo/rtl              one engineer's scratch directory
  /proj/chip_a/users/azhar/axi_fifo/rtl/files.f      one engineer's scratch directory
  /proj/chip_a/users/azhar/axi_fifo/runs             one engineer's scratch directory
  /proj/chip_a/users/azhar/axi_fifo/tb               one engineer's scratch directory
  /tools/cadence/xcelium/19.09/bin/xrun              one tool install, pinned version
  /tools/cadence/xcelium/19.09/tools/uvm/uvm-1.2     one tool install, pinned version

That is the whole chapter's evidence in one run, and the annotation's five defects are all in it: sixteen path-like literals, three functions whose shape Section 4 examines, 21 print calls, sys.exit appearing 0 times, and — Section 10 — zero functions a test could call.

The path count is reported three ways on purpose, because the obvious measurement is wrong. A regex for "a quoted string starting with a slash" finds eleven distinct values, four of which are not paths at all: /sim.log, /summary.csv, /summary.html and /summary_last.csv are fragments the script concatenates onto a root. Requiring a second slash leaves seven genuine rooted paths. The naive number would have overstated the problem by more than half, and this is the module's own lesson applied to its own instruments: a count is only better than an adjective if the count is right.

Four more it does not name are there too: a mutable default argument, a bare except, six open calls outside a with, and two shell=True invocations. And one it could not have named is the RESULTS block, which is Section 6.

4. Three Functions, Thirty-Seven Branches

The per-function table in Section 3 is the one to read twice:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  name                 lines  branches  args  returns  world  testable
  run_everything          65        17     0        0     22     False
  report                  48        13     2        0     14     False
  check_regressions       21         7     0        0      8     False

The annotation says "one function" and three is close enough, because the count that matters is not how many functions exist but how many jobs each one owns:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
JOBS = {
    "run_everything": ["build a run-directory path", "create the directory",
                       "build the simulator command line", "launch the tool",
                       "wait for it", "check the log exists", "read the log",
                       "count severities", "extract the test name and seed",
                       "classify the run", "tally the counters",
                       "print each verdict", "compute the pass rate",
                       "delete temporary files"],
    "report": ["build CSV text", "write the CSV", "build HTML",
               "write the HTML", "send mail", "find the worst failure",
               "re-read that run's log", "re-count its errors",
               "print the first few"],
    "check_regressions": ["find the baseline", "parse the baseline CSV",
                          "compare each row", "print new failures",
                          "print fixes", "rotate the baseline file"],
}

print(f"{'function':<20} {'jobs':>5}  {'lines':>6} {'lines per job':>14}")
LINES = {"run_everything": 65, "report": 48, "check_regressions": 21}
for name, jobs in JOBS.items():
    print(f"{name:<20} {len(jobs):>5}  {LINES[name]:>6} "
          f"{LINES[name] / len(jobs):>14.1f}")

print()
print("total distinct jobs in 134 lines:", sum(len(j) for j in JOBS.values()))
print("deepest nesting in run_everything:", 6)
print()
print("to change the severity-counting rule you must edit a function that:")
for j in JOBS["run_everything"][:5]:
    print("   -", j)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
function              jobs   lines  lines per job
run_everything          14      65            4.6
report                   9      48            5.3
check_regressions        6      21            3.5

total distinct jobs in 134 lines: 29
deepest nesting in run_everything: 6

to change the severity-counting rule you must edit a function that:
   - build a run-directory path
   - create the directory
   - build the simulator command line
   - launch the tool
   - wait for it

Twenty-nine distinct jobs across three functions, and run_everything owns fourteen of them nested six deep. That is what makes it unchangeable rather than merely long: the severity-counting rule lives inside a function that also launches a simulator, so there is no way to exercise a change to it without a licence, a design, and twenty minutes.

Chapter 33.2's split exists precisely to break that coupling, and Chapter 34.3 applies it here — three layers, one direction of dependency, with the counting in the middle one where a test can reach it.

The args column is the quiet one. Two of the three functions take no arguments, so they cannot be called with different inputs; they read module globals instead, which is Chapter 33.2 Section 10's hidden-input problem at the scale of a whole program. And the returns column is zero across the board — Section 6 is what that costs.

5. Seven Machine Facts, Sixteen Edits

Section 3 reported both numbers, and they say different things:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
LITERALS, DISTINCT = 16, 7

FACTS = [
    ("a username", "azhar", "nobody else can run it, including a teammate"),
    ("a project", "chip_a", "a second project cannot use it"),
    ("a block", "axi_fifo", "a second block cannot use it"),
    ("a scratch root", "/proj/.../users", "no shared location at all"),
    ("a tool vendor", "cadence", "one simulator only"),
    ("a tool version", "19.09", "breaks the day the site upgrades"),
    ("a licence server", "5280@lic-server-3", "one site only"),
]

print(f"{'what is baked in':<18} {'value':<22} consequence")
for what, val, why in FACTS:
    print(f"{what:<18} {val:<22} {why}")

print()
print("rooted paths (facts about one machine):", DISTINCT)
print("path-like literals (edits to make)    :", LITERALS)
print("the gap                               :", LITERALS - DISTINCT)
print("the gap exists because                :",
      "PROJ is spelled out again instead of composed from")
print()
print("a second team can use this script  :", False)
print("the author on another machine can  :", False)
print("the failure when the site upgrades :",
      "looks like the simulator vanished")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what is baked in   value                  consequence
a username         azhar                  nobody else can run it, including a teammate
a project          chip_a                 a second project cannot use it
a block            axi_fifo               a second block cannot use it
a scratch root     /proj/.../users        no shared location at all
a tool vendor      cadence                one simulator only
a tool version     19.09                  breaks the day the site upgrades
a licence server   5280@lic-server-3      one site only

rooted paths (facts about one machine): 7
path-like literals (edits to make)    : 16
the gap                               : 9
the gap exists because                : PROJ is spelled out again instead of composed from

a second team can use this script  : False
the author on another machine can  : False
the failure when the site upgrades : looks like the simulator vanished

Seven facts and sixteen edits. Seven is how much of one machine is baked in; sixteen is how many string literals mention a path at all, and the nine-edit gap exists purely because PROJ is written out in full five more times rather than being composed from — RUNS = PROJ + "/runs" was available and was not used.

The username is the detail that makes this unfixable by goodwill. /proj/chip_a/users/azhar is not a shared location, so the author's own teammate cannot run it, and the licence server pins it to one site. Chapter 34.5 is entirely about moving these seven facts into configuration, and its annotation is the shape they move into:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
RUN_ROOT = Path(os.environ.get("FLOW_RUN_ROOT", ROOT / "runs"))

Note what the tool paths pin that the others do not: a version. xcelium/19.09 means the script breaks on upgrade day, and the symptom — a path that no longer exists — reads as the simulator having vanished rather than as a stale constant.

6. The Bug the Structure Hid

Everything so far is a style judgement a reasonable engineer could argue with. This is not. From Section 3:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
== the RESULTS global
  assigned at lines        : [90]
  read at lines            : [154, 163]
  appended to / mutated    : []

RESULTS is assigned an empty list and never appended to, and it is the list both report and check_regressions work from. Trace the consequences:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
RESULTS = []            # exactly what the specimen has at line 90
rows = RESULTS          # exactly what report(RESULTS) receives

csv_lines = ["test,seed,status,errors"]
for r in rows:
    csv_lines.append("...")

html_rows = [r for r in rows]
worst = None
for r in rows:
    if r.get("status") != "PASS":
        worst = r

print("simulations the script runs      :", 4 * 3)
print("rows reaching report()           :", len(rows))
print("lines in summary.csv             :", len(csv_lines), "(the header)")
print("rows in the HTML table           :", len(html_rows))
print("the 'worst failure' it reports   :", worst)
print("baseline comparisons it performs :", len(rows))
print("NEW FAILURE lines it can print   :", 0)
print()
print("what the terminal shows          :", "a correct per-run summary")
print("because those counters are       :", "local to run_everything")
print("and the exit code is             :", 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
simulations the script runs      : 12
rows reaching report()           : 0
lines in summary.csv             : 1 (the header)
rows in the HTML table           : 0
the 'worst failure' it reports   : None
baseline comparisons it performs : 0
NEW FAILURE lines it can print   : 0

what the terminal shows          : a correct per-run summary
because those counters are       : local to run_everything
and the exit code is             : 0

Twelve simulations run and zero results recorded. The script emails a CSV containing one header line, writes an HTML file with an empty table, never identifies a worst failure, and compares nothing against the baseline — so Chapter 30.3's new-versus-fixed detection, which is the whole reason check_regressions exists, has never once produced a line of output.

And nobody noticed, for a specific and instructive reason: run_everything keeps its tallies in local variables and prints them. The terminal output is correct. total 12, passed 9, failed 3, and a per-run PASS/FAIL for each — all accurate, all derived from counters that are discarded when the function returns. The structured outputs, which nobody reads because the terminal already told them what they wanted, are empty.

The structural cause is worth naming precisely, because it is what Chapter 34.2 fixes. run_everything returns nothing — its signature is def run_everything(): with no return — so the only way for it to communicate is a side effect, and the side effect it was supposed to perform (appending to RESULTS) was never written. A function that returned its rows could not have had this bug: the caller would have had nothing to pass on.

7. Two Defects Visible From the Signature

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def report(rows, out=[]):
    """The specimen's signature, exactly. `out` is the defect."""
    out.append("test,seed,status,errors")
    for r in rows:
        out.append(r["test"])
    return out


FIRST = [{"test": "axi_burst_test"}]
SECOND = [{"test": "axi_lite_test"}]

print("call 1:", report(FIRST))
print("call 2:", report(SECOND))
print("call 3:", report([]))
print()
final = report([])
print("lines the 4th call would write:", len(final))
print("header lines among them       :", final.count("test,seed,status,errors"))
print("the default list is created   :", "once, when def runs — not per call")
print("so a second regression in one process appends to the first's report:",
      True)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
call 1: ['test,seed,status,errors', 'axi_burst_test']
call 2: ['test,seed,status,errors', 'axi_burst_test', 'test,seed,status,errors', 'axi_lite_test']
call 3: ['test,seed,status,errors', 'axi_burst_test', 'test,seed,status,errors', 'axi_lite_test', 'test,seed,status,errors']

lines the 4th call would write: 6
header lines among them       : 4
the default list is created   : once, when def runs — not per call
so a second regression in one process appends to the first's report: True

Four header lines in one CSV. A default argument is evaluated once, when the def statement executes, so every call that does not pass out shares the same list. In the specimen this is dormant because report is called once — which is the worst kind of latent defect, waiting for the day somebody imports this module and calls report twice.

The second signature-level defect is arithmetic:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for passed, total in ((9, 12), (0, 12), (12, 12), (1, 3)):
    print(f"  passed={passed:>3} total={total:>3}  "
          f"printed as: pass rate {100 * passed / total}%")

print()
print("  the type of 100 * 9 / 12:", type(100 * 9 / 12).__name__)
print("  so a clean run prints   :", "pass rate 100.0%")
print("  and 1 of 3 prints       :", f"pass rate {100 * 1 / 3}%")
print()
try:
    print(100 * 0 / 0)
except ZeroDivisionError as exc:
    print("  an empty TESTS list     :", type(exc).__name__, "-", exc)
print("  which happens when      :", "a filter leaves no tests to run")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  passed=  9 total= 12  printed as: pass rate 75.0%
  passed=  0 total= 12  printed as: pass rate 0.0%
  passed= 12 total= 12  printed as: pass rate 100.0%
  passed=  1 total=  3  printed as: pass rate 33.333333333333336%

  the type of 100 * 9 / 12: float
  so a clean run prints   : pass rate 100.0%
  and 1 of 3 prints       : pass rate 33.333333333333336%

  an empty TESTS list     : ZeroDivisionError - division by zero
  which happens when      : a filter leaves no tests to run

pass rate 33.333333333333336% is what a human reads in the morning, and Chapter 7.4's formatting is one :.1f away. The ZeroDivisionError is the sharper problem: an empty test list is a perfectly ordinary thing to produce with a filter, and the script's response is a traceback after it has already run the simulations.

8. Printed, Not Returned, and Always Zero

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
OUTPUTS = [
    ("the per-run verdict", "print", "a human watching", "no", "correct"),
    ("the totals", "print", "a human watching", "no", "correct"),
    ("the pass rate", "print", "a human watching", "no", "correct"),
    ("summary.csv", "a file", "another script", "yes", "header only"),
    ("summary.html", "a file", "a browser", "yes", "empty table"),
    ("the exit code", "none exists", "CI", "yes", "always 0"),
]

print(f"{'what it produces':<22} {'how':<13} {'read by':<18} "
      f"{'machine-readable':<17} state")
for what, how, who, mr, state in OUTPUTS:
    print(f"{what:<22} {how:<13} {who:<18} {mr:<17} {state}")

correct_and_machine = [o for o in OUTPUTS if o[3] == "yes" and o[4] == "correct"]
print()
print("outputs a machine can consume   :", sum(1 for o in OUTPUTS if o[3] == "yes"))
print("of those, ones that are correct :", len(correct_and_machine))
print("outputs that are correct        :", sum(1 for o in OUTPUTS if o[4] == "correct"))
print("of those, ones a machine can use:", 0)
print()
print("sys.exit calls in the specimen  :", 0, "— measured in §3")
print("so a CI job wrapping it sees    :", "success, on every possible outcome")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what it produces       how           read by            machine-readable  state
the per-run verdict    print         a human watching   no                correct
the totals             print         a human watching   no                correct
the pass rate          print         a human watching   no                correct
summary.csv            a file        another script     yes               header only
summary.html           a file        a browser          yes               empty table
the exit code          none exists   CI                 yes               always 0

outputs a machine can consume   : 3
of those, ones that are correct : 0
outputs that are correct        : 3
of those, ones a machine can use: 0

sys.exit calls in the specimen  : 0 — measured in §3
so a CI job wrapping it sees    : success, on every possible outcome

The two sets do not intersect. Three outputs are machine-readable and none of them is correct; three are correct and none of them is machine-readable. That is a complete account of why this script works in use and is unusable in automation, and it is not a coincidence — the correct ones are the ones a human checks every morning, and the broken ones are the ones nobody looks at.

The exit code is the sharpest case because it does not exist at all. Section 3 measured sys.exit appearing zero times, so every outcome — twelve clean runs, twelve failures, a ZeroDivisionError from Section 7, a missing filelist — leaves the process exiting 0. A CI job that wraps this script and branches on $? is branching on a constant.

One small, telling detail sits in the specimen's imports: sys is imported on line 4 and never used anywhere in 166 lines. Somebody intended to exit with a status — that import is the fossil of the intention — and the line was never written. Chapter 33.8's contract is exactly what is missing, and Chapter 34.6's annotation states it as a promise the next owner can rely on:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
"""run_regress.py - run a suite; exit 0 only if every job passed."""

9. Importing It Runs It

The specimen has no if __name__ == "__main__": guard, and six calls at module level. That has one consequence that matters more than all the style points:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import contextlib
import io
from pathlib import Path

Path("unguarded.py").write_text(
    'print("=== regression starting ===")\n'
    'TESTS = ["axi_burst_test"]\n'
    '\n'
    'def classify(text):\n'
    '    return "FAIL" if "UVM_ERROR" in text else "PASS"\n'
    '\n'
    'print("running", TESTS[0])\n'
    'print("=== done ===")\n', encoding="utf-8")

Path("guarded.py").write_text(
    'TESTS = ["axi_burst_test"]\n'
    '\n'
    'def classify(text):\n'
    '    return "FAIL" if "UVM_ERROR" in text else "PASS"\n'
    '\n'
    'def main():\n'
    '    print("=== regression starting ===")\n'
    '    print("running", TESTS[0])\n'
    '    print("=== done ===")\n'
    '    return 0\n'
    '\n'
    'if __name__ == "__main__":\n'
    '    import sys\n'
    '    sys.exit(main())\n', encoding="utf-8")

import sys
sys.path.insert(0, ".")

for name in ("unguarded", "guarded"):
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        mod = __import__(name)
    printed = len(buf.getvalue().strip().splitlines())
    print(f"  import {name:<12} printed {printed} line(s), "
          f"classify usable = {mod.classify('UVM_ERROR x') == 'FAIL'}")

print()
print("the guard costs            :", "3 lines")
print("what it buys               :", "the file becomes importable")
print("and 34.2's first line      :", "from flow.parse import parse_sim_log")
print("which is impossible without:", "the guard")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  import unguarded    printed 3 line(s), classify usable = True
  import guarded      printed 0 line(s), classify usable = True

the guard costs            : 3 lines
what it buys               : the file becomes importable
and 34.2's first line      : from flow.parse import parse_sim_log
which is impossible without: the guard

Both files expose classify; only one can be imported without running the regression. In the specimen, import run_regress would set two environment variables, create directories under /proj, launch twelve simulations, and email the DV team — as a side effect of wanting to reuse one parsing function.

That is why "no tests" in the annotation is not a matter of discipline. A test file begins by importing the module under test, and here the import is the regression. The guard is three lines and it is the entire difference between a script and something another file can use, which is Chapter 13.6's point arriving as a precondition for the whole module.

10. The Testable Surface Is Empty

The last column of Section 3's table is the one this whole module exists to change:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  name                 lines  branches  args  returns  world  testable
  run_everything          65        17     0        0     22     False
  report                  48        13     2        0     14     False
  check_regressions       21         7     0        0      8     False
  functions callable in a test: 0
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CRITERIA = [
    ("returns a value", "0 of 3", "nothing to assert on"),
    ("free of world calls", "0 of 3", "calling it does something"),
    ("takes arguments", "1 of 3", "cannot be driven with test inputs"),
]

print(f"{'what a test needs':<22} {'specimen':<9} why it fails")
for what, got, why in CRITERIA:
    print(f"{what:<22} {got:<9} {why}")

print()
print("functions a test could call      :", 0)
print("lines inside functions           :", 134)
print()
print("33.2 measured a real runner's pure fraction:", "62%")
print("the specimen's                             :", "0%")
print()
PURE_LOGIC = ["counting UVM_ERROR lines", "excluding the UVM_ERROR : summary",
              "classifying FATAL / FAIL / TIMEOUT / PASS",
              "computing the pass rate", "choosing the worst failure",
              "diffing against the baseline"]
print("pure computations trapped inside impure functions:", len(PURE_LOGIC))
for x in PURE_LOGIC:
    print("   -", x)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what a test needs      specimen  why it fails
returns a value        0 of 3    nothing to assert on
free of world calls    0 of 3    calling it does something
takes arguments        1 of 3    cannot be driven with test inputs

functions a test could call      : 0
lines inside functions           : 134

33.2 measured a real runner's pure fraction: 62%
the specimen's                             : 0%

pure computations trapped inside impure functions: 6
   - counting UVM_ERROR lines
   - excluding the UVM_ERROR : summary
   - classifying FATAL / FAIL / TIMEOUT / PASS
   - computing the pass rate
   - choosing the worst failure
   - diffing against the baseline

Zero of three functions can be called from a test, and the reason is not that the logic is inherently impure. Six genuinely pure computations are listed there — the severity counting, the summary-line exclusion that Chapter 24.3 warned about, the four-way classification, the arithmetic, the worst-failure selection, and the baseline diff. Every one of them is a function of its inputs. Every one of them is written inside a function that also launches a simulator or writes a file.

That is the entire thesis of this module. The logic is fine. Its arrangement makes it untestable, unreusable, and — as Section 6 showed — capable of hiding a total failure behind a correct-looking print.

11. The Road Out

One script, six refactorings

A seven-stage refactoring path for one script: name the defects by measuring them, split one file into importable modules with one direction of dependency, separate tool control from parsing from reporting, introduce a class only where state and behaviour travel together, move machine-specific facts into configuration, document the contract for the next owner, and then change the tool without breaking the wrappers around it.01MEASURE THE DEFECTScounts, not adjectives — 34.102ONE FILE TO MODULESthe guard makes import possible — 34.203THREE LAYERS, ONE DIRECTIONtools, parse, report — 34.304A CLASS ONLY IF EARNEDstate and behaviour travel — 34.405ASSUMPTIONS TO CONFIGthe 7 machine facts — 34.506DOCUMENT THE CONTRACTflags, codes, non-goals — 34.607CHANGE IT SAFELYwrappers already depend on it — 34.7
Each stage removes one of the defects measured above and nothing else. The order matters: the guard and the module split come first because every later stage needs the file to be importable, and the configuration stage comes before documentation because you cannot document a contract the code does not yet have.

Step 1 is the one that is usually skipped, and skipping it is why refactoring arguments go nowhere. "This script is a mess" invites disagreement; "this script has sixteen hard-coded paths, zero testable functions, and a global that is read twice and never written" does not.

Step 4's caption is deliberately conditional. Chapter 34.4's own purpose warns that a premature class costs more than it saves, so that stage can legitimately be skipped — it is the only one of the six that might be.

12. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import ast

SRC = (
    "RESULTS = []\n"
    "def collect():\n"
    "    rows = []\n"
    "    rows.append({'test': 'axi_burst_test'})\n"
    "def emit(rows, out=[]):\n"
    "    out.append('header')\n"
    "    return out\n"
    "collect()\n"
    "emit(RESULTS)\n"
)
tree = ast.parse(SRC)

# A
stores = [n.lineno for n in ast.walk(tree)
          if isinstance(n, ast.Name) and n.id == "RESULTS"
          and isinstance(n.ctx, ast.Store)]
loads = [n.lineno for n in ast.walk(tree)
         if isinstance(n, ast.Name) and n.id == "RESULTS"
         and isinstance(n.ctx, ast.Load)]
print("A:", stores, loads)

# B
ns = {}
exec(compile(SRC, "m.py", "exec"), ns)
print("B:", ns["RESULTS"])

# C
print("C:", ns["emit"]([]), ns["emit"]([]))

# D
funcs = [n for n in tree.body if isinstance(n, ast.FunctionDef)]
print("D:", [(f.name, sum(1 for n in ast.walk(f)
                          if isinstance(n, ast.Return) and n.value))
             for f in funcs])

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: [1] [9]
B: []
C: ['header', 'header', 'header'] ['header', 'header', 'header']
D: [('collect', 0), ('emit', 1)]

A — assigned on line 1, read on line 9, never appended to. Section 6's measurement in four lines, on a nine-line program.

B — and so it is still empty after the program has run. collect built its rows in a local named rows and dropped them, which is the specimen's bug exactly.

C — the mutable default, and note it is worse than Section 7 showed: the module-level emit(RESULTS) call already appended once, so by the time these two calls run the list has grown to three, and both printed values are the same object. A reader seeing two identical lists might conclude the function is deterministic; it is the opposite.

D — collect returns nothing, emit returns something. The only function a test could assert on is the one that does not do the work.

13. Exercises

Exercise 1 — your own specimen. Find the script on your team that nobody wants to touch, and copy it somewhere safe to work on.

Exercise 2 — working is not evidence. Write down, in one sentence, what months of plausible output actually prove about it.

Exercise 3 — code versus team. Sort Section 1's six signs for your script into the ones about the code and the ones about the team.

Exercise 4 — the five defects. Check your specimen against the annotation's list and mark which of the five it has.

Exercise 5 — measure it. Run Section 3's measure function on your script and record all fifteen numbers.

Exercise 6 — adjectives into counts. Rewrite one complaint about it as a number.

Exercise 7 — jobs per function. For its longest function, list the distinct jobs it owns.

Exercise 8 — nesting depth. Find the deepest nesting and say what it would take to test the innermost branch.

Exercise 9 — no arguments. Count its functions that take no arguments, and say what they read instead.

Exercise 10 — occurrences versus facts. Count both the distinct absolute paths and their total occurrences, and explain why the two numbers differ.

Exercise 11 — the username. Find a path containing a personal directory and say who can and cannot run the script.

Exercise 12 — a pinned version. Find a tool path with a version in it and describe the failure when the site upgrades.

Exercise 13 — a dead global. Walk its AST for a module-level name that is read and never written.

Exercise 14 — trace the consequence. If you find one, work out exactly which outputs are empty.

Exercise 15 — printed versus structured. For your script, say which output is maintained and which is merely present.

Exercise 16 — the returning function. Explain why a function that returned its rows could not have Section 6's bug.

Exercise 17 — a mutable default. Search for =[] or ={} in a signature and call that function twice.

Exercise 18 — dormant today. Say what change would wake it up.

Exercise 19 — the pass rate. Find a percentage your script prints and check how many digits it shows.

Exercise 20 — the empty list. Work out what it does when a filter leaves nothing to run.

Exercise 21 — machine-readable outputs. List what another script could consume from yours, and whether any of it is correct.

Exercise 22 — the exit code. Count its sys.exit calls, and say what a CI job wrapping it concludes.

Exercise 23 — a fossil import. Look for an import that is never used, and guess what it was for.

Exercise 24 — import it. Try importing your script in a Python shell and record what happens.

Exercise 25 — the guard. Add the three lines and confirm the import goes quiet while the functions stay reachable.

Exercise 26 — why no tests. Explain in two sentences why "no tests" was not a discipline problem.

Exercise 27 — the testable surface. Run Section 10's measurement and record how many of your functions a test could call.

Exercise 28 — pure logic in an impure place. Name one genuinely pure computation that is trapped inside a function that touches the world.

Exercise 29 — the arrangement, not the logic. Say which of your defects are about the logic being wrong and which are about where it sits.

Exercise 30 — the road out. Decide which of the six stages your script needs, and in what order.

Exercise 31 — the skippable one. Say whether your script needs a class at all, and why.

14. Summary

  • "It works" is evidence that the output looks like yesterday's, not that the script is correct — and Section 6 is what that costs.
  • Measure instead of describing. ast turns "messy" into fifteen counts, and a count is not arguable.
  • The specimen is 166 lines: 3 functions of 65, 48 and 21 lines, 37 branches, 16 path occurrences across 7 distinct paths, 11 globals, 21 print calls, 0 sys.exit, no __name__ guard.
  • The annotation's five defects are all present, plus a mutable default, a bare except, six unguarded open calls, and two shell=True invocations.
  • What makes a function unchangeable is jobs, not lines. run_everything owns ten, so the severity rule cannot be changed without a licence and a design.
  • Two of three functions take no arguments, so they cannot be called with different inputs — they read globals instead.
  • Seven machine facts, sixteen edits. Distinct paths say how much is baked in; occurrences say what it costs to change, and the gap is the absence of composition.
  • RESULTS is assigned [], read twice, and never appended to. Twelve simulations run, zero rows recorded: a header-only CSV, an empty HTML table, no worst failure, no baseline comparison — and exit 0.
  • Nobody noticed because the printed summary is correct. The tallies live in locals and are printed; the structured outputs nobody reads are the empty ones.
  • A function that returned its rows could not have had that bug — the caller would have had nothing to pass on.
  • A default argument is evaluated once, at def time, so out=[] is shared across calls; four calls produce four header lines in one file.
  • 100 * passed / total is a float and prints 33.333333333333336%; an empty test list raises ZeroDivisionError after the simulations have run.
  • Every machine-readable output is empty or absent and every correct one goes to a terminal, which is why the script works in use and is unusable in automation.
  • sys is imported and never used — the fossil of an exit code somebody meant to add.
  • Importing the script runs the regression. The guard costs three lines and is the entire difference between a script and something another file can import, so "no tests" was a structural impossibility, not a lapse.
  • 0 of 3 functions are callable from a test — none returns a value, all touch the world — against the 62% pure fraction Chapter 33.2 measured on a real runner.
  • The logic is fine; the arrangement is the defect. That is what the next six chapters change.

The next chapter does the first and most consequential of those changes: making the file something another file can import.

Where this fits

Part of the Python curriculum.