Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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   : 1

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rows = [f"| {r.test} | {r.seed} | {r.status} |" for r in fails]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
| axi_burst | 7731 | FAIL |
| dma_long | 2048 | TIMEOUT |

rows produced: 2
lines that make this a table: 0

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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     : 4

Four 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: True

Note 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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("|"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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? False

One 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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()
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bad night:
  | test | seed | status |
  |---|---|---|
  | axi_burst | | |
  | dma_long | | |
  lines: 4  body rows: 2

clean night:
  | test | seed | status |
  |---|---|---|
  lines: 2  body rows: 0

A 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: False

Dropping 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
### 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  : False

Ten 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: True

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: True

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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_path for exactly this, because the prose cell function basenames and would have reduced the path to sim.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 jobs is 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.