Python Part II · Module 30 · Engineering Report Generation
Self-Contained HTML With Links to Logs and Waves
One file, opened by double-clicking it, with every failure linked to its log, its run directory and its waveform. That is the most useful artefact in this module and the most dangerous to generate, because HTML is the only one of the three formats where a malformed document still displays: a stray angle bracket from a module name silently restructures the page, and a path containing an apostrophe produces a link that looks exactly like a working link and goes somewhere else. Both are verified here by parsing the output rather than by reading it.
Advanced21 min readPythonPart IIAutomationReportingHTML
Part II · Module 30 · Chapter 30.6 · Self-Contained HTML With Links to Logs and Waves
1. What This Format Buys
Chapter 30.5's Markdown reaches more venues. This one does something Markdown cannot:
CAPABILITIES = [
("one clickable link per row", "no", "yes"),
("three links per row, no width cost", "no", "yes"),
("keeps a newline inside a cell", "no", "yes"),
("pastes into a merge request", "yes", "no"),
("readable with no tooling at all", "yes", "no"),
]
print(f"{'capability':<36} {'markdown':<10} html")
for cap, md, h in CAPABILITIES:
print(f"{cap:<36} {md:<10} {h}")
print()
print("capabilities only html has:", sum(1 for c in CAPABILITIES if c[1] == "no"))
print("capabilities only markdown has:", sum(1 for c in CAPABILITIES if c[2] == "no"))
print("so both exist, and neither replaces the other.")capability markdown html
one clickable link per row no yes
three links per row, no width cost no yes
keeps a newline inside a cell no yes
pastes into a merge request yes no
readable with no tooling at all yes no
capabilities only html has: 3
capabilities only markdown has: 2
so both exist, and neither replaces the other.The decisive one is the first. Chapter 30.2 Section 3 noted that "where to look next" costs zero lines; in HTML it costs zero lines and removes the copy-paste step, which is the difference between a reviewer opening three logs and a reviewer opening none.
2. The Annotation
row = f"<tr><td>{r.test}</td><td><a href='{r.log}'>log</a></td></tr>"from dataclasses import dataclass
@dataclass(frozen=True)
class Fail:
test: str
log: str
r = Fail("axi_burst", "runs/axi_burst_7731/sim.log")
row = f"<tr><td>{r.test}</td><td><a href='{r.log}'>log</a></td></tr>"
print(row)
print()
print("length:", len(row))
print("looks correct:", True)<tr><td>axi_burst</td><td><a href='runs/axi_burst_7731/sim.log'>log</a></td></tr>
length: 81
looks correct: TrueOn this data it is correct. The rest of the chapter uses data where it is not, and the important methodological point comes first: the generated string will not be inspected by eye. Reading markup to decide whether it is correct is how both of the next two defects survive review, so everything here is checked by parsing it:
from html.parser import HTMLParser
class Audit(HTMLParser):
"""An independent oracle: what a parser sees, not what the author wrote."""
def __init__(self):
super().__init__()
self.tags = []
self.links = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
if tag == "a":
self.links.append(dict(attrs).get("href"))
def audit(markup):
p = Audit()
p.feed(markup)
return {"tags": p.tags, "links": p.links}
row = ("<tr><td>axi_burst</td>"
"<td><a href='runs/axi_burst_7731/sim.log'>log</a></td></tr>")
a = audit(row)
print("tags :", a["tags"])
print("links:", a["links"])
print()
print("tags the author wrote:", ["tr", "td", "td", "a"])
print("tags the parser sees :", a["tags"])
print("identical :", a["tags"] == ["tr", "td", "td", "a"])tags : ['tr', 'td', 'td', 'a']
links: ['runs/axi_burst_7731/sim.log']
tags the author wrote: ['tr', 'td', 'td', 'a']
tags the parser sees : ['tr', 'td', 'td', 'a']
identical : TrueThat is the harness for Sections 3 and 4. It answers "what document did I actually produce", which is a different question from "what string did I build".
3. An Unescaped < Injects an Element
Hardware names and expressions contain angle brackets and ampersands:
from html.parser import HTMLParser
class Audit(HTMLParser):
def __init__(self):
super().__init__()
self.tags = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
def tags(markup):
p = Audit()
p.feed(markup)
return p.tags
EXPECTED = ["tr", "td", "td", "a"]
for cell in ("axi_burst",
"fifo<full> deasserted",
"wr_en & ~full",
"assert (a < b)"):
row = f"<tr><td>{cell}</td><td><a href='x'>log</a></td></tr>"
got = tags(row)
extra = [t for t in got if t not in ("tr", "td", "a")]
print(f"{cell:<24} tags={str(got):<42} unexpected={extra}")axi_burst tags=['tr', 'td', 'td', 'a'] unexpected=[]
fifo<full> deasserted tags=['tr', 'td', 'full', 'td', 'a'] unexpected=['full']
wr_en & ~full tags=['tr', 'td', 'td', 'a'] unexpected=[]
assert (a < b) tags=['tr', 'td', 'td', 'a'] unexpected=[]fifo<full> added a full element to the document. The author wrote four tags and the browser receives five, and from that point the page's structure is whatever the browser's error recovery decides — which is why the symptom is "the table looks odd below row 12" rather than an error.
Note the two cases that did not inject a tag. wr_en & ~full is a bare ampersand, which a lenient HTML parser accepts as text, and assert (a < b) has a < followed by a space, which cannot begin a tag name. Both are still wrong — a bare & is invalid and may be interpreted as the start of an entity — and the lesson is that the absence of an injected tag is not evidence of correctness:
import html
for raw in ("fifo<full> deasserted", "wr_en & ~full", "assert (a < b)",
'say "hi"', "o'brien"):
print(f"{raw:<24} -> {html.escape(raw, quote=True)}")fifo<full> deasserted -> fifo<full> deasserted
wr_en & ~full -> wr_en & ~full
assert (a < b) -> assert (a < b)
say "hi" -> say "hi"
o'brien -> o'brienhtml.escape(..., quote=True) handles all five characters that matter, and the quote=True argument is the one people omit — it is what converts " and ', and Section 4 is entirely about why that matters.
import html
from html.parser import HTMLParser
class Audit(HTMLParser):
def __init__(self):
super().__init__()
self.tags = []
self.text = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
def handle_data(self, data):
self.text.append(data)
CELL = "fifo<full> deasserted"
row = f"<tr><td>{html.escape(CELL, quote=True)}</td><td><a href='x'>log</a></td></tr>"
p = Audit()
p.feed(row)
print("tags :", p.tags)
print("unexpected :", [t for t in p.tags if t not in ("tr", "td", "a")])
print("cell text :", repr(p.text[0]))
print("round trip :", p.text[0] == CELL)tags : ['tr', 'td', 'td', 'a']
unexpected : []
cell text : 'fifo<full> deasserted'
round trip : TrueFour tags, no injection, and the parser hands back the original string — the entity is decoded on the way out, so escaping is lossless. That is the property that makes it the right fix rather than a workaround: Chapter 30.5's Markdown had to destroy information to be safe, and HTML does not.
4. A Single-Quoted href Breaks on an Apostrophe
The annotation quotes its attribute with ', and run directories are named after things:
from html.parser import HTMLParser
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.hrefs = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.hrefs.append(dict(attrs).get("href"))
def hrefs(markup):
p = Links()
p.feed(markup)
return p.hrefs
for path in ("runs/axi_burst_7731/sim.log",
"runs/o'brien_regress/sim.log",
"runs/wr&rd_mix/sim.log"):
markup = f"<a href='{path}'>log</a>"
got = hrefs(markup)
print(f"path {path}")
print(f" parsed {got[0]!r}")
print(f" intact {got[0] == path}")path runs/axi_burst_7731/sim.log
parsed 'runs/axi_burst_7731/sim.log'
intact True
path runs/o'brien_regress/sim.log
parsed 'runs/o'
intact False
path runs/wr&rd_mix/sim.log
parsed 'runs/wr&rd_mix/sim.log'
intact TrueThe link points at runs/o. It is still an anchor, it still renders as a clickable link, it still says "log" — and it goes nowhere. A missing link announces itself; this one does not, and a reviewer concludes the log was not kept.
The fix is both halves together — double quotes and escaping:
import html
from html.parser import HTMLParser
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.hrefs = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.hrefs.append(dict(attrs).get("href"))
def hrefs(markup):
p = Links()
p.feed(markup)
return p.hrefs
PATHS = ["runs/axi_burst_7731/sim.log", "runs/o'brien_regress/sim.log",
'runs/say"hi"/sim.log', "runs/wr&rd_mix/sim.log"]
print(f"{'variant':<34} {'all intact':>11}")
variants = {
"single-quoted, unescaped": lambda p: f"<a href='{p}'>log</a>",
"double-quoted, unescaped": lambda p: f'<a href="{p}">log</a>',
"double-quoted, escaped": lambda p: f'<a href="{html.escape(p, quote=True)}">log</a>',
}
for name, make in variants.items():
ok = all(hrefs(make(p))[0] == p for p in PATHS)
print(f"{name:<34} {str(ok):>11}")
print()
for p in PATHS:
got = hrefs(variants["double-quoted, escaped"](p))[0]
print(f" {p:<30} -> {got == p}")variant all intact
single-quoted, unescaped False
double-quoted, unescaped False
double-quoted, escaped True
runs/axi_burst_7731/sim.log -> True
runs/o'brien_regress/sim.log -> True
runs/say"hi"/sim.log -> True
runs/wr&rd_mix/sim.log -> TrueOnly the third variant survives all four paths. Double-quoting alone is not enough, because a path containing a double quote breaks it in exactly the same way — and html.escape alone is not enough either if the attribute is single-quoted and quote=True is forgotten.
5. A Relative Link Dies When the Report Is Copied
"No server" means the file is opened from the filesystem, and that makes link resolution depend on where the file currently is:
import os
REPORT = "runs/2026-02-18_nightly/report.html"
LOG = "runs/2026-02-18_nightly/axi_burst_7731/sim.log"
rel = os.path.relpath(LOG, os.path.dirname(REPORT))
print("report:", REPORT)
print("log :", LOG)
print("relative href:", rel)
print()
for location in ("runs/2026-02-18_nightly", "/home/me/Downloads", "/tmp/attach"):
resolved = os.path.normpath(os.path.join(location, rel))
print(f"opened from {location:<26} -> {resolved}")
print()
print("the relative link is correct in exactly one location.")report: runs/2026-02-18_nightly/report.html
log : runs/2026-02-18_nightly/axi_burst_7731/sim.log
relative href: axi_burst_7731/sim.log
opened from runs/2026-02-18_nightly -> runs/2026-02-18_nightly/axi_burst_7731/sim.log
opened from /home/me/Downloads -> /home/me/Downloads/axi_burst_7731/sim.log
opened from /tmp/attach -> /tmp/attach/axi_burst_7731/sim.log
the relative link is correct in exactly one location.A relative link is correct in one directory, and a report that nobody copies is a report nobody needed. Downloading it, attaching it to a ticket, or saving it to look at tomorrow all break every link in the table.
The options are few and each has a cost worth stating rather than discovering:
OPTIONS = [
("relative", "works where generated", "every link dies on a copy"),
("absolute filesystem path", "survives a copy", "needs the same mount; file:// may need a prefix"),
("absolute http URL", "survives anything", "needs a server — breaks 'no server'"),
("embed the log in the page", "survives anything", "page size; 30.2's budget"),
]
print(f"{'option':<26} {'advantage':<24} cost")
for name, adv, cost in OPTIONS:
print(f"{name:<26} {adv:<24} {cost}")
print()
print("options that satisfy 'no server' and survive a copy:",
[o[0] for o in OPTIONS if "server" not in o[2] and "copy" not in o[2]])option advantage cost
relative works where generated every link dies on a copy
absolute filesystem path survives a copy needs the same mount; file:// may need a prefix
absolute http URL survives anything needs a server — breaks 'no server'
embed the log in the page survives anything page size; 30.2's budget
options that satisfy 'no server' and survive a copy: ['absolute filesystem path', 'embed the log in the page']Two options survive a copy without a server, and on an engineering network with a shared filesystem the absolute path is the right default: every reviewer has the same mount, so the link works from a download directory, an email attachment or a ticket.
It has one honest caveat that should be in the report rather than in folklore. A browser opening a local file resolves a bare absolute path against the file:// scheme, and some browsers require the scheme written out. Generating file:///proj/... is more reliable and less readable; generating /proj/... is readable and depends on the browser. Either is defensible — writing the choice down in the page itself is what is not optional, because the first reviewer whose link fails needs to know which convention they are looking at.
6. "No Dependency", Checked
The canonical purpose says no dependency, and that is checkable:
from html.parser import HTMLParser
class External(HTMLParser):
def __init__(self):
super().__init__()
self.refs = []
def handle_starttag(self, tag, attrs):
d = dict(attrs)
for key in ("src", "href"):
v = d.get(key)
if v is None:
continue
if v.startswith(("http://", "https://", "//")) or v.endswith((".css", ".js")):
self.refs.append((tag, key, v))
def external_refs(page):
p = External()
p.feed(page)
return p.refs
GOOD = ("<style>body{font-family:sans-serif}td{padding:4px 8px}</style>"
"<table><tr><td>axi_burst</td></tr></table>")
BAD = ('<link rel="stylesheet" href="https://cdn.example/table.css">'
'<script src="sortable.js"></script>'
"<table><tr><td>axi_burst</td></tr></table>")
for label, page in (("inline style only", GOOD), ("cdn css + local js", BAD)):
refs = external_refs(page)
print(f"{label:<20} external refs: {refs or 'none'}")
print()
print("self-contained:", not external_refs(GOOD))
print("a 'local' .js is still a dependency:", any(r[2] == "sortable.js" for r in external_refs(BAD)))inline style only external refs: none
cdn css + local js external refs: [('link', 'href', 'https://cdn.example/table.css'), ('script', 'src', 'sortable.js')]
self-contained: True
a 'local' .js is still a dependency: TrueThe second finding is the one that catches people. A sortable.js sitting next to the report works on the machine that generated it and is gone the moment the HTML file travels alone — exactly Section 5's problem in a different guise. Self-contained means one file, and the check is eight lines.
7. What HTML Can Keep That the Others Cannot
Chapter 30.5 had to collapse a multi-line error message because a Markdown row ends at a newline. HTML's whitespace is not structural:
import html
from html.parser import HTMLParser
MSG = ("UVM_ERROR drv.sv(88): bad handshake\n"
" while in state BURST\n"
" awlen=3 beat=3")
class Text(HTMLParser):
def __init__(self):
super().__init__()
self.data = []
def handle_data(self, d):
self.data.append(d)
cell = f"<td><pre>{html.escape(MSG, quote=True)}</pre></td>"
p = Text()
p.feed(cell)
recovered = "".join(p.data)
print("original lines :", len(MSG.split("\n")))
print("recovered lines:", len(recovered.split("\n")))
print("byte-identical :", recovered == MSG)
print()
print("markdown would have produced:", repr(" ".join(MSG.split()))[:58] + "…")
print("html preserves the layout the log had.")original lines : 3
recovered lines: 3
byte-identical : True
markdown would have produced: 'UVM_ERROR drv.sv(88): bad handshake while in state BURST …
html preserves the layout the log had.Three lines in, three lines out, byte-identical. A <pre> element keeps the message exactly as the log wrote it, which matters for an error whose indentation carries meaning — a stack trace, a UVM topology dump, a column-aligned table.
And the three links the purpose asks for cost nothing in width:
import html
def link(href, text):
return f'<a href="{html.escape(href, quote=True)}">{html.escape(text)}</a>'
row = ("<tr><td>axi_burst</td><td>7731</td><td>"
+ link("/proj/runs/axi_burst_7731/sim.log", "log") + " "
+ link("/proj/runs/axi_burst_7731", "dir") + " "
+ link("/proj/runs/axi_burst_7731/wave.fsdb", "wave")
+ "</td></tr>")
print(row)
print()
print("links in the row :", row.count("<a "))
print("visible characters:", len("axi_burst 7731 log dir wave"))
print("markup characters :", len(row))
print("a markdown row with three paths would be:",
len("| axi_burst | 7731 | /proj/runs/axi_burst_7731/sim.log | "
"/proj/runs/axi_burst_7731 | /proj/runs/axi_burst_7731/wave.fsdb |"),
"characters wide")<tr><td>axi_burst</td><td>7731</td><td><a href="/proj/runs/axi_burst_7731/sim.log">log</a> <a href="/proj/runs/axi_burst_7731">dir</a> <a href="/proj/runs/axi_burst_7731/wave.fsdb">wave</a></td></tr>
links in the row : 3
visible characters: 27
markup characters : 199
a markdown row with three paths would be: 122 characters wideTwenty-seven visible characters carrying three paths. The markup is long and the rendered row is short, which is the inversion that makes HTML the right format for this job: Markdown's width problem from Chapter 30.5 Section 6 does not exist here, because the path lives in an attribute rather than in the visible text.
8. The Page, Assembled
import html
from dataclasses import dataclass
from html.parser import HTMLParser
from typing import List
STYLE = ("body{font-family:sans-serif;margin:2rem}"
"table{border-collapse:collapse}"
"td,th{border:1px solid #ccc;padding:4px 8px;text-align:left}"
"pre{margin:0;font-size:12px}")
@dataclass(frozen=True)
class Bucket:
name: str
count: int
message: str
log: str
run_dir: str
wave: str
def esc(v):
return html.escape(str(v), quote=True)
def link(href, text):
return f'<a href="{esc(href)}">{esc(text)}</a>'
def render(run_id, counts, buckets: List[Bucket], submitted, accounted):
rows = []
for b in buckets:
rows.append(
"<tr>"
f"<td>{esc(b.name)}</td>"
f"<td>{b.count}</td>"
f"<td><pre>{esc(b.message)}</pre></td>"
"<td>" + " ".join([link(b.log, "log"), link(b.run_dir, "dir"),
link(b.wave, "wave")]) + "</td>"
"</tr>")
missing = submitted - accounted
return (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<title>regression {esc(run_id)}</title><style>{STYLE}</style></head><body>"
f"<h1>regression {esc(run_id)}</h1>"
f"<p>{submitted} jobs submitted, {accounted} accounted"
+ (f", <strong>{missing} MISSING</strong>" if missing else "") + "</p>"
"<p>" + " ".join(f"{esc(k)} {v}" for k, v in counts.items()) + "</p>"
"<p><em>links are absolute filesystem paths</em></p>"
"<table><tr><th>bucket</th><th>n</th><th>first failure</th><th>links</th></tr>"
+ "".join(rows) +
"</table></body></html>")
class Audit(HTMLParser):
def __init__(self):
super().__init__()
self.tags = []
self.hrefs = []
self.external = []
self.pre_text = []
self._in_pre = False
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
d = dict(attrs)
if tag == "a":
self.hrefs.append(d.get("href"))
for key in ("src", "href"):
v = d.get(key)
if v and (v.startswith(("http://", "https://", "//"))
or v.endswith((".css", ".js"))):
self.external.append(v)
if tag == "pre":
self._in_pre = True
def handle_endtag(self, tag):
if tag == "pre":
self._in_pre = False
def handle_data(self, data):
if self._in_pre:
self.pre_text.append(data)
BUCKETS = [
Bucket("handshake", 3,
"UVM_ERROR drv.sv(88): fifo<full> & wr_en\n while in state BURST",
"/proj/runs/o'brien_regress/axi_burst_7731/sim.log",
"/proj/runs/o'brien_regress/axi_burst_7731",
"/proj/runs/o'brien_regress/axi_burst_7731/wave.fsdb"),
Bucket("timeout", 2, "no TEST DONE banner after 1800 s",
"/proj/runs/dma_chain_9004/sim.log",
"/proj/runs/dma_chain_9004",
"/proj/runs/dma_chain_9004/wave.fsdb"),
]
page = render("2026-02-18_nightly_r3",
{"PASS": 360, "FAIL": 9, "TIMEOUT": 4, "NO_RUN": 6, "INFRA": 1},
BUCKETS, 400, 380)
a = Audit()
a.feed(page)
expected_tags = {"html", "head", "meta", "title", "style", "body", "h1", "p",
"em", "strong", "table", "tr", "th", "td", "pre", "a"}
unexpected = sorted({t for t in a.tags if t not in expected_tags})
print("page bytes :", len(page))
print("unexpected tags :", unexpected or "none")
print("external refs :", a.external or "none")
print("links :", len(a.hrefs))
print("links per bucket :", len(a.hrefs) // len(BUCKETS))
print()
print("every href byte-intact:")
for b in BUCKETS:
for want in (b.log, b.run_dir, b.wave):
print(f" {want in a.hrefs} {want}")
print()
msg = "".join(a.pre_text)
print("first message recovered with its newline:", "\n" in msg)
print("first message byte-intact :", BUCKETS[0].message in msg)page bytes : 1194
unexpected tags : none
external refs : none
links : 6
links per bucket : 3
every href byte-intact:
True /proj/runs/o'brien_regress/axi_burst_7731/sim.log
True /proj/runs/o'brien_regress/axi_burst_7731
True /proj/runs/o'brien_regress/axi_burst_7731/wave.fsdb
True /proj/runs/dma_chain_9004/sim.log
True /proj/runs/dma_chain_9004
True /proj/runs/dma_chain_9004/wave.fsdb
first message recovered with its newline: True
first message byte-intact : TrueOne file, no external references, six links across two buckets, and every path byte-intact despite containing an apostrophe — which is the case that broke the annotation in Section 4. The message containing fifo<full> & wr_en injected no element and came back with its newline, which is Section 3 and Section 7 together.
Every one of those checks is performed by the parser rather than by reading the markup, and that is the chapter's method as much as its content. Chapter 30.7 generalises it: a report generator needs tests, and the tests have to be about the artefact rather than about the code that produced it.
9. Embedding Costs Bytes; Linking Costs Context
Section 5's options table listed "embed the log in the page" with the cost "page size" and left it there. It is worth measuring, because the answer is not what the phrase suggests.
LOG_BYTES = 2_400_000 # a UVM log with full verbosity
FAILURES = 9
TAIL_LINES = 40
BYTES_PER_LINE = 95
options = [
("link only", 0),
("embed the last 40 lines of each failing log", FAILURES * TAIL_LINES * BYTES_PER_LINE),
("embed every failing log in full", FAILURES * LOG_BYTES),
]
print(f"{'option':<44} {'added bytes':>14} {'page size':>12}")
BASE = 3_000
for name, added in options:
total = BASE + added
print(f"{name:<44} {added:>14,} {total:>12,}")
print()
print("ratio, full embed vs link only:", f"{(BASE + options[2][1]) // (BASE + options[0][1])}x")
print("a 40-line tail per failure costs:", f"{options[1][1] / 1024:.0f} KiB")option added bytes page size
link only 0 3,000
embed the last 40 lines of each failing log 34,200 37,200
embed every failing log in full 21,600,000 21,603,000
ratio, full embed vs link only: 7201x
a 40-line tail per failure costs: 33 KiBFull embedding gives a 21 MB page, which is not a report — it is an archive that happens to open in a browser, and it will be slow to load and impossible to attach anywhere.
The middle row is the one to notice: a forty-line tail per failing log costs 34 KB, which is nothing, and it changes what the page is for.
TAIL = """UVM_ERROR axi_drv.sv(188) @ 141900: WLAST on beat 3 of 4
UVM_INFO axi_seq.sv(88) @ 142000: starting burst 1
UVM_ERROR axi_drv.sv(188) @ 158200: WLAST on beat 2 of 4
UVM_FATAL tb.sv(90) @ 158300: too many errors
--- UVM Report Summary ---
UVM_ERROR : 2
UVM_FATAL : 1"""
print("what the embedded tail answers without leaving the page:")
QUESTIONS = [
("is this the same error twice?", "yes — same line, two times"),
("did the run reach its end banner?", "yes — a report summary is present"),
("how many errors in total?", "2, from the tool's own tally"),
]
for q, a in QUESTIONS:
print(f" {q:<36} {a}")
print()
print("tail lines embedded:", len(TAIL.splitlines()))
print("questions needing the full log:", 0)
print("but the link is still there for the ones that do.")what the embedded tail answers without leaving the page:
is this the same error twice? yes — same line, two times
did the run reach its end banner? yes — a report summary is present
how many errors in total? 2, from the tool's own tally
tail lines embedded: 7
questions needing the full log: 0
but the link is still there for the ones that do.Three questions answered from forty lines, and all three are the ones a reviewer asks before deciding whether the failure is worth an hour. The log tail is also the part that survives Chapter 30.6 Section 5's copy problem: embedded content cannot break when the file moves, so a report with tails is still useful after it has been attached to a ticket and the run directory has been swept.
That gives a defensible default: embed a bounded tail, link the full log, and state the bound in the page so nobody mistakes the tail for the whole thing. The bound is a number somebody chose, so it belongs beside the content for the same reason Chapter 29.2 Section 7's thresholds carry their reasons.
10. Predict, Run, Observe
import html
from html.parser import HTMLParser
class T(HTMLParser):
def __init__(self):
super().__init__()
self.tags = []
self.href = None
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
if tag == "a":
self.href = dict(attrs).get("href")
def parse(m):
p = T()
p.feed(m)
return p
# A
print("A:", parse("<td>fifo<full></td>").tags)
# B
print("B:", parse("<td>" + html.escape("fifo<full>") + "</td>").tags)
# C
print("C:", parse("<a href='runs/o'brien/x.log'>l</a>").href)
# D
print("D:", parse('<a href="' + html.escape("runs/o'brien/x.log", quote=True) + '">l</a>').href)The verified output:
A: ['td', 'full']
B: ['td']
C: runs/o
D: runs/o'brien/x.logA — a full element the author never wrote.
B — escaping removes it, and the cell text survives unchanged.
C — the link points at runs/o, and it still renders as a link.
D — double-quoted and escaped, the path comes back byte-intact.
11. Exercises
Exercise 1 — what each format buys. List the capabilities your flow actually needs and say which format supplies them.
Exercise 2 — zero copy-paste. Time a reviewer opening three logs from a Markdown table and from an HTML one.
Exercise 3 — parse, do not read. Write the Audit parser and run it over your current generator's output.
Exercise 4 — an angle bracket from your design. Find a module, signal or message in your project containing < or &.
Exercise 5 — the injected element. Feed it to your generator unescaped and report the tag list.
Exercise 6 — absence is not correctness. Explain why wr_en & ~full injected no tag and is still wrong.
Exercise 7 — escape is lossless. Confirm that an escaped cell's text round-trips byte-identically.
Exercise 8 — quote=True. Remove it and find a value for which the output changes.
Exercise 9 — an apostrophe in a path. Construct one and show what your generator produces.
Exercise 10 — the link that renders. Explain in two sentences why a silently wrong link is worse than a missing one.
Exercise 11 — all three variants. Test single-quoted, double-quoted and double-quoted-escaped against four awkward paths.
Exercise 12 — rank the defects. Order this module's failure modes by how long each survives in production, and justify the order.
Exercise 13 — copy the report. Move one of your HTML reports to another directory and click a link.
Exercise 14 — one correct directory. Say how many locations a relative link is correct in, and why that is not enough.
Exercise 15 — price the options. For your environment, pick one of the four link strategies and write down its cost.
Exercise 16 — the file:// caveat. Determine what your reviewers' browsers do with a bare absolute path.
Exercise 17 — write the convention down. Add the one-line note naming your link convention to your page.
Exercise 18 — external refs. Run the dependency check over a report you already generate.
Exercise 19 — the local .js. Explain why a script file beside the report is still a dependency.
Exercise 20 — keep the newline. Put a multi-line message in a <pre> cell and confirm the layout survives.
Exercise 21 — indentation that means something. Find a message in your logs whose indentation carries information.
Exercise 22 — width inversion. Compare the rendered width and the markup width of a three-link row.
Exercise 23 — audit the whole page. Add unexpected-tag, external-ref and href-fidelity checks to your generator's self-test.
Exercise 24 — test the artefact. Explain why these checks belong to the output and not to the generator's functions.
12. Summary
- HTML buys three things Markdown cannot — a clickable link per row, three links at no width cost, and a newline inside a cell.
- Never decide markup is correct by reading it. Both of this chapter's defects survive review that way; parse the output instead.
- An unescaped
<injects an element.fifo<full>turned four tags into five, and from there the page structure is the browser's error recovery. - The absence of an injected tag is not correctness — a bare
&and a<followed by a space both pass a lenient parser and are still invalid. html.escape(..., quote=True)is lossless: the parser hands back the original string, so unlike Markdown nothing has to be destroyed to be safe.- A single-quoted
hrefbreaks on an apostrophe.runs/o'brien/sim.logbecame a link toruns/othat still renders as a link. - Double-quote and escape. Only that combination survived all four awkward paths.
- A silently wrong link is the module's worst defect, because the symptom points at the archiving script rather than the generator.
- A relative link is correct in exactly one directory, and a report nobody copies is a report nobody needed.
- Absolute filesystem paths are the right default on a shared mount — and the
file://convention must be written into the page, not left to folklore. - "No dependency" is checkable in eight lines, and a
sortable.jsbeside the report is still a dependency. - A
<pre>cell preserves a message byte-identically, newlines and indentation included. - Three paths cost twenty-seven visible characters, because the path lives in an attribute — Chapter 30.5's width problem does not exist here.
- Every check in the assembled generator is performed on the artefact, not on the functions that built it.
- Embedding every log gives a 21 MB page; a bounded 40-line tail per failure costs 34 KB and answers the three questions asked before opening anything.
- Embedded content cannot break when the file moves, so a report with tails survives the copy that kills every link.
- State the tail bound in the page, because it is a number somebody chose.
The last chapter of the module makes that principle the subject: how to test a report, and why a failing run must never be able to render as a clean summary.
Where this fits
Part of the Python curriculum.
