Python Part II · Module 30 · Engineering Report Generation
Markdown Summaries for Reviews and Merge Requests
A regression summary is most useful where the discussion already is — in a merge request, a ticket, a chat message — and that means plain text with no browser, no server and no attachment. Markdown is the right format for it and the weakest one in this module: where Chapter 30.4's CSV writer could quote a difficult field and carry a newline inside it, a Markdown cell has exactly one escape and no way to hold a line break at all. Every field that broke the CSV breaks this harder, and the table still renders — just with the wrong number of columns.
Intermediate18 min readPythonPart IIAutomationReportingMarkdown
Part II · Module 30 · Chapter 30.5 · Markdown Summaries for Reviews and Merge Requests
1. Why Plain Text Wins Here
The format is chosen by where the reader already is:
VENUES = [
("merge request comment", "plain text", "yes"),
("ticket description", "plain text", "yes"),
("chat message", "plain text", "yes"),
("wiki page", "markdown", "yes"),
("dashboard", "html + a server", "no"),
]
print(f"{'venue':<24} {'accepts':<16} works with paste only")
for venue, fmt, paste in VENUES:
print(f"{venue:<24} {fmt:<16} {paste}")
print()
print("venues reachable by pasting text:", sum(1 for v in VENUES if v[2] == "yes"))
print("venues needing infrastructure :", sum(1 for v in VENUES if v[2] == "no"))venue accepts works with paste only
merge request comment plain text yes
ticket description plain text yes
chat message plain text yes
wiki page markdown yes
dashboard html + a server no
venues reachable by pasting text: 4
venues needing infrastructure : 1Four of five venues need nothing but text, which is the whole argument for this chapter existing alongside Chapter 30.6's HTML. A summary that requires a server reaches one place; a summary that is text reaches the other four, and Chapter 30.2's fourteen-line report was sized for exactly this.
2. The Annotation
rows = [f"| {r.test} | {r.seed} | {r.status} |" for r in fails]from dataclasses import dataclass
@dataclass(frozen=True)
class Fail:
test: str
seed: int
status: str
fails = [Fail("axi_burst", 7731, "FAIL"), Fail("dma_long", 2048, "TIMEOUT")]
rows = [f"| {r.test} | {r.seed} | {r.status} |" for r in fails]
for row in rows:
print(row)
print()
print("rows produced:", len(rows))
print("lines that make this a table:", 0)| axi_burst | 7731 | FAIL |
| dma_long | 2048 | TIMEOUT |
rows produced: 2
lines that make this a table: 0The comprehension is fine and the output is not a table. A GitHub-flavoured Markdown table needs a header row and a separator row, and without the separator a renderer treats both lines as ordinary paragraph text with literal pipe characters in it:
HEADER = "| test | seed | status |"
SEP = "|---|---|---|"
ROWS = ["| axi_burst | 7731 | FAIL |", "| dma_long | 2048 | TIMEOUT |"]
def is_table(lines):
"""A GFM table needs a header followed by a separator of dashes."""
if len(lines) < 2:
return False
sep = lines[1].strip()
cells = [c.strip() for c in sep.strip("|").split("|")]
return bool(cells) and all(set(c) <= set("-: ") and "-" in c for c in cells)
print("annotation's output:", is_table(ROWS))
print("with header only :", is_table([HEADER] + ROWS))
print("with separator :", is_table([HEADER, SEP] + ROWS))
print()
print("the separator row is not decoration; it is the declaration.")annotation's output: False
with header only : False
with separator : True
the separator row is not decoration; it is the declaration.Two lines of boilerplate turn a list of strings into a table, and omitting them produces output that looks correct in a terminal and renders as a wall of pipes in the place it was written for. This is the only defect in the module that is invisible until the artefact reaches its destination — which is why Chapter 30.7's correctness checks include rendering properties and not just arithmetic.
3. A Pipe in a Field Adds a Column
Markdown's cell delimiter is a character that appears in log output:
import re
CELL = re.compile(r"(?<!\\)\|")
def cells(row):
"""Split a row into cells, honouring backslash escapes."""
return CELL.split(row.strip())[1:-1]
MSG = "UVM_ERROR drv.sv(88): bad handshake | retrying"
row = f"| axi_burst | 7731 | {MSG} |"
print("row:", row)
print("cells:", len(cells(row)))
for i, c in enumerate(cells(row)):
print(f" {i}: {c.strip()!r}")
print()
print("columns declared by the header:", 3)
print("columns this row supplies :", len(cells(row)))row: | axi_burst | 7731 | UVM_ERROR drv.sv(88): bad handshake | retrying |
cells: 4
0: 'axi_burst'
1: '7731'
2: 'UVM_ERROR drv.sv(88): bad handshake'
3: 'retrying'
columns declared by the header: 3
columns this row supplies : 4Four cells from a three-column schema, and nothing raised. In a rendered table the extra cell is either dropped or pushes the row out of alignment, depending on the renderer — so the symptom varies by venue, which makes it hard to recognise.
Markdown offers exactly one escape, and it is enough for this:
import re
CELL = re.compile(r"(?<!\\)\|")
def cells(row):
return CELL.split(row.strip())[1:-1]
def md_cell(value):
"""Everything a Markdown cell cannot contain, removed or escaped."""
text = str(value)
text = text.replace("\\", "\\\\")
text = text.replace("|", "\\|")
text = " ".join(text.split()) # collapse every run of whitespace
return text
MSG = "UVM_ERROR drv.sv(88): bad handshake | retrying"
row = f"| {md_cell('axi_burst')} | {md_cell(7731)} | {md_cell(MSG)} |"
print("row:", row)
print("cells:", len(cells(row)))
print("third cell:", cells(row)[2].strip())
print()
print("pipe survives as text:", "\\|" in row)
print("columns now match the header:", len(cells(row)) == 3)row: | axi_burst | 7731 | UVM_ERROR drv.sv(88): bad handshake \| retrying |
cells: 3
third cell: UVM_ERROR drv.sv(88): bad handshake \| retrying
pipe survives as text: True
columns now match the header: TrueNote the backslash is escaped first. Escaping the pipe first and the backslash second would turn \| into \\|, which renders as a literal backslash followed by a cell break — the classic ordering bug in every escaping routine, and the reason the two replace calls cannot be swapped.
4. A Newline Ends the Row
The pipe has an escape. The newline does not:
MSG = "UVM_ERROR drv.sv(88): bad handshake\n while in state BURST"
raw = f"| axi_burst | 7731 | {MSG} |"
print("raw row, repr:")
print(repr(raw))
print("physical lines:", len(raw.split("\n")))
print()
for i, line in enumerate(raw.split("\n")):
print(f" line {i}: {line!r}")
print()
print("is line 1 a table row?", line.strip().startswith("|"))raw row, repr:
'| axi_burst | 7731 | UVM_ERROR drv.sv(88): bad handshake\n while in state BURST |'
physical lines: 2
line 0: '| axi_burst | 7731 | UVM_ERROR drv.sv(88): bad handshake'
line 1: ' while in state BURST |'
is line 1 a table row? FalseOne record became two physical lines: a table row missing its closing cell, followed by a fragment that is not a row at all. Chapter 30.4 Section 3's CSV writer handled this by putting the newline inside quotes; Markdown has no quotes, so the only available fix is to make the cell single-line by construction:
MSG = "UVM_ERROR drv.sv(88): bad handshake\n while in state BURST"
def md_cell(value):
text = str(value).replace("\\", "\\\\").replace("|", "\\|")
return " ".join(text.split())
row = f"| axi_burst | 7731 | {md_cell(MSG)} |"
print("row:", row)
print("physical lines:", len(row.split("\n")))
print()
print("original characters:", len(MSG))
print("after collapsing :", len(md_cell(MSG)))
print("information lost :", "the line break and the leading spaces")
print("information kept :", "every word")row: | axi_burst | 7731 | UVM_ERROR drv.sv(88): bad handshake while in state BURST |
physical lines: 1
original characters: 58
after collapsing : 56
information lost : the line break and the leading spaces
information kept : every word" ".join(text.split()) collapses every run of whitespace — newlines, tabs and runs of spaces — into single spaces, which is lossy and is the correct trade here. The lost information is layout; the kept information is the words, and the full text with its original layout is one click away at the log path.
5. An Empty Table and a Broken Generator Look Identical
The annotation iterates fails, and a good night has none:
HEADER = "| test | seed | status |"
SEP = "|---|---|---|"
def render(fails):
return [HEADER, SEP] + [f"| {f} | | |" for f in fails]
for label, fails in (("bad night", ["axi_burst", "dma_long"]), ("clean night", [])):
lines = render(fails)
print(f"{label}:")
for line in lines:
print(" " + line)
print(f" lines: {len(lines)} body rows: {len(lines) - 2}")
print()bad night:
| test | seed | status |
|---|---|---|
| axi_burst | | |
| dma_long | | |
lines: 4 body rows: 2
clean night:
| test | seed | status |
|---|---|---|
lines: 2 body rows: 0A header and a separator with nothing under them. In a merge request that renders as an empty table with column titles, which is indistinguishable from a generator that crashed after emitting its header — and a reader who has been trained by one broken night to distrust the table will ignore the real one.
A clean night deserves a sentence, not an empty table:
def render(fails, submitted, accounted):
out = [f"**{submitted} jobs, {accounted} accounted**"]
if not fails:
out.append("")
out.append("No failures. " +
("All jobs accounted for." if submitted == accounted
else f"{submitted - accounted} jobs produced no result."))
return out
out.append("")
out.append("| test | seed | status |")
out.append("|---|---|---|")
out.extend(f"| {t} | {s} | {st} |" for t, s, st in fails)
return out
print("clean night, nothing missing:")
for line in render([], 400, 400):
print(" " + line)
print()
print("clean night, 20 jobs missing:")
for line in render([], 400, 380):
print(" " + line)
print()
print("failures present:")
for line in render([("axi_burst", 7731, "FAIL")], 400, 400):
print(" " + line)clean night, nothing missing:
**400 jobs, 400 accounted**
No failures. All jobs accounted for.
clean night, 20 jobs missing:
**400 jobs, 380 accounted**
No failures. 20 jobs produced no result.
failures present:
**400 jobs, 400 accounted**
| test | seed | status |
|---|---|---|
| axi_burst | 7731 | FAIL |Three renderings for three genuinely different nights. The middle one is the important one: "No failures" and "20 jobs produced no result" belong in the same sentence, because a night where a fifth of the jobs vanished is not a clean night, and an empty failure table is exactly how it would otherwise be reported. Chapter 30.1 Section 3 computed that number; this is where it has to appear.
6. Width Is a Real Constraint
"No browser and no server" means the output lands in a fixed-width context, and a long cell destroys a table there even when the markup is perfect:
LONG = ("UVM_ERROR /proj/chip/tb/axi/src/axi_master_driver.sv(1884) @ 1248300: "
"axi_vip_seq.burst_check - WLAST asserted on beat 3 of a 4-beat burst "
"while awlen indicated 3 and the write data channel had already "
"accepted the final beat")
row = f"| axi_burst | 7731 | {LONG} |"
print("row width:", len(row))
for limit in (80, 120, 200):
print(f" fits in {limit:>3}: {len(row) <= limit}")
print()
def shorten(text, limit):
text = " ".join(str(text).split())
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
for limit in (60, 90):
s = shorten(LONG, limit)
print(f"limit {limit}: {len(s)} chars")
print(f" {s}")row width: 248
fits in 80: False
fits in 120: False
fits in 200: False
limit 60: 59 chars
UVM_ERROR /proj/chip/tb/axi/src/axi_master_driver.sv(1884)…
limit 90: 90 chars
UVM_ERROR /proj/chip/tb/axi/src/axi_master_driver.sv(1884) @ 1248300: axi_vip_seq.burst_c…A 248-character row in an 80-column context wraps and the table is gone. Truncation with an explicit … is the only option — and note what the 60-character version shows: a full path consumed the whole budget and the error is invisible. The description does not begin until well past that point.
LONG = ("UVM_ERROR /proj/chip/tb/axi/src/axi_master_driver.sv(1884) @ 1248300: "
"axi_vip_seq.burst_check - WLAST asserted on beat 3 of a 4-beat burst")
def tail_of_path(text):
"""Keep the file's basename and drop the directories that precede it."""
words = []
for w in str(text).split():
if "/" in w:
w = w.rsplit("/", 1)[-1]
words.append(w)
return " ".join(words)
def shorten(text, limit):
text = " ".join(str(text).split())
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
print("raw :", shorten(LONG, 70))
print("basenamed:", shorten(tail_of_path(LONG), 70))
print()
print("characters spent on directories, before:", len(LONG) - len(tail_of_path(LONG)))
print("the error text now visible within 70 chars:", "WLAST" in shorten(tail_of_path(LONG), 70))raw : UVM_ERROR /proj/chip/tb/axi/src/axi_master_driver.sv(1884) @ 1248300:…
basenamed: UVM_ERROR axi_master_driver.sv(1884) @ 1248300: axi_vip_seq.burst_che…
characters spent on directories, before: 22
the error text now visible within 70 chars: FalseDropping directory prefixes bought 22 characters, and the last line is the one to read: at a 70-character limit the word WLAST is still not visible. Basenaming helps and does not solve it, because the fixed preamble — severity, file, line, simulation time — consumes the budget before any description starts.
That is worth stating plainly rather than papering over: a one-line cell cannot carry a UVM error message and its location and its timestamp. Something has to be dropped deliberately, and the directory is the right first casualty because it is identical for every row in the table and therefore carries no information at that position. Beyond that the honest choices are a wider cell, a second line, or accepting that the cell locates the failure while the log explains it — which is what the bucket's own name is for.
7. The Summary, Assembled
import re
from dataclasses import dataclass
from typing import List, Optional
CELL = re.compile(r"(?<!\\)\|")
def cells(row):
return CELL.split(row.strip())[1:-1]
def md_cell(value, limit=64):
"""For prose cells: escape, collapse, drop directories, truncate."""
text = str(value).replace("\\", "\\\\").replace("|", "\\|")
text = " ".join(text.split())
words = []
for w in text.split():
words.append(w.rsplit("/", 1)[-1] if "/" in w else w)
text = " ".join(words)
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
def md_path(value):
"""For path cells: escape only. Never basenamed, never truncated."""
return str(value).replace("\\", "\\\\").replace("|", "\\|")
@dataclass(frozen=True)
class Bucket:
name: str
count: int
first_error: str
log: str
def render(submitted, accounted, counts, buckets: List[Bucket],
new: Optional[List[str]], fixed: Optional[List[str]]):
out = [f"### regression {submitted} jobs · {accounted} accounted"
+ ("" if submitted == accounted else f" · **{submitted - accounted} MISSING**")]
out.append("")
out.append(" ".join(f"{k} {v}" for k, v in counts.items()))
out.append("")
if not buckets:
out.append("No failures."
+ ("" if submitted == accounted
else f" {submitted - accounted} jobs produced no result."))
else:
out.append("| bucket | n | first failure | log |")
out.append("|---|---:|---|---|")
for b in buckets:
out.append(f"| {md_cell(b.name)} | {b.count} | "
f"{md_cell(b.first_error)} | `{md_path(b.log)}` |")
out.append("")
if new is None:
out.append("_no previous run: new and fixed are unknown_")
else:
out.append(f"**new** {', '.join(new) if new else 'none'} · "
f"**fixed** {', '.join(fixed) if fixed else 'none'}")
return out
BUCKETS = [
Bucket("handshake", 3,
"UVM_ERROR /proj/chip/tb/axi/src/axi_master_driver.sv(1884): "
"WLAST on beat 3 | awlen 3",
"runs/axi_burst_7731/sim.log"),
Bucket("timeout", 2, "no TEST DONE banner after 1800 s",
"runs/dma_chain_9004/sim.log"),
]
lines = render(400, 380,
{"PASS": 360, "FAIL": 9, "TIMEOUT": 4, "NO_RUN": 6, "INFRA": 1},
BUCKETS, ["axi_burst"], ["old_wrap"])
for line in lines:
print(line)
body = [l for l in lines if l.startswith("| ") and not set(l) <= set("|-: ")]
print()
print("lines :", len(lines))
print("table body rows :", len(body) - 1)
print("every body row has 4 cells:", all(len(cells(r)) == 4 for r in body))
print("widest line :", max(len(l) for l in lines))
print("fits in 100 columns :", max(len(l) for l in lines) <= 100)### regression 400 jobs · 380 accounted · **20 MISSING**
PASS 360 FAIL 9 TIMEOUT 4 NO_RUN 6 INFRA 1
| bucket | n | first failure | log |
|---|---:|---|---|
| handshake | 3 | UVM_ERROR axi_master_driver.sv(1884): WLAST on beat 3 \| awlen 3 | `runs/axi_burst_7731/sim.log` |
| timeout | 2 | no TEST DONE banner after 1800 s | `runs/dma_chain_9004/sim.log` |
**new** axi_burst · **fixed** old_wrap
lines : 10
table body rows : 2
every body row has 4 cells: True
widest line : 116
fits in 100 columns : FalseTen lines, a valid table, and the cell count verified by an independent splitter rather than asserted — which is the only kind of check worth having here, because the generator and the verifier must not share the escaping logic.
Two things in that output are worth reading carefully. The embedded pipe survived as \|, and basenaming the driver path brought that cell under the 64-character limit without truncation — so the original message, which would not have fitted, now renders in full.
And the widest line is 116 characters, which does not fit 100. The log column is the cause, and it is there deliberately: md_path escapes the pipe and does not basename or truncate, because a shortened path is not a path. That is the one place in this chapter where two rules conflict — fit the venue, and keep the path usable — and the resolution is to let the path win and report the resulting width rather than hide it. The honest remedies are a wider venue, the path on its own line, or a shorter prose limit; silently truncating the path is not among them.
8. The First Line Is the Whole Message
A pasted summary is often not read. It is previewed — in a notification, a chat list, an email subject line — and the preview is the first line.
SUMMARY = [
"### regression 2026-02-18_nightly_r3",
"",
"400 jobs submitted, 380 accounted, **20 MISSING**",
"PASS 360 FAIL 9 TIMEOUT 4 NO_RUN 6 INFRA 1",
"",
"| bucket | n | first failure |",
]
for width in (60, 80):
print(f"preview at {width} chars: {SUMMARY[0][:width]!r}")
print()
print("does the first line say whether anything broke?",
any(w in SUMMARY[0] for w in ("FAIL", "MISSING", "failures", "clean")))
print("the reader must open the message to learn anything:", True)preview at 60 chars: '### regression 2026-02-18_nightly_r3'
preview at 80 chars: '### regression 2026-02-18_nightly_r3'
does the first line say whether anything broke? False
the reader must open the message to learn anything: TrueThe heading names the run and says nothing about it. For a reader scanning twenty notifications, that is indistinguishable from every other night.
Put the verdict first and the identity second:
def headline(submitted, accounted, counts, new):
failed = counts.get("FAIL", 0)
missing = submitted - accounted
bits = []
if failed:
bits.append(f"{failed} failed")
if missing:
bits.append(f"{missing} MISSING")
if new:
bits.append(f"{len(new)} new")
if not bits:
return f"CLEAN · {submitted} jobs"
return " · ".join(bits) + f" · {submitted} jobs"
CASES = [
("bad night", 400, 380, {"FAIL": 9}, ["axi_burst", "reg_rw"]),
("clean", 400, 400, {"FAIL": 0}, []),
("quiet but incomplete", 400, 380, {"FAIL": 0}, []),
("failures, nothing new", 400, 400, {"FAIL": 3}, []),
]
for label, sub, acc, counts, new in CASES:
h = headline(sub, acc, counts, new)
print(f"{label:<22} {h:<44} {len(h)} chars")
print()
print("every headline fits a 60-char preview:",
all(len(headline(*c[1:])) <= 60 for c in CASES))bad night 9 failed · 20 MISSING · 2 new · 400 jobs 40 chars
clean CLEAN · 400 jobs 16 chars
quiet but incomplete 20 MISSING · 400 jobs 21 chars
failures, nothing new 3 failed · 400 jobs 19 chars
every headline fits a 60-char preview: TrueFour nights, four headlines, all inside a 60-character preview, and the third one is why this section exists: a night with no failures and twenty missing jobs produces 20 MISSING · 400 jobs, which is not a clean night and does not read like one. Section 5 made the same point inside the body; here it reaches a reader who never opened the message.
Note the headline is composed from the facts rather than written as a template with blanks. A template needs a branch per combination and there are eight; building a list of present conditions and joining it needs none, and handles the all-clear case by the list being empty.
9. Predict, Run, Observe
import re
CELL = re.compile(r"(?<!\\)\|")
# A
print("A:", len(CELL.split("| a | b|c |".strip())[1:-1]))
# B
print("B:", len(CELL.split("| a | b\\|c |".strip())[1:-1]))
# C
print("C:", len(("| a | b\nc |").split("\n")))
# D
print("D:", repr(" ".join("a\n b\tc".split())))The verified output:
A: 3
B: 2
C: 2
D: 'a b c'A — three cells where two were intended, from one unescaped pipe.
B — the escaped pipe keeps the cell count at two.
C — a newline makes two lines out of one row, and there is no escape for it.
D — " ".join(text.split()) collapses newline, tab and runs of spaces in one step.
10. Exercises
Exercise 1 — where your readers are. List the venues your summary is pasted into and which need a server.
Exercise 2 — rows are not a table. Paste the annotation's output into a merge request and record what renders.
Exercise 3 — the separator. Write the is_table check and run it against your current generator's output.
Exercise 4 — invisible until it lands. Explain why this defect survives local testing, in two sentences.
Exercise 5 — a pipe from a log. Find a real error message in your logs containing a pipe.
Exercise 6 — count the cells. Write an independent cell splitter and run it over every row your generator emits.
Exercise 7 — escape order. Swap the two replace calls and show what happens to an already-escaped pipe.
Exercise 8 — no quoting. Say what CSV does with a newline in a field and why Markdown cannot do the same.
Exercise 9 — collapse, and what it costs. Collapse a multi-line error and list what information was lost.
Exercise 10 — the empty table. Render a clean night with your current generator and say whether a reader could tell it from a crash.
Exercise 11 — a sentence, not a table. Write the clean-night sentence for your flow.
Exercise 12 — clean but incomplete. Write the sentence for a night with no failures and twenty missing jobs.
Exercise 13 — trained to distrust. Describe what happens to a team's use of a report after one broken night.
Exercise 14 — measure your width. Compute the widest line your generator emits for a real night.
Exercise 15 — the budget your venue gives you. Find the column width of the narrowest place your summary is pasted.
Exercise 16 — the path ate the message. Truncate a real first-failure line at 60 characters and say whether the error is still visible.
Exercise 17 — basenames. Apply the directory-dropping transform to your messages and report the characters recovered.
Exercise 18 — what the directory tells a reader. Explain why the directory prefix carries no information in a table where every row shares it.
Exercise 19 — one path must stay whole. Say which cell in your table must never be truncated, and why.
Exercise 20 — three escaping functions. Write the CSV, Markdown and HTML cell functions for one field and say why they cannot be shared.
Exercise 21 — independent verification. Explain why the cell-count check must not use the generator's escaping code.
Exercise 22 — compose escape and truncate. Construct a case where truncating first and escaping second gives a broken cell.
Exercise 23 — the width report. Add the widest-line number to your generator's self-check output.
Exercise 24 — paste it cold. Paste your rendered summary into the actual venue and list every difference from your terminal.
11. Summary
- Four of five venues need nothing but text, which is why a pasteable summary reaches more readers than a dashboard.
- The annotation builds rows, not a table. A GFM table needs a header and a separator row, and without them the output renders as a wall of literal pipes.
- That is the only defect in the module invisible until the artefact lands, which is why Chapter 30.7 checks rendering properties as well as arithmetic.
- A pipe in a field adds a column — four cells from a three-column schema, with nothing raised, and a symptom that varies by renderer.
- Escape the backslash before the pipe. Doing it the other way turns
\|into a literal backslash plus a cell break. - A newline ends the row, and Markdown has no quoting, so the only fix is to collapse whitespace and make the cell single-line by construction.
" ".join(text.split())collapses newlines, tabs and runs of spaces in one step — lossy about layout, faithful about words.- Three formats, three escaping functions, forced by the formats: CSV quotes, Markdown escapes and collapses, HTML uses entities. Never one shared
sanitise(). - An empty table and a crashed generator render identically. A clean night deserves a sentence.
- "No failures" and "20 jobs produced no result" belong in the same sentence, because a night that lost a fifth of its jobs is not a clean night.
- Width is a real constraint — a 248-character row destroys a table in an 80-column venue, so truncate with an explicit marker.
- A full path can consume the whole cell budget. Dropping directory prefixes recovered 22 characters and still left the description outside a 70-character cell — a one-line cell cannot carry a message, a location and a timestamp.
- The log path is the one cell that must never be truncated — escape it and leave it alone, and report the resulting width rather than hiding it. The assembled renderer needed a separate
md_pathfor exactly this, because the prose cell function basenames and would have reduced the path tosim.log. - Verify the cell count with an independent splitter, never with the generator's own escaping code.
- The first line is the whole message for a reader scanning notifications; a heading that names the run says nothing about it.
- Put the verdict first and the identity second, and compose it from the facts rather than from a template with eight branches.
20 MISSING · 400 jobsis the headline that matters — a night with no failures and twenty missing jobs must not read like a clean one.
The next chapter takes the same facts to the one venue that needs a file rather than a paste, and finds that the escaping problem has teeth there.
Where this fits
Part of the Python curriculum.
