Skip to content
VLSI Mentor

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:

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
row = f"<tr><td>{r.test}</td><td><a href='{r.log}'>log</a></td></tr>"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<tr><td>axi_burst</td><td><a href='runs/axi_burst_7731/sim.log'>log</a></td></tr>

length: 81
looks correct: True

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fifo<full> deasserted    -> fifo&lt;full&gt; deasserted
wr_en & ~full            -> wr_en &amp; ~full
assert (a < b)           -> assert (a &lt; b)
say "hi"                 -> say &quot;hi&quot;
o'brien                  -> o&#x27;brien

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
tags       : ['tr', 'td', 'td', 'a']
unexpected : []
cell text  : 'fifo<full> deasserted'
round trip : True

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

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

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

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

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

"No server" means the file is opened from the filesystem, and that makes link resolution depend on where the file currently is:

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

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

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

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

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

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

Twenty-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

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

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

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: ['td', 'full']
B: ['td']
C: runs/o
D: runs/o'brien/x.log

A — 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 href breaks on an apostrophe. runs/o'brien/sim.log became a link to runs/o that 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.js beside 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.