Skip to content
VLSI Mentor

Python Part II · Module 30 · Engineering Report Generation

CSV and Machine-Readable Summaries

Chapters 30.1 to 30.3 built a report for a person. This one is for a program, and the requirements invert: a human reader tolerates a shifted column and infers what a blank cell means, while a consumer script does neither — it compares a seed against an integer, indexes a column by position, and silently produces nothing when either assumption fails. Every defect in this chapter is a file that opens correctly in a spreadsheet and gives a wrong answer to the script that was written to read it.

Intermediate19 min readPythonPart IIAutomationReportingCSV

Part II · Module 30 · Chapter 30.4 · CSV and Machine-Readable Summaries

1. A Different Reader, Different Rules

The report of Chapter 30.2 and the file in this chapter describe the same night and share almost no requirements:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CONTRASTS = [
    ("unit of a row", "a bucket", "a job"),
    ("size", "one screen", "as long as the regression"),
    ("aggregation", "essential", "forbidden — the consumer aggregates"),
    ("column order", "whatever reads well", "fixed, forever"),
    ("a blank cell", "obvious from context", "ambiguous and must be defined"),
    ("run identity", "the page title", "a column in every row"),
]

print(f"{'concern':<16} {'human report':<26} machine summary")
for concern, human, machine in CONTRASTS:
    print(f"{concern:<16} {human:<26} {machine}")

print()
print("requirements shared by both:", 0)
print("so they are two files, not one file with two renderings.")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
concern          human report               machine summary
unit of a row    a bucket                   a job
size             one screen                 as long as the regression
aggregation      essential                  forbidden — the consumer aggregates
column order     whatever reads well        fixed, forever
a blank cell     obvious from context       ambiguous and must be defined
run identity     the page title             a column in every row

requirements shared by both: 0
so they are two files, not one file with two renderings.

Nothing is shared, and the useful consequence is that trying to serve both from one artefact produces a file that is too long to read and too aggregated to query. Chapter 30.2's budget and this chapter's one-row-per-job rule are directly contradictory, and both are right.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
write_csv(run_id, results, out_dir / "regression.csv")

Three arguments, and the first one is the whole point of the chapter:

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


def write_csv(run_id, results, path):
    """The signature the annotation specifies."""
    return {"run_id": run_id, "rows": len(results), "path": str(path)}


info = write_csv("2026-02-18_nightly_r3",
                 [{"test": "axi_burst", "seed": 7731, "status": "FAIL"}],
                 Path("out") / "regression.csv")

for k, v in info.items():
    print(f"  {k:<8} {v}")

print()
print("run_id is a PARAMETER, which means it was meant to reach the file.")
print("the question §6 asks:", "did it reach the rows, or only the path?")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  run_id   2026-02-18_nightly_r3
  rows     1
  path     out/regression.csv

run_id is a PARAMETER, which means it was meant to reach the file.
the question §6 asks: did it reach the rows, or only the path?

The signature is right. Sections 3 to 7 are about the body.

3. ",".join(...) Corrupts a Real Record

The first instinct for writing a CSV is string concatenation, and a regression record is exactly the shape that breaks it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

RECORD = {
    "test": "axi_burst",
    "seed": 7731,
    "status": "FAIL",
    "first": "UVM_ERROR drv.sv(88), bad handshake\nretrying",
}
FIELDS = ["test", "seed", "status", "first"]

naive = ",".join(str(RECORD[f]) for f in FIELDS)
print("naive join:")
print(repr(naive))
print("  physical lines:", len(naive.split("\n")))
print("  commas in the line:", naive.count(","))
print("  columns a reader will see on line 1:", len(naive.split("\n")[0].split(",")))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
naive join:
'axi_burst,7731,FAIL,UVM_ERROR drv.sv(88), bad handshake\nretrying'
  physical lines: 2
  commas in the line: 4
  columns a reader will see on line 1: 5

One record became two lines with five columns on the first, from a four-column schema. A consumer reading line by line and splitting on commas gets one malformed row and one orphan.

The csv module gets it right, and the way it gets it right is worth seeing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

RECORD = {
    "test": "axi_burst",
    "seed": 7731,
    "status": "FAIL",
    "first": "UVM_ERROR drv.sv(88), bad handshake\nretrying",
}
FIELDS = ["test", "seed", "status", "first"]

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=FIELDS)
w.writeheader()
w.writerow(RECORD)
text = buf.getvalue()

print("what was written:")
print(repr(text))
print()
print("physical lines :", len(text.rstrip("\r\n").split("\n")))

rows = list(csv.DictReader(io.StringIO(text)))
print("rows read back :", len(rows))
print("field intact   :", rows[0]["first"] == RECORD["first"])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what was written:
'test,seed,status,first\r\naxi_burst,7731,FAIL,"UVM_ERROR drv.sv(88), bad handshake\nretrying"\r\n'

physical lines : 3
rows read back : 1
field intact   : True

Three physical lines, one row, the field byte-identical. The comma is handled by quoting the field and the newline is simply allowed to sit inside the quotes — which is legal CSV and which no line-oriented reader can parse.

That last point is a constraint on consumers, not a bug: anything reading this file must use a CSV parser, because for line in f is wrong on a file that may contain a quoted newline. It is also the reason Chapter 30.5's Markdown table must collapse newlines instead of quoting them — a Markdown row has no quoting mechanism at all.

4. Everything Reads Back as a String

The round-trip above preserved the text perfectly, and that is the trap:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=["test", "seed", "status", "errors"])
w.writeheader()
w.writerow({"test": "axi_burst", "seed": 7731, "status": "FAIL", "errors": 3})

row = list(csv.DictReader(io.StringIO(buf.getvalue())))[0]

print("written          : seed =", repr(7731), " errors =", repr(3))
print("read back        : seed =", repr(row["seed"]), " errors =", repr(row["errors"]))
print()
print("row['seed'] == 7731      :", row["seed"] == 7731)
print("row['errors'] > 0        :", row["errors"] > "0")
print("int(row['seed']) == 7731 :", int(row["seed"]) == 7731)
print("int(row['errors']) > 0   :", int(row["errors"]) > 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
written          : seed = 7731  errors = 3
read back        : seed = '7731'  errors = '3'

row['seed'] == 7731      : False
row['errors'] > 0        : True
int(row['seed']) == 7731 : True
int(row['errors']) > 0   : True

row["seed"] == 7731 is False. A consumer filtering for a particular seed finds nothing, reports nothing, and looks like it worked.

The errors comparison is the more insidious one. row["errors"] > "0" is a string comparison that happens to give the right answer here and will not in general — "10" > "0" is True but "10" > "9" is False, so a filter for "more than 9 errors" silently excludes every row with 10 or more. The fix is to convert at the boundary, once, with a declared schema:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

SCHEMA = {"test": str, "seed": int, "status": str, "errors": int, "runtime_s": float}

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(SCHEMA))
w.writeheader()
w.writerow({"test": "axi_burst", "seed": 7731, "status": "FAIL",
            "errors": 10, "runtime_s": 142.5})
w.writerow({"test": "axi_wrap", "seed": 1190, "status": "FAIL",
            "errors": 9, "runtime_s": 88.25})


def load(text, schema):
    out = []
    for raw in csv.DictReader(io.StringIO(text)):
        out.append({k: fn(raw[k]) for k, fn in schema.items()})
    return out


rows = load(buf.getvalue(), SCHEMA)
for r in rows:
    print(f"  {r['test']:<10} seed={r['seed']!r:<6} errors={r['errors']!r:<4} "
          f"runtime={r['runtime_s']!r}")

print()
print("string comparison '10' > '9' :", "10" > "9")
print("typed comparison  10  >  9   :", 10 > 9)
print("rows with more than 9 errors, typed:",
      [r["test"] for r in rows if r["errors"] > 9])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  axi_burst  seed=7731   errors=10   runtime=142.5
  axi_wrap   seed=1190   errors=9    runtime=88.25

string comparison '10' > '9' : False
typed comparison  10  >  9   : True
rows with more than 9 errors, typed: ['axi_burst']

"10" > "9" is False, so the untyped filter would have dropped the row with the most errors. One dict comprehension at the read boundary removes the whole class, and the schema doubles as the fieldnames list — which is Section 5.

5. Columns Drift Unless You Name Them

A row built from a dict's values inherits that dict's insertion order, and two records assembled by different code paths do not share one:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
r1 = {"test": "axi_burst", "seed": 7731, "status": "PASS"}
r2 = {"status": "FAIL", "test": "axi_wrap", "seed": 1190}

print("rendered from .values():")
for r in (r1, r2):
    print("  " + ",".join(str(v) for v in r.values()))

print()
print("same three fields, same two records, different column order.")
print("errors raised:", 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rendered from .values():
  axi_burst,7731,PASS
  FAIL,axi_wrap,1190

same three fields, same two records, different column order.
errors raised: 0

A consumer indexing fields[2] for the status reads PASS from the first row and 1190 from the second. Explicit fieldnames makes the order a property of the file rather than of whichever dict arrived:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

FIELDS = ["test", "seed", "status"]
RECORDS = [
    {"test": "axi_burst", "seed": 7731, "status": "PASS"},
    {"status": "FAIL", "test": "axi_wrap", "seed": 1190},
]

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=FIELDS)
w.writeheader()
for r in RECORDS:
    w.writerow(r)

print(buf.getvalue().replace("\r\n", "\n"), end="")
print()
print("column order is now the file's, not the record's.")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
test,seed,status
axi_burst,7731,PASS
axi_wrap,1190,FAIL

column order is now the file's, not the record's.

Now the two cases that DictWriter makes decisions about, and which must be decided deliberately:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

FIELDS = ["test", "seed", "status"]

print("a record with an EXTRA key:")
try:
    w = csv.DictWriter(io.StringIO(), fieldnames=FIELDS, extrasaction="raise")
    w.writerow({"test": "a", "seed": 1, "status": "PASS", "wave": "a.fsdb"})
except ValueError as exc:
    print("  extrasaction='raise' ->", exc)

sb = io.StringIO()
w = csv.DictWriter(sb, fieldnames=FIELDS, extrasaction="ignore")
w.writerow({"test": "a", "seed": 1, "status": "PASS", "wave": "a.fsdb"})
print("  extrasaction='ignore' ->", repr(sb.getvalue()))

print()
print("a record MISSING a key:")
sb2 = io.StringIO()
w2 = csv.DictWriter(sb2, fieldnames=FIELDS, restval="")
w2.writerow({"test": "a", "seed": 1})
print("  restval='' ->", repr(sb2.getvalue()))

sb3 = io.StringIO()
w3 = csv.DictWriter(sb3, fieldnames=FIELDS, restval="UNKNOWN")
w3.writerow({"test": "a", "seed": 1})
print("  restval='UNKNOWN' ->", repr(sb3.getvalue()))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a record with an EXTRA key:
  extrasaction='raise' -> dict contains fields not in fieldnames: 'wave'
  extrasaction='ignore' -> 'a,1,PASS\r\n'

a record MISSING a key:
  restval='' -> 'a,1,\r\n'
  restval='UNKNOWN' -> 'a,1,UNKNOWN\r\n'

extrasaction="raise" is the right default for a report generator. The day somebody adds a wave field to the result record, the writer stops rather than silently dropping a column that a consumer may already be looking for — which is Chapter 29.2's refuse-the-ungoverned-metric argument applied to a schema.

And restval makes the missing-versus-empty distinction into a decision. With restval="" a job whose status was never determined is indistinguishable from one whose status is the empty string; with "UNKNOWN" it is a value a consumer can filter on. Chapter 30.1 Section 6's None-is-not-empty rule, in a file format with no None.

6. The Run Identity Has to Be Inside the File

Here is why the annotation passes run_id as a parameter:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

ROWS = [
    {"test": "axi_burst", "seed": 7731, "status": "FAIL"},
    {"test": "axi_wrap", "seed": 1190, "status": "PASS"},
]
FIELDS = ["test", "seed", "status"]


def render(rows):
    buf = io.StringIO()
    w = csv.DictWriter(buf, fieldnames=FIELDS)
    w.writeheader()
    for r in rows:
        w.writerow(r)
    return buf.getvalue()


monday = render(ROWS)
friday = render(ROWS)

print("monday path : runs/2026-02-16_nightly/regression.csv")
print("friday path : runs/2026-02-20_nightly/regression.csv")
print()
print("file contents identical:", monday == friday)
print("bytes                  :", len(monday.encode()))
print()
print("after somebody attaches one to a ticket, the filename is:",
      "regression.csv")
print("and the run it came from is:", "unrecoverable")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
monday path : runs/2026-02-16_nightly/regression.csv
friday path : runs/2026-02-20_nightly/regression.csv

file contents identical: True
bytes                  : 59

after somebody attaches one to a ticket, the filename is: regression.csv
and the run it came from is: unrecoverable

Two byte-identical files from runs four days apart. The identity lived in the directory name, and a directory name survives exactly until somebody copies, downloads, emails or attaches the file — which is the entire purpose of a machine-readable summary.

Putting the identity in every row costs one column and makes the file self-describing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

FIELDS = ["run_id", "started_utc", "tool", "test", "seed", "status"]
RUN = {"run_id": "2026-02-18_nightly_r3",
       "started_utc": "2026-02-18T22:05:00Z",
       "tool": "simtool-1.2.3"}
ROWS = [
    {"test": "axi_burst", "seed": 7731, "status": "FAIL"},
    {"test": "axi_wrap", "seed": 1190, "status": "PASS"},
]

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=FIELDS, extrasaction="raise")
w.writeheader()
for r in ROWS:
    w.writerow({**RUN, **r})

text = buf.getvalue()
print(text.replace("\r\n", "\n"), end="")

back = list(csv.DictReader(io.StringIO(text)))
print()
print("rows            :", len(back))
print("distinct run_ids:", sorted({r["run_id"] for r in back}))
print("file identifies its own run:", len({r["run_id"] for r in back}) == 1)
print("two runs concatenated would show:", "2 distinct run_ids")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
run_id,started_utc,tool,test,seed,status
2026-02-18_nightly_r3,2026-02-18T22:05:00Z,simtool-1.2.3,axi_burst,7731,FAIL
2026-02-18_nightly_r3,2026-02-18T22:05:00Z,simtool-1.2.3,axi_wrap,1190,PASS

rows            : 2
distinct run_ids: ['2026-02-18_nightly_r3']
file identifies its own run: True
two runs concatenated would show: 2 distinct run_ids

Repeating the run identity on every row looks wasteful and is the reason this format is useful: two of these files concatenate into a valid dataset, and the consumer can still tell the runs apart. A header-block format with the identity written once cannot be concatenated, cannot be loaded by a plain CSV reader, and cannot answer a question that spans two nights.

The tool column earns its place for the reason Chapter 29.1 Section 8 gave: when a number looks wrong, the first question is which version produced it.

7. A List Cannot Cross a Cell

Chapter 21.1 Section 5 tested each field of a result record against the question "does it survive the format?", and one field failed:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

RECORD = {
    "test": "axi_burst",
    "seed": 7731,
    "errors": ["UVM_ERROR drv.sv(88): bad handshake",
               "UVM_ERROR drv.sv(91): bad handshake"],
}

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=["test", "seed", "errors"])
w.writeheader()
w.writerow(RECORD)
text = buf.getvalue()
back = list(csv.DictReader(io.StringIO(text)))[0]

print("written errors type:", type(RECORD["errors"]).__name__)
print("read back type     :", type(back["errors"]).__name__)
print("read back value    :", repr(back["errors"])[:60])
print()
print("is it a list?       ", isinstance(back["errors"], list))
print("round trip intact?  ", back["errors"] == RECORD["errors"])
print("usable by a consumer:", False)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
written errors type: list
read back type     : str
read back value    : "['UVM_ERROR drv.sv(88): bad handshake', 'UVM_ERROR drv.sv(9

is it a list?        False
round trip intact?   False
usable by a consumer: False

A list written to a CSV cell becomes its Python repr, which round-trips as a string that happens to look like a list and is not one. A consumer either gets it wrong or reaches for ast.literal_eval, which makes the file's schema "Python source" rather than CSV.

The answer is the one Chapter 21.1 reached: store what a cell can hold, and point at the rest.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

RECORD = {
    "test": "axi_burst",
    "seed": 7731,
    "errors": ["UVM_ERROR drv.sv(88): bad handshake",
               "UVM_ERROR drv.sv(91): bad handshake"],
    "log": "runs/axi_burst_7731/sim.log",
}
FIELDS = ["test", "seed", "error_count", "first_error", "log"]

flat = {
    "test": RECORD["test"],
    "seed": RECORD["seed"],
    "error_count": len(RECORD["errors"]),
    "first_error": RECORD["errors"][0] if RECORD["errors"] else "",
    "log": RECORD["log"],
}

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=FIELDS, extrasaction="raise")
w.writeheader()
w.writerow(flat)
back = list(csv.DictReader(io.StringIO(buf.getvalue())))[0]

for k in FIELDS:
    print(f"  {k:<12} {back[k]!r}")

print()
print("errors retrievable in full from:", back["log"])
print("every cell is a scalar:", all(not isinstance(v, (list, dict)) for v in flat.values()))
print("count is queryable, text is readable, nothing was invented.")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  test         'axi_burst'
  seed         '7731'
  error_count  '2'
  first_error  'UVM_ERROR drv.sv(88): bad handshake'
  log          'runs/axi_burst_7731/sim.log'

errors retrievable in full from: runs/axi_burst_7731/sim.log
every cell is a scalar: True
count is queryable, text is readable, nothing was invented.

A count a consumer can filter on, one error a human can read, and a path to all of them. This is Chapter 28.1 Section 12's rule for the third time in Part II: extract what a program compares, point at what a person reads.

8. The Writer, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional

SCHEMA = {
    "run_id": str, "started_utc": str, "tool": str,
    "test": str, "seed": int, "status": str,
    "error_count": int, "first_error": str,
    "runtime_s": float, "log": str, "run_dir": str,
}
FIELDS = list(SCHEMA)


@dataclass(frozen=True)
class JobResult:
    test: str
    seed: int
    status: str
    errors: List[str]
    runtime_s: float
    log: str
    run_dir: str


def write_csv(run_id, run_meta, results, path):
    """One row per job, fixed columns, run identity on every row."""
    buf = io.StringIO()
    w = csv.DictWriter(buf, fieldnames=FIELDS, extrasaction="raise",
                       restval="UNKNOWN")
    w.writeheader()
    for r in results:
        w.writerow({
            "run_id": run_id,
            "started_utc": run_meta["started_utc"],
            "tool": run_meta["tool"],
            "test": r.test, "seed": r.seed, "status": r.status,
            "error_count": len(r.errors),
            "first_error": r.errors[0] if r.errors else "",
            "runtime_s": f"{r.runtime_s:.2f}",
            "log": r.log, "run_dir": r.run_dir,
        })
    Path(path).write_text(buf.getvalue(), encoding="utf-8", newline="")
    return len(results)


def load_csv(path, schema):
    text = Path(path).read_text(encoding="utf-8")
    rows = []
    for raw in csv.DictReader(io.StringIO(text)):
        rows.append({k: fn(raw[k]) for k, fn in schema.items()})
    return rows


RESULTS = [
    JobResult("axi_burst", 7731, "FAIL",
              ["UVM_ERROR drv.sv(88), bad handshake\nretrying"],
              142.5, "runs/axi_burst_7731/sim.log", "runs/axi_burst_7731"),
    JobResult("axi_wrap", 1190, "PASS", [], 88.25,
              "runs/axi_wrap_1190/sim.log", "runs/axi_wrap_1190"),
    JobResult("dma_long", 2048, "TIMEOUT", [], 1800.0,
              "runs/dma_long_2048/sim.log", "runs/dma_long_2048"),
]

n = write_csv("2026-02-18_nightly_r3",
              {"started_utc": "2026-02-18T22:05:00Z", "tool": "simtool-1.2.3"},
              RESULTS, "regression.csv")

rows = load_csv("regression.csv", SCHEMA)

print(f"rows written: {n}   rows read: {len(rows)}")
print(f"columns     : {len(FIELDS)}")
print()
print(f"{'test':<10} {'seed':>6} {'status':<8} {'errs':>5} {'runtime':>9}")
for r in rows:
    print(f"{r['test']:<10} {r['seed']:>6} {r['status']:<8} "
          f"{r['error_count']:>5} {r['runtime_s']:>9.2f}")

print()
print("seed is an int        :", all(isinstance(r["seed"], int) for r in rows))
print("runtime is a float    :", all(isinstance(r["runtime_s"], float) for r in rows))
print("embedded newline kept :",
      "\n" in [r["first_error"] for r in rows if r["test"] == "axi_burst"][0])
print("one run_id            :", sorted({r["run_id"] for r in rows}))
print("physical lines in file:",
      len(Path("regression.csv").read_text(encoding="utf-8").rstrip("\r\n").split("\n")))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rows written: 3   rows read: 3
columns     : 11

test         seed status    errs   runtime
axi_burst    7731 FAIL         1    142.50
axi_wrap     1190 PASS         0     88.25
dma_long     2048 TIMEOUT      0   1800.00

seed is an int        : True
runtime is a float    : True
embedded newline kept : True
one run_id            : ['2026-02-18_nightly_r3']
physical lines in file: 5

Three rows across five physical lines, with the embedded newline preserved and every value correctly typed on the way back in. The physical lines: 5 figure is the load-bearing one: it proves the quoted newline is really in the file, and it is why wc -l is the wrong tool for counting jobs in a regression CSV.

Note newline="" on the write. Without it, Python's text layer translates the writer's \r\n again on some platforms and the file gains blank rows — the one platform-dependent detail in this chapter, and the reason the csv documentation specifies it.

9. Row Order Decides Whether Two Files Diff

A machine-readable summary is read by scripts and also by diff, and that second reader is the one nobody designs for.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
RESULTS = [("dma_long", 2048), ("axi_burst", 7731), ("axi_wrap", 1190)]

print("as the scheduler completed them (one possible order):")
for t, s in RESULTS:
    print(f"  {t},{s}")

print()
print("the same run, a different night, same results:")
for t, s in [("axi_wrap", 1190), ("dma_long", 2048), ("axi_burst", 7731)]:
    print(f"  {t},{s}")

print()
print("rows differing:", 3)
print("results differing:", 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
as the scheduler completed them (one possible order):
  dma_long,2048
  axi_burst,7731
  axi_wrap,1190

the same run, a different night, same results:
  axi_wrap,1190
  dma_long,2048
  axi_burst,7731

rows differing: 3
results differing: 0

Every row differs and nothing changed. Writing rows in completion order makes the file a record of the scheduler rather than of the regression, and it destroys the cheapest comparison anybody has: diff last_night.csv tonight.csv.

It is also a determinism violation of exactly the kind Module 27 warned about, arriving in a file instead of in a printed value. Sorting by a key the run owns fixes it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

RESULTS = [("dma_long", 2048, "TIMEOUT"), ("axi_burst", 7731, "FAIL"),
           ("axi_wrap", 1190, "PASS")]


def render(results, sort=True):
    rows = sorted(results, key=lambda r: (r[0], r[1])) if sort else results
    buf = io.StringIO()
    w = csv.DictWriter(buf, fieldnames=["test", "seed", "status"])
    w.writeheader()
    for t, s, st in rows:
        w.writerow({"test": t, "seed": s, "status": st})
    return buf.getvalue()


shuffled = [RESULTS[2], RESULTS[0], RESULTS[1]]

print("unsorted, two orders identical:", render(RESULTS, False) == render(shuffled, False))
print("sorted, two orders identical  :", render(RESULTS, True) == render(shuffled, True))
print()
print(render(RESULTS, True).replace("\r\n", "\n"), end="")
print()
print("sort key:", "(test, seed) — both are the caller's, neither is measured")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
unsorted, two orders identical: False
sorted, two orders identical  : True

test,seed,status
axi_burst,7731,FAIL
axi_wrap,1190,PASS
dma_long,2048,TIMEOUT

sort key: (test, seed) — both are the caller's, neither is measured

The sort key matters as much as the sorting. (test, seed) are values the caller chose before the run started, so they are stable across nights; sorting by runtime or by status would reintroduce exactly the instability the sort was meant to remove, because both are outcomes.

One consequence is worth stating because it looks like a drawback and is not: a sorted file no longer tells you what ran first. That information belongs in a column — a start timestamp — where a consumer can sort by it on demand, rather than in the row order where it silently prevents every other comparison.

10. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=["seed", "errors"])
w.writeheader()
w.writerow({"seed": 7731, "errors": 10})
row = list(csv.DictReader(io.StringIO(buf.getvalue())))[0]

# A
print("A:", row["seed"] == 7731, int(row["seed"]) == 7731)

# B
print("B:", "10" > "9", 10 > 9)

# C
r1 = {"a": 1, "b": 2}
r2 = {"b": 2, "a": 1}
print("C:", list(r1.values()), list(r2.values()))

# D
print("D:", repr(",".join(["x", "a,b"])))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: False True
B: False True
C: [1, 2] [2, 1]
D: 'x,a,b'

A — the comparison a consumer writes first is false. Convert at the boundary.

B — the string comparison that silently drops every row with ten or more errors.

C — two records with the same fields and opposite column orders, from .values().

D — three columns from two fields, with no quoting and no error.

11. Exercises

Exercise 1 — two readers. List the requirements your human report and your machine summary share. Say what that implies.

Exercise 2 — contradictory rules. Explain why Chapter 30.2's line budget and this chapter's one-row-per-job rule cannot both be satisfied by one file.

Exercise 3 — break it with a comma. Put a comma in a first-failure field and run your current writer. Report what the file contains.

Exercise 4 — break it with a newline. Do the same with a newline and count the physical lines.

Exercise 5 — wc -l is wrong. Explain why counting lines does not count jobs, and what does.

Exercise 6 — the free-form column. Name every column in your CSV that can contain arbitrary log text.

Exercise 7 — truncate or quote. Decide for your flow whether the first-failure column is truncated or fully quoted, and say why.

Exercise 8 — the false comparison. Find a consumer script that compares a CSV value against an integer, and run it.

Exercise 9 — the string filter. Construct a "more than 9" filter on untyped values and show which row it drops.

Exercise 10 — a schema at the boundary. Add a type map to one of your loaders and convert every column once.

Exercise 11 — column drift. Build two records in different key orders and render both from .values().

Exercise 12 — index by position. Say what a consumer reading fields[2] gets from each of those two rows.

Exercise 13 — name your fields. Convert one writer to explicit fieldnames and confirm the header matches.

Exercise 14 — raise on extras. Set extrasaction="raise" and add a field to your record. Report the exception.

Exercise 15 — missing versus empty. Choose a restval for your flow and justify it in one sentence.

Exercise 16 — two identical files. Produce byte-identical CSVs from two different runs of your flow.

Exercise 17 — identity in the row. Add run_id to every row and say what it costs in bytes for a 400-job run.

Exercise 18 — concatenate. Join two nights' CSVs and confirm a plain reader can still separate the runs.

Exercise 19 — why not a header block. Explain what a header-block format cannot do that a repeated column can.

Exercise 20 — the tool column. Say what question the tool column answers and when you would need it.

Exercise 21 — a list in a cell. Write a list to a CSV cell, read it back, and report its type.

Exercise 22 — flatten it. Replace one list column with a count, a first value and a path.

Exercise 23 — literal_eval is a schema. Explain why using ast.literal_eval on a column makes the file's format Python rather than CSV.

Exercise 24 — newline="". Remove it from a writer and describe what changes.

12. Summary

  • A human report and a machine summary share no requirements — the bucket is the unit of one and the job is the unit of the other, and both rules are right.
  • ",".join(...) corrupts a real record. One four-column row became two lines with five columns on the first.
  • The csv module quotes the comma and allows the newline inside the quotes: three physical lines, one row, the field byte-identical.
  • A quoted newline is legal CSV, so a consumer must use a CSV parser and wc -l does not count jobs.
  • The column that breaks a CSV is always the error message, because it is the only free-form one and the one consumers most want.
  • Every value reads back as a string. row["seed"] == 7731 is False.
  • "10" > "9" is False, so an untyped "more than 9 errors" filter drops exactly the worst rows. Convert at the boundary with a declared schema.
  • Columns drift unless you name them. Two records with the same fields rendered in opposite orders with no error.
  • extrasaction="raise" is the right default so a new field stops the writer instead of silently vanishing.
  • restval turns missing-versus-empty into a decision in a format that has no None.
  • Two byte-identical regression.csv files came from runs four days apart. A directory name survives until somebody copies the file.
  • Repeat the run identity on every row: it costs one column and it makes two files concatenate into a valid dataset.
  • A list written to a cell becomes its Python repr — store a count, a first value and a path instead.
  • Pass newline="" to the writer, the one platform-dependent detail in the chapter.
  • Row order decides whether two files diff. Completion order made every row differ on a night when nothing changed.
  • Sort by keys the caller chose — (test, seed) — never by an outcome like runtime or status, which reintroduces the instability.
  • Put start time in a column, not in the row order, so a consumer can sort by it without losing every other comparison.

The next chapter renders the same facts for a human to paste into a review, where the escaping problem is the same and the available tools are weaker.

Where this fits

Part of the Python curriculum.