Python Part II · Module 29 · Coverage Automation
Parsing Coverage Reports Across Tools
Module 28 parsed reports whose headline was a single signed number. A coverage report is harder in a way that is easy to miss: it carries dozens of numbers, each belonging to a different metric at a different scope, and the one thing every layout agrees on is the per cent sign. That is exactly the character a naive pattern anchors to, and anchoring there produces a parser that succeeds on every line, returns the right percentages, and keys half of them to the wrong name — while silently discarding every group that reached a hundred.
Advanced20 min readPythonPart IIAutomationCoverageReport Parsing
Part II · Module 29 · Chapter 29.1 · Parsing Coverage Reports Across Tools
1. Four Things a Coverage Number Needs
A timing report's headline is one number with one meaning. A coverage number is meaningless until four things are attached to it:
REQUIRED = [
("metric", "statement, branch, toggle, functional — 29.2 separates them"),
("scope", "which instance or covergroup, and at which level of hierarchy"),
("hits / total", "the pair the percentage was rendered from — §6"),
("provenance", "which report file, in which layout, from which tool"),
]
print(f"{'attribute':<14} why it has to travel with the number")
for name, why in REQUIRED:
print(f"{name:<14} {why}")
print()
print("fields that identify a number:", len(REQUIRED))
print("a bare 94.5 answers:", "none of the four")attribute why it has to travel with the number
metric statement, branch, toggle, functional — 29.2 separates them
scope which instance or covergroup, and at which level of hierarchy
hits / total the pair the percentage was rendered from — §6
provenance which report file, in which layout, from which tool
fields that identify a number: 4
a bare 94.5 answers: none of the fourA single 94.5 floating in a dashboard cell is the thing this module exists to prevent. Every later chapter consumes the record, not the number: Chapter 29.2 gates per metric, Chapter 29.3 rolls up per scope, Chapter 29.4 needs the pair, and Chapter 29.6 has to show a reviewer where the figure came from.
2. The Annotation
The canonical annotation for this chapter is a single line:
re.search(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%", line)It is the pattern almost everybody writes first, and it is worth writing first, because it works:
import re
PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
line = " alu_cov 95.50%"
m = PAT.search(line)
print("name:", m.group("name"))
print("pct :", m.group("pct"))
print("as a float:", float(m.group("pct")))name: alu_cov
pct : 95.50
as a float: 95.5Two named groups, one re.search, and the row is parsed. Named groups are Chapter 19.9's device and they are the right choice here — m.group("pct") survives somebody inserting a column in a way m.group(2) does not.
The pattern is also wrong in three separate ways, each of which is silent, and each of which is a different lesson. The next two sections take them in order of how much damage they do.
3. Three Reports, Three Layouts
Here are the three shapes a coverage report arrives in. The first is a score-first summary table:
Total Coverage Summary
SCORE LINE COND TOGGLE FSM ASSERT
88.21 94.50 81.33 61.07 90.00 100.00
Hierarchical coverage for top-level instances
SCORE LINE COND TOGGLE NAME
94.12 98.00 91.10 93.26 tb.dut.alu
71.40 82.30 64.90 67.00 tb.dut.dma
99.80 99.90 99.70 99.80 tb.dut.regfileThe second is a name-first covergroup list with its own counts:
Covergroup Hits Goal Coverage
cg_axi_burst 512 512 100.00%
cg_axi_len 380 512 74.22%
cg_fifo_level 64 64 100%
cg_err_inject 12 40 30.00%The third is a key/value block that gives the numerator and denominator outright:
instance : tb.dut.regfile
statement : 1890 / 2000 ( 94.50 % )
branch : 712 / 800 ( 89.00 % )
toggle : 410 / 672 ( 61.01 % )from pathlib import Path
for name in ("cov_a.rpt", "cov_b.rpt", "cov_c.rpt"):
text = Path(name).read_text(encoding="utf-8")
lines = text.splitlines()
print(f"{name} lines={len(lines):<3} "
f"has_pct_sign={'%' in text} "
f"name_column_first={not lines[2].strip()[0].isdigit() if len(lines) > 2 else None}")cov_a.rpt lines=9 has_pct_sign=False name_column_first=False
cov_b.rpt lines=5 has_pct_sign=True name_column_first=True
cov_c.rpt lines=4 has_pct_sign=True name_column_first=TrueRead the first column of that output again, because it is not what the section was building towards. cov_a.rpt contains no per cent sign at all. A score-first summary table prints bare numbers under a SCORE header, and the unit lives in the column heading rather than on every cell.
So the annotation does not merely mis-key that layout. It finds nothing in it:
import re
from pathlib import Path
PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
Path("cov_a.rpt").write_text(
"Total Coverage Summary\n"
"SCORE LINE COND TOGGLE FSM ASSERT\n"
" 88.21 94.50 81.33 61.07 90.00 100.00\n"
"\n"
"Hierarchical coverage for top-level instances\n"
"SCORE LINE COND TOGGLE NAME\n"
" 94.12 98.00 91.10 93.26 tb.dut.alu\n"
" 71.40 82.30 64.90 67.00 tb.dut.dma\n"
" 99.80 99.90 99.70 99.80 tb.dut.regfile\n", encoding="utf-8")
text = Path("cov_a.rpt").read_text(encoding="utf-8")
hits = list(PAT.finditer(text))
print("lines in the report :", len(text.splitlines()))
print("numbers a human can read :", 24)
print("matches the pattern found:", len(hits))
print()
print("what the caller sees:", "an empty list")
print("what it means :", "indistinguishable from a report with no coverage")lines in the report : 9
numbers a human can read : 24
matches the pattern found: 0
what the caller sees: an empty list
what it means : indistinguishable from a report with no coverageTwenty-four numbers in the file and zero of them extracted — and the caller receives an empty list, which is exactly what it would receive from a report of a design with no coverage at all. An empty parse is the one failure mode that cannot be distinguished from a legitimate result, which is why Section 5's percent-bearing-line count is not optional: a parser that reports "I found 0 numbers in a 9-line report" is debuggable, and one that returns [] is not.
Three layouts, then, and they do not even agree on the per cent sign — the character the annotation anchors to is absent from a third of them. Chapter 28.1 Section 3 made the same observation about timing reports; coverage reports are worse, because the layout varies between metrics within one tool, not just between tools, and the unit may live in a heading rather than beside the value.
4. \S+ Takes the Wrong Column
re.search scans left to right and returns the leftmost position at which the whole pattern matches contiguously. That second word does all the damage:
import re
PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
ROWS = [
" alu_cov 95.50%",
"cg_axi_len 380 512 74.22%",
" TOTAL COVERAGE 95.50%",
" axi_cov 512 480 93.75%",
]
for row in ROWS:
m = PAT.search(row)
got = m.group("name") if m else None
print(f"name={str(got):<10} from {row.strip()!r}")name=alu_cov from 'alu_cov 95.50%'
name=512 from 'cg_axi_len 380 512 74.22%'
name=COVERAGE from 'TOTAL COVERAGE 95.50%'
name=480 from 'axi_cov 512 480 93.75%'Three of four rows are keyed wrongly, and not one of them raised anything.
Trace the second row, because the mechanism is worth owning. re.search tries to match starting at c of cg_axi_len: \S+ greedily takes cg_axi_len, \s+ takes the spaces, and then \d+\.\d+% has to match 380 — it does not, because after 380 comes a space, not a dot. Backtracking inside \S+ cannot help, because every shorter prefix leaves a non-space character where \s+ needs whitespace. So the engine abandons that start position and tries the next, and the next, until it starts at 512: \S+ takes 512, \s+ takes the spaces, 74.22% matches. The pattern found the only place it could match, and that place put the name group on the Goal column.
The third row is the same mechanism with a different cause: \S+ cannot span the space in TOTAL COVERAGE, so the match starts at the second word.
The fix is to say what the line actually looks like rather than what is near the percentage:
import re
ROWS = [
"cg_axi_burst 512 512 100.00%",
"cg_axi_len 380 512 74.22%",
"cg_err_inject 12 40 30.00%",
]
GROUP_ROW = re.compile(
r"^(?P<name>\w+)\s+(?P<hits>\d+)\s+(?P<goal>\d+)\s+(?P<pct>\d+(?:\.\d+)?)%\s*$"
)
for row in ROWS:
m = GROUP_ROW.match(row)
print(f"{m.group('name'):<16} {m.group('hits'):>5} / {m.group('goal'):<5} {m.group('pct')}%")cg_axi_burst 512 / 512 100.00%
cg_axi_len 380 / 512 74.22%
cg_err_inject 12 / 40 30.00%Four differences, and every one of them is load-bearing: ^ and $ anchor the row so a partial match is a failure rather than a silent shift; \w+ for the name cannot wander into a number; the count columns are named and captured instead of skipped, which is Section 6; and re.match with an anchored pattern makes a non-conforming row return None loudly instead of matching somewhere unintended.
5. \d+\.\d+ Drops Every Closed Group
The third defect is the one that does genuine damage, and it is visible only if you look at which rows produced nothing:
import re
PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
ROWS = [
("cg_axi_burst", "cg_axi_burst 512 512 100.00%"),
("cg_fifo_level", "cg_fifo_level 64 64 100%"),
("cg_err_inject", "cg_err_inject 12 40 30.00%"),
("dma_cov", " dma_cov 88.0 %"),
]
parsed, dropped = [], []
for label, row in ROWS:
m = PAT.search(row)
(parsed if m else dropped).append(label)
print("parsed :", parsed)
print("dropped:", dropped)
print()
print("rows presented:", len(ROWS))
print("rows the parser will report on:", len(parsed))parsed : ['cg_axi_burst', 'cg_err_inject']
dropped: ['cg_fifo_level', 'dma_cov']
rows presented: 4
rows the parser will report on: 2cg_fifo_level is at 100% and the pattern cannot see it, because \d+\.\d+ requires a decimal point and the report wrote 100%. dma_cov is lost to a space before the per cent sign.
Now price the consequence, because this is the defect worth remembering:
import re
PAT = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
FIXED = re.compile(r"^(?P<name>\w+)\s+(?P<pct>\d+(?:\.\d+)?)\s*%", re.M)
REPORT = """cg_axi_burst 100%
cg_axi_len 74.22%
cg_fifo_level 100%
cg_err_inject 30.00%
cg_reset 100%
cg_wr_rd 56.80%
"""
naive = [float(m.group("pct")) for m in
(PAT.search(l) for l in REPORT.splitlines()) if m]
fixed = [float(m.group("pct")) for m in FIXED.finditer(REPORT)]
print("groups in the report:", len(REPORT.splitlines()))
print("naive pattern saw :", len(naive), naive)
print("fixed pattern saw :", len(fixed), fixed)
print()
print(f"mean over what the naive pattern saw: {sum(naive)/len(naive):.2f}%")
print(f"mean over every group : {sum(fixed)/len(fixed):.2f}%")groups in the report: 6
naive pattern saw : 3 [74.22, 30.0, 56.8]
fixed pattern saw : 6 [100.0, 74.22, 100.0, 30.0, 100.0, 56.8]
mean over what the naive pattern saw: 53.67%
mean over every group : 76.84%53.67% against a true 76.84%. The pattern discarded exactly the rows that were fully covered, because those are the rows a tool is most likely to print without a decimal, and the reported figure came out 23 points too low.
Think about which direction that error points. A parser that over-reports coverage gets found immediately — somebody asks why a block nobody has written tests for is at 94%. A parser that under-reports coverage sends a team to write tests for groups that are already closed, and the only symptom is engineers reporting that the number will not move. An error that makes you look worse than you are is the one that survives longest in a flow, which is the same asymmetry Chapter 20.7 described for a missed UVM_ERROR, pointing the other way.
The guard is cheap and belongs in every report parser:
import re
FIXED = re.compile(r"^(?P<name>\w+)\s+(?P<pct>\d+(?:\.\d+)?)\s*%", re.M)
REPORT = """cg_axi_burst 100%
cg_axi_len 74.22%
cg_fifo_level 100%
cg_err_inject 30.00%
Covergroup summary follows
cg_reset 100%
"""
candidates = [l for l in REPORT.splitlines() if "%" in l]
matched = [m.group("name") for m in FIXED.finditer(REPORT)]
print("lines containing a per cent sign:", len(candidates))
print("lines the pattern parsed :", len(matched))
print("unparsed but percent-bearing :",
[l.strip() for l in candidates if not FIXED.match(l)])
print()
print("accounted for:", len(matched) == len(candidates))lines containing a per cent sign: 5
lines the pattern parsed : 5
unparsed but percent-bearing : []
accounted for: TrueCount the lines that should have parsed and compare against the ones that did. This is Chapter 20.6's unparsed counter applied to a report instead of a log, and it is the only mechanism that turns a silent drop into a visible number. Note that the Covergroup summary follows line contains no per cent sign and so is correctly not a candidate.
6. A Percentage Is Not the Number You Want
The annotation captures pct and discards the counts beside it. That is the deepest of the three defects, because nothing about it looks like a defect:
RUNS = [
("Monday", 900, 1000),
("Friday", 1056, 1200),
]
for day, hits, total in RUNS:
print(f"{day:<8} {hits:>5} / {total:<5} = {100.0 * hits / total:.2f}%")
mon, fri = RUNS[0], RUNS[1]
pct_mon = 100.0 * mon[1] / mon[2]
pct_fri = 100.0 * fri[1] / fri[2]
print()
print(f"percentage moved: {pct_fri - pct_mon:+.2f} points")
print(f"bins covered : {fri[1] - mon[1]:+d}")
print(f"bins coverable : {fri[2] - mon[2]:+d}")Monday 900 / 1000 = 90.00%
Friday 1056 / 1200 = 88.00%
percentage moved: -2.00 points
bins covered : +156
bins coverable : +200The percentage fell two points in a week during which the team covered 156 more bins than before. Somebody merged a block with 200 new coverable bins, and the denominator moved faster than the numerator.
A parser that kept only pct has thrown away the only evidence that could distinguish those two readings, and no amount of cleverness downstream can recover it. Chapter 29.4 is built entirely on this pair; Chapter 29.2 needs it to gate without being fooled by rounding; Chapter 29.5 needs the denominator to tell a hole from an unreachable bin. Capture hits and total wherever the report prints them, and derive the percentage yourself.
SPEC = [
("cov_a.rpt", "score-first table", False, "percentage only"),
("cov_b.rpt", "covergroup list", True, "Hits and Goal columns"),
("cov_c.rpt", "key/value block", True, "numerator / denominator"),
]
print(f"{'report':<12} {'layout':<20} {'has pair':<9} source")
for name, layout, pair, src in SPEC:
print(f"{name:<12} {layout:<20} {str(pair):<9} {src}")
print()
print("reports that give the pair:", sum(1 for s in SPEC if s[2]), "of", len(SPEC))
print("so the record needs:", "an optional pair, and a percentage that is always present")report layout has pair source
cov_a.rpt score-first table False percentage only
cov_b.rpt covergroup list True Hits and Goal columns
cov_c.rpt key/value block True numerator / denominator
reports that give the pair: 2 of 3
so the record needs: an optional pair, and a percentage that is always presentNot every report gives the pair, so the record carries it as optional — exactly the pattern Chapter 28.5 Section 4 used for an unavailable metric. A number with no denominator is still usable; it just cannot participate in Chapter 29.4's comparison, and the record has to say so rather than inventing a total.
7. Per-Instance and Per-Module Are Different Scopes
cov_a.rpt reports three instances under tb.dut. The same RTL module can appear at several instance paths, and the two scopes answer different questions:
INSTANCES = [
("tb.dut.fifo_tx", "fifo", 61, 80),
("tb.dut.fifo_rx", "fifo", 74, 80),
("tb.dut.alu", "alu", 196, 200),
]
print(f"{'instance':<18} {'module':<8} {'hits':>5} {'total':>6} pct")
for path, module, hits, total in INSTANCES:
print(f"{path:<18} {module:<8} {hits:>5} {total:>6} {100.0*hits/total:.2f}%")
by_module = {}
for _, module, hits, total in INSTANCES:
h, t = by_module.get(module, (0, 0))
by_module[module] = (h + hits, t + total)
print()
print("merged by module:")
for module, (h, t) in sorted(by_module.items()):
print(f" {module:<8} {h:>5} / {t:<5} = {100.0*h/t:.2f}%")instance module hits total pct
tb.dut.fifo_tx fifo 61 80 76.25%
tb.dut.fifo_rx fifo 74 80 92.50%
tb.dut.alu alu 196 200 98.00%
merged by module:
alu 196 / 200 = 98.00%
fifo 135 / 160 = 84.38%Both views are correct and they support different decisions. The per-module number answers "is this RTL exercised anywhere?", which is the right question for code coverage of a reusable block — a line covered in fifo_rx is a line that has been executed, and merging is how tools treat code coverage by default. The per-instance number answers "is this instantiation exercised?", which is the right question when one instance is configured differently: fifo_tx at 76% against fifo_rx at 92% says the transmit path has traffic the receive path does not, and merging them to 84% hides that entirely.
So the record needs scope as two fields, not one, and the merge must never be implicit:
from dataclasses import dataclass
@dataclass(frozen=True)
class Scope:
instance: str
module: str
merged: bool
s1 = Scope("tb.dut.fifo_tx", "fifo", False)
s2 = Scope(instance="", module="fifo", merged=True)
print("per-instance:", s1)
print("per-module :", s2)
print()
print("a merged record has no instance path:", s2.instance == "")
print("and says so explicitly :", s2.merged)per-instance: Scope(instance='tb.dut.fifo_tx', module='fifo', merged=False)
per-module : Scope(instance='', module='fifo', merged=True)
a merged record has no instance path: True
and says so explicitly : TrueA merged flag looks like bookkeeping until somebody compares a merged number against a per-instance one and reports a regression that is purely an artefact of the scope changing. Chapter 28.3 Section 7 made the same demand of synthesis numbers: two figures are comparable only when they were produced the same way.
8. Recording Where Each Number Came From
The canonical purpose for this chapter ends with a requirement that is easy to skip: record which tool and report format each number came from. Here is why it is in the purpose.
from dataclasses import dataclass, asdict
from typing import Optional
@dataclass(frozen=True)
class CoverageNumber:
metric: str
scope: str
pct: float
hits: Optional[int]
total: Optional[int]
layout: str
report: str
n1 = CoverageNumber("functional", "cg_axi_len", 74.22, 380, 512, "covergroup-list", "cov_b.rpt")
n2 = CoverageNumber("toggle", "tb.dut.dma", 67.00, None, None, "score-table", "cov_a.rpt")
for n in (n1, n2):
d = asdict(n)
print(f"{d['metric']:<11} {d['scope']:<12} {d['pct']:>6.2f}% "
f"pair={'yes' if d['hits'] is not None else 'no ':<3} "
f"layout={d['layout']:<16} {d['report']}")
print()
print("can take part in a run-over-run delta:", n1.hits is not None, n2.hits is not None)functional cg_axi_len 74.22% pair=yes layout=covergroup-list cov_b.rpt
toggle tb.dut.dma 67.00% pair=no layout=score-table cov_a.rpt
can take part in a run-over-run delta: True FalseThe layout field is the one people drop, and it is the one that pays for itself. When a number looks wrong, the first question is which parsing path produced it — and with three layouts in one flow, that question is unanswerable from a bare percentage. The report field is Chapter 28.1 Section 12's detail_path under another name: extract what a program compares, point at what a person reads.
9. The Parser, Assembled
Three layouts, one record type, one dispatch:
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
GROUP_ROW = re.compile(
r"^(?P<name>\w+)\s+(?P<hits>\d+)\s+(?P<goal>\d+)\s+(?P<pct>\d+(?:\.\d+)?)%\s*$")
KV_ROW = re.compile(
r"^\s+(?P<metric>\w+)\s*:\s*(?P<hits>\d+)\s*/\s*(?P<total>\d+)\s*\(\s*(?P<pct>\d+(?:\.\d+)?)\s*%\s*\)")
KV_INSTANCE = re.compile(r"^instance\s*:\s*(?P<path>\S+)")
@dataclass(frozen=True)
class CoverageNumber:
metric: str
scope: str
pct: float
hits: Optional[int]
total: Optional[int]
layout: str
report: str
def parse_covergroup_list(path):
out, seen, skipped = [], 0, []
for line in Path(path).read_text(encoding="utf-8").splitlines():
if "%" not in line:
continue
seen += 1
m = GROUP_ROW.match(line)
if not m:
skipped.append(line.strip())
continue
out.append(CoverageNumber("functional", m.group("name"), float(m.group("pct")),
int(m.group("hits")), int(m.group("goal")),
"covergroup-list", str(path)))
return out, seen, skipped
def parse_kv_block(path):
out, instance, seen, skipped = [], "", 0, []
for line in Path(path).read_text(encoding="utf-8").splitlines():
hit_inst = KV_INSTANCE.match(line)
if hit_inst:
instance = hit_inst.group("path")
continue
if "%" not in line:
continue
seen += 1
m = KV_ROW.match(line)
if not m:
skipped.append(line.strip())
continue
out.append(CoverageNumber(m.group("metric"), instance, float(m.group("pct")),
int(m.group("hits")), int(m.group("total")),
"kv-block", str(path)))
return out, seen, skipped
Path("cov_b.rpt").write_text(
"Covergroup Hits Goal Coverage\n"
"cg_axi_burst 512 512 100.00%\n"
"cg_axi_len 380 512 74.22%\n"
"cg_fifo_level 64 64 100%\n"
"cg_err_inject 12 40 30.00%\n", encoding="utf-8")
Path("cov_c.rpt").write_text(
"instance : tb.dut.regfile\n"
" statement : 1890 / 2000 ( 94.50 % )\n"
" branch : 712 / 800 ( 89.00 % )\n"
" toggle : 410 / 672 ( 61.01 % )\n", encoding="utf-8")
numbers = []
for path, parser in (("cov_b.rpt", parse_covergroup_list), ("cov_c.rpt", parse_kv_block)):
got, seen, skipped = parser(path)
numbers.extend(got)
print(f"{path}: percent-bearing lines {seen} | parsed {len(got)} | unparsed {len(skipped)}")
for s in skipped:
print(" ! unparsed:", s)
print()
print(f"{'metric':<11} {'scope':<16} {'pct':>7} hits/total layout")
for n in numbers:
print(f"{n.metric:<11} {n.scope:<16} {n.pct:>6.2f}% {n.hits:>4}/{n.total:<5} {n.layout}")
print()
print("numbers extracted:", len(numbers))
print("every number carries a denominator:", all(n.total for n in numbers))
print("derived percentages agree with the printed ones:",
all(abs(100.0 * n.hits / n.total - n.pct) < 0.02 for n in numbers))cov_b.rpt: percent-bearing lines 4 | parsed 4 | unparsed 0
cov_c.rpt: percent-bearing lines 3 | parsed 3 | unparsed 0
metric scope pct hits/total layout
functional cg_axi_burst 100.00% 512/512 covergroup-list
functional cg_axi_len 74.22% 380/512 covergroup-list
functional cg_fifo_level 100.00% 64/64 covergroup-list
functional cg_err_inject 30.00% 12/40 covergroup-list
statement tb.dut.regfile 94.50% 1890/2000 kv-block
branch tb.dut.regfile 89.00% 712/800 kv-block
toggle tb.dut.regfile 61.01% 410/672 kv-block
numbers extracted: 7
every number carries a denominator: True
derived percentages agree with the printed ones: TrueTwo layouts, seven numbers, zero unparsed percent-bearing lines, and a final check that the arithmetic the report printed agrees with the arithmetic the parser derived. That last line is the cheapest real verification in the module: when a report's printed percentage disagrees with its own counts, something is wrong with either the parse or the report, and you want to know which on the night it happens rather than in a closure meeting.
Note what the dispatch does not do: it never guesses the layout from the content. Each path is parsed by the parser for its format, and the layout field records which one ran. Chapter 29.3 picks these records up and rolls them into a view a meeting can read.
10. Predict, Run, Observe
import re
A = re.compile(r"(?P<name>\S+)\s+(?P<pct>\d+\.\d+)%")
B = re.compile(r"^(?P<name>\w+)\s+(?P<pct>\d+(?:\.\d+)?)%$")
# A
print("A:", A.search("cg_x 40 50 80.00%").group("name"))
# B
print("B:", A.search("cg_y 100%"))
# C
m = B.match("cg_z 100%")
print("C:", m.group("name"), m.group("pct"))
# D
print("D:", bool(B.match(" cg_w 90.0%")))The verified output:
A: 50
B: None
C: cg_z 100
D: FalseA — the name group landed on the Goal column, because that is the only start position from which the whole pattern matches. Section 4.
B — 100% has no decimal point, so the row vanishes. Section 5, and the reason a naive parser under-reports.
C — the optional fraction group parses 100 and the anchored name group keeps cg_z.
D — ^ means ^. The leading spaces make the anchored pattern fail, which is a reminder that anchoring a row pattern means handling the report's indentation deliberately — ^\s* where the report indents, as KV_ROW does in Section 9.
11. Exercises
Exercise 1 — reproduce the column theft. Run the annotation against a row with a count column and print name. Explain in one sentence why the engine chose that start position.
Exercise 2 — the two-word label. Find a row in your own reports whose label contains a space, and say what the annotation returns for it.
Exercise 3 — count what you dropped. Add the percent-bearing-line counter from Section 5 to a parser you already have, and report the number of unparsed lines.
Exercise 4 — which direction. Explain in two sentences why under-reporting coverage survives longer in a flow than over-reporting it.
Exercise 5 — 100% in your reports. Determine whether your tool prints 100 or 100.00, and say how you found out.
Exercise 6 — anchor a row. Rewrite the annotation as an anchored full-row pattern for one layout you own, with named count groups.
Exercise 7 — keep the pair. Take a parser that captures only a percentage and extend it to capture hits and total. Note every report where the pair is unavailable.
Exercise 8 — derive and compare. Add the check that a derived percentage agrees with the printed one, and run it over a real report.
Exercise 9 — the denominator moved. Construct two runs where coverage rises in bins and falls in per cent, and state both numbers.
Exercise 10 — scope. For one reusable block in your design, give the per-instance numbers and the merged number, and say which decision each supports.
Exercise 11 — the merged flag. Explain what goes wrong when a merged number is compared against a per-instance one, with a concrete pair of numbers.
Exercise 12 — provenance. Add layout and report fields to your coverage record and say which question each one answers.
Exercise 13 — three layouts. Write the anchored row pattern for a third layout of your own and add it to the dispatch.
Exercise 14 — a tool upgrade. Describe what you would see in your records on the night a tool changes its report layout, with the layout field present and without it.
Exercise 15 — no denominator. Decide what your record should do when a report gives only a percentage, and say why inventing a total is worse.
Exercise 16 — the empty parse. Run a pattern anchored on % against a report that has no per cent signs, and say what the caller receives.
Exercise 17 — twenty-four numbers, zero matches. Explain in two sentences why an empty result is the hardest failure mode to notice.
Exercise 18 — the unit in the heading. Find a report in your flow where the unit lives in a column heading rather than beside the value.
Exercise 19 — backtracking by hand. Trace (?P<name>\S+)\s+(?P<pct>\d+\.\d+)% through "cg_x 40 50 80.00%" and say why every start position before 50 fails.
Exercise 20 — re.match versus re.search. Replace a search with an anchored match in one of your parsers and report what stopped matching.
Exercise 21 — the indentation. Write the anchored row pattern for a report that indents its rows, and say why bare ^ is not enough.
Exercise 22 — derive and disagree. Construct a report whose printed percentage disagrees with its own counts, and say which you would trust.
Exercise 23 — two scopes, one module. For a block instantiated twice in your design, give both per-instance numbers and the merged one, and name a decision each supports.
Exercise 24 — the merged flag in a comparison. Write the check that refuses to compare a merged number with a per-instance one.
Exercise 25 — layout as a dimension. Group a week of your coverage records by layout and say what a sudden change in the grouping would mean.
12. Summary
- A coverage number needs four things attached: metric, scope, the hits/total pair, and provenance. A bare percentage answers none of them.
re.searchreturns the leftmost position where the whole pattern matches — so a count column between the name and the percentage puts the name group on the count, silently.- A label containing a space loses its first word to
\S+for the same reason. \d+\.\d+requires a decimal point, so a report that prints100%loses every fully-covered group, and the reported figure comes out too low.- An error that under-reports is the one that survives, because nobody investigates a number that makes them look worse.
- Count the percent-bearing lines and compare against what parsed. A silent drop becomes a visible number.
- Anchor the row.
^,$,\w+for names, and named count groups; a partial match should be a failure, not a shift. - A percentage is a rendering of hits/total — capture the pair where the report gives it and derive the percentage yourself.
- The percentage can fall while covered bins rise, when somebody merges new coverable code. Only the pair distinguishes the two readings.
- Per-instance and per-module are different scopes answering different questions; a merge must be recorded, never implicit.
- Record the layout and the report path. The layout field turns a tool upgrade from a mystery coverage movement into a
GROUP BY. - Check the derived percentage against the printed one. It is the cheapest verification in the module.
- A report may contain no per cent sign at all — a score-first table carries the unit in its heading, so a pattern anchored on
%returns an empty list from a nine-line report. - An empty parse is indistinguishable from a legitimate zero-coverage result, which is why the percent-bearing-line count matters more than any single pattern.
re.matchwith an anchored pattern fails loudly;re.searchwith a loose one succeeds in the wrong place.- Anchoring means handling the report's indentation deliberately —
^\s*where rows are indented. - A record carries scope as two fields, instance and module, plus a
mergedflag that is never implicit.
The next chapter takes these records and asks what each metric actually counts — and why one threshold for all of them is the wrong shape of answer.
Where this fits
Part of the Python curriculum.
