Python Part II · Module 19 · Regular Expressions for VLSI
Debugging a Pattern That Lies to You
A pattern that fails to compile tells you so. A pattern that compiles, runs, and returns the wrong thing tells you nothing — and there are only two symptoms available to it, a None or a wrong value, which it uses for at least four unrelated causes. Staring at the regex is the natural response and the least effective one, because three of those four causes are not in the regex at all. This chapter is the procedure that separates them, in the order that eliminates the most causes per command, and the central tool in it takes four lines to write and will find the exact symbol at fault.
Advanced18 min readPythonPart IIRegexDebuggingLog Parsing
Part II · Module 19 · Chapter 19.17 · Debugging a Pattern That Lies to You
1. Four Causes, Two Symptoms
A pattern that is not doing what you want shows you one of exactly two things: None, or a value that is wrong. Behind those two symptoms sit at least four causes:
| Cause | Symptom | Where the fault is |
|---|---|---|
the data is not what you assumed — tabs, trailing space, \r | None | the subject |
| the field was renamed by a newer tool version | None | the subject |
| a flag is missing or wrong | None | the call |
| the pattern is too loose and matched elsewhere | wrong value | the pattern |
Three of the four are not in the regex. That is the single most useful fact in this chapter, because the instinct on a failing pattern is to edit the pattern — and three times out of four that means changing something correct until it accidentally fits something you have not looked at.
2. Step 1 — print(repr(line))
The first command, every time, and the canonical annotation's first line:
print(repr(line)) # tabs? trailing whitespace?A line printed normally and the same line printed with repr are different amounts of information:
LINE = " slack\t(VIOLATED)\t-0.142 "
print("plain:", LINE)
print("repr :", repr(LINE))plain: slack (VIOLATED) -0.142
repr : ' slack\t(VIOLATED)\t-0.142 'The plain print shows a line with gaps in it. The repr shows two tab characters and two trailing spaces, and tells you immediately that a pattern written with literal spaces cannot work:
import re
LINE = " slack\t(VIOLATED)\t-0.142 "
print("literal space:", re.search(r"slack \(VIOLATED\)", LINE))
print("whitespace :", repr(re.search(r"slack\s+\(VIOLATED\)", LINE).group(0)))literal space: None
whitespace : 'slack\t(VIOLATED)'That is the whole of Chapter 19.12 Stage 3's argument, arriving as a bug report instead of as advice. Tabs are invisible in every terminal, every log viewer and every code review, and a tool that aligns one column with spaces and the next with a tab is completely normal.
repr also catches the one that nobody guesses:
LINE = "slack (MET) 0.031\r\n"
stripped_newline = LINE.rstrip("\n")
stripped_all = LINE.rstrip()
print("rstrip('\\n'):", repr(stripped_newline))
print("rstrip() :", repr(stripped_all))rstrip('\n'): 'slack (MET) 0.031\r'
rstrip() : 'slack (MET) 0.031'A log written on one operating system and parsed on another arrives with \r\n line endings, and rstrip("\n") — which is the careful-looking version, because it only removes what you named — leaves the \r attached. That single invisible character breaks anchored patterns:
import re
WITH_CR = "slack (MET) 0.031\r"
CLEAN = "slack (MET) 0.031"
print("with \\r:", re.search(r"(\S+)$", WITH_CR))
print("clean :", repr(re.search(r"(\S+)$", CLEAN).group(1)))with \r: None
clean : '0.031'None, from a pattern that is correct. \r is a whitespace character, so \S+ cannot consume it, and $ tolerates a trailing \n but not a trailing \r — so there is nowhere in that string for the pattern to end. The fix is rstrip() with no argument, which removes all trailing whitespace, and the lesson is that the careful form of a strip is the one that causes this.
3. Step 2 — Which Symptom Do You Have?
The second line of the canonical annotation:
print(m.groups() if m else "NO MATCH")It looks trivial and it is the branch point of the whole procedure:
import re
PATTERN = r"slack\s+\((?:VIOLATED|MET)\)\s+(-?\d+(?:\.\d+)?)"
SUBJECTS = [
" slack (VIOLATED) -0.142",
" slack_ns (VIOLATED) -0.142",
" note: slack margin policy is 0.000 for this corner",
]
for subject in SUBJECTS:
m = re.search(PATTERN, subject)
print(f"{subject.strip()[:38]:<40} {m.groups() if m else 'NO MATCH'}")slack (VIOLATED) -0.142 ('-0.142',)
slack_ns (VIOLATED) -0.142 NO MATCH
note: slack margin policy is 0.000 for NO MATCHTwo NO MATCH results from two completely different causes — a renamed field in the second row, and a line that correctly should not match in the third. The symptom does not identify the cause, which is why Sections 4 and 5 exist as separate branches.
Write the if m else form rather than m.groups() on its own. m.groups() on a failed match is Chapter 19.8's AttributeError, and a traceback in the middle of a debugging session tells you only what you already knew.
4. Step 3 — Bisect the Pattern
This is the tool worth taking away from the chapter. When a pattern returns None and the subject looks right, run progressively longer prefixes of the pattern and find the first one that fails:
import re
STAGES = [
r"slack",
r"slack\s+",
r"slack\s+\(",
r"slack\s+\(VIOLATED\)",
r"slack\s+\(VIOLATED\)\s+",
r"slack\s+\(VIOLATED\)\s+(-?\d+\.\d+)",
]
def bisect(stages, subject):
"""Print the first prefix of a pattern that fails against a subject."""
for stage in stages:
m = re.search(stage, subject)
print(f"{'ok ' if m else 'FAIL'} {stage:<40} {repr(m.group(0)) if m else ''}")
print("subject:", repr(" slack\t(VIOLATED)\t-0.142"))
bisect(STAGES, " slack\t(VIOLATED)\t-0.142")subject: ' slack\t(VIOLATED)\t-0.142'
ok slack 'slack'
ok slack\s+ 'slack\t'
ok slack\s+\( 'slack\t('
ok slack\s+\(VIOLATED\) 'slack\t(VIOLATED)'
ok slack\s+\(VIOLATED\)\s+ 'slack\t(VIOLATED)\t'
ok slack\s+\(VIOLATED\)\s+(-?\d+\.\d+) 'slack\t(VIOLATED)\t-0.142'Six ok rows means the pattern works on that line, and the group(0) column shows exactly what each symbol consumed — 'slack\t' proves \s+ ate a tab, which no amount of reading the pattern would have told you.
Now the same bisect on a line that fails:
import re
STAGES = [
r"slack",
r"slack\s+",
r"slack\s+\(",
r"slack\s+\(VIOLATED\)",
r"slack\s+\(VIOLATED\)\s+",
r"slack\s+\(VIOLATED\)\s+(-?\d+\.\d+)",
]
def bisect(stages, subject):
"""Print the first prefix of a pattern that fails against a subject."""
for stage in stages:
m = re.search(stage, subject)
print(f"{'ok ' if m else 'FAIL'} {stage:<40} {repr(m.group(0)) if m else ''}")
print("subject:", repr(" slack\t(VIOLATED)\t-1"))
bisect(STAGES, " slack\t(VIOLATED)\t-1")subject: ' slack\t(VIOLATED)\t-1'
ok slack 'slack'
ok slack\s+ 'slack\t'
ok slack\s+\( 'slack\t('
ok slack\s+\(VIOLATED\) 'slack\t(VIOLATED)'
ok slack\s+\(VIOLATED\)\s+ 'slack\t(VIOLATED)\t'
FAIL slack\s+\(VIOLATED\)\s+(-?\d+\.\d+)Five ok rows and then one FAIL, and the difference between the last two rows is the symbols (-?\d+\.\d+). The fault is in the number, not in the word, the gap, the brackets or the tag — and from there the diagnosis is immediate, because the subject's repr already told you the value is -1 with no decimal point. That is Chapter 19.12 Section 6's defect, found by procedure instead of by having read about it.
This is why Chapter 19.12 built its pattern in stages. A pattern assembled one construct at a time comes with its own bisect list already written, and keeping that list in the module is the cheapest debugging investment in this module.
5. Step 4 — A Wrong Value: Print group(0)
The other symptom. A loose pattern finds something, and the something is plausible:
import re
NOTE = "note: slack margin policy is 0.000 for VIOLATED corners"
m = re.search(r"slack.*?(-?\d+\.\d+)", NOTE)
print("group(1):", m.group(1))
print("group(0):", repr(m.group(0)))group(1): 0.000
group(0): 'slack margin policy is 0.000'group(1) is 0.000, which is a perfectly believable slack value — a path exactly at the margin. A timing summary built on this pattern reports a path at zero slack that does not exist, and it reports it in the right units with the right number of decimal places.
group(0) settles it in one line. The pattern consumed 'slack margin policy is 0.000', which is not a slack result at all, and the fix is not to the number part — it is to require the parenthesised tag, exactly as Chapter 19.12 Stage 3 did.
For a subject where the match is somewhere in the middle, a context window is worth the extra line:
import re
LINE = "UVM_ERROR drv.sv(88) @ 12500: seed=1839274"
m = re.search(r"\d+", LINE)
print("group:", repr(m.group(0)))
print("span :", m.span())
print("ctx :", repr(LINE[max(0, m.start() - 8):m.end() + 8]))group: '88'
span : (17, 19)
ctx : ' drv.sv(88) @ 1250'The context shows \d+ matched the line number inside the parentheses, not the cycle and not the seed — Chapter 19.11 Section 2's point, as a diagnosis rather than a warning. A bare \d+ on a log line is almost never the number you meant, and the span plus eight characters either side is how you find out which one it took.
6. Step 5 — The Field the New Version Renamed
The cause that no amount of pattern work will fix:
import re
PATTERN = r"slack\s+\((?:VIOLATED|MET)\)\s+(-?\d+(?:\.\d+)?)"
V1 = " slack (VIOLATED) -0.142"
V2 = " slack_ns (VIOLATED) -0.142"
print("old tool:", re.search(PATTERN, V1).group(1))
print("new tool:", re.search(PATTERN, V2))old tool: -0.142
new tool: NoneOne field renamed from slack to slack_ns, and a parser that worked for two years returns None. The symptom is identical to a tab, to a missing flag and to a wrong pattern — which is why Step 1 matters so much: repr(V2) shows slack_ns and the diagnosis takes a second.
Two things to do about it, and they are different.
Diagnose it by comparison. Keep one real line from each tool version you support, and when a pattern starts failing, run it against all of them. The version that still works tells you what changed:
import re
PATTERN = r"slack\s+\((?:VIOLATED|MET)\)\s+(-?\d+(?:\.\d+)?)"
SAMPLES = {
"tool_v1": " slack (VIOLATED) -0.142",
"tool_v2": " slack_ns (VIOLATED) -0.142",
}
for name in sorted(SAMPLES):
m = re.search(PATTERN, SAMPLES[name])
print(f"{name}: {m.group(1) if m else 'NO MATCH'}")tool_v1: -0.142
tool_v2: NO MATCHFix it deliberately, not by loosening. The tempting fix is to make the pattern vaguer — \w*slack\w*, or drop the word entirely — and that trades a loud failure for Section 5's quiet one. The right fix is to name both spellings, so the pattern still rejects everything else:
import re
PATTERN = r"slack(?:_ns)?\s+\((?:VIOLATED|MET)\)\s+(-?\d+(?:\.\d+)?)"
SAMPLES = {
"tool_v1": " slack (VIOLATED) -0.142",
"tool_v2": " slack_ns (VIOLATED) -0.142",
"note ": " note: slack margin policy is 0.000",
}
for name in sorted(SAMPLES):
m = re.search(PATTERN, SAMPLES[name])
print(f"{name}: {m.group(1) if m else 'NO MATCH'}")note : NO MATCH
tool_v1: -0.142
tool_v2: -0.142(?:_ns)? is Chapter 19.10's optional group inside Chapter 19.12's non-capturing group: both spellings accepted, the policy note still rejected, and the pattern still has exactly one numbered group. That is a widened pattern rather than a loosened one, and the difference is whether it still says no to anything.
7. The Routine, as One Function
Everything above, in the order of the diagram:
import re
PATTERN = r"slack\s+\((?:VIOLATED|MET)\)\s+(-?\d+(?:\.\d+)?)"
STAGES = [
r"slack",
r"slack\s+\(",
r"slack\s+\((?:VIOLATED|MET)\)",
r"slack\s+\((?:VIOLATED|MET)\)\s+",
PATTERN,
]
def diagnose(subject, pattern, stages):
"""Print everything worth knowing about one pattern against one line."""
print("1 subject :", repr(subject))
m = re.search(pattern, subject)
print("2 symptom :", m.groups() if m else "NO MATCH")
if m is None:
for stage in stages:
hit = re.search(stage, subject)
print(f"3 {'ok ' if hit else 'FAIL'} {stage}")
if hit is None:
break
else:
print("4 consumed:", repr(m.group(0)))
print("4 span :", m.span())
diagnose(" slack\t(MET)\t0.031", PATTERN, STAGES)
print("-" * 46)
diagnose(" slack_ns (VIOLATED) -0.142", PATTERN, STAGES)1 subject : ' slack\t(MET)\t0.031'
2 symptom : ('0.031',)
4 consumed: 'slack\t(MET)\t0.031'
4 span : (2, 19)
----------------------------------------------
1 subject : ' slack_ns (VIOLATED) -0.142'
2 symptom : NO MATCH
3 ok slack
3 FAIL slack\s+\(Twenty lines, and both diagnoses are complete. The first line matched despite its tabs, and group(0) confirms what it ate. The second returned NO MATCH, the bisect stopped at the first failing prefix — slack\s+\( — and that plus the repr on line 1 says the whole story: the word is there, what follows it is not a bracket, and the subject shows _ns sitting between them.
Note the break in the bisect loop. Once a prefix fails, every longer prefix fails too, so printing them adds nothing — the first FAIL is the answer.
8. Predict, Run, Observe
import re
SUBJECT = "seed\t1839274 "
# A
print("A:", repr(SUBJECT))
# B
print("B:", re.search(r"seed (\d+)", SUBJECT))
# C
print("C:", re.search(r"seed\s+(\d+)", SUBJECT).group(1))
# D
print("D:", re.search(r"(\d+)$", SUBJECT))
# E
print("E:", re.search(r"(\d+)\s*$", SUBJECT).group(1))
# F
m = re.search(r"\d+", "drv.sv(88) seed=1839274")
print("F:", m.group(0), m.span())
# G
loose = re.search(r"seed\D*(\d+)", "note: seed policy applies to 12 corners")
print("G:", loose.group(1), repr(loose.group(0)))The verified output:
A: 'seed\t1839274 '
B: None
C: 1839274
D: None
E: 1839274
F: 88 (7, 9)
G: 12 'seed policy applies to 12'A — a tab and two trailing spaces, invisible until repr.
B — a literal space cannot match a tab. Step 1 would have shown this before the pattern was written.
C — \s+ matches the tab.
D — the trailing spaces mean the digits are not at the end, so an anchored pattern fails. Same shape as the \r case.
E — \s* before $ absorbs the trailing whitespace. A .rstrip() on the line would be better, and both are correct.
F — a bare \d+ took the line number out of the parentheses. The span, not the group, is what tells you that.
G — a wrong value, not a None: the pattern read 12 out of a sentence about corners and would have reported it as a seed. group(0) is the only thing in that output that reveals it.
9. Exercises
Exercise 1 — repr first. Take a line with a tab and trailing whitespace. Print it plainly and with repr, and list what the second form told you that the first did not.
Exercise 2 — the literal space. Write a pattern with a literal space against your Exercise 1 line, record the None, and fix it. Say which chapter gave the rule you just rediscovered.
Exercise 3 — the carriage return. Build a string ending in \r, strip it with rstrip("\n") and with rstrip(), and show an anchored pattern failing on one and working on the other. Explain why $ does not help.
Exercise 4 — two causes, one symptom. Produce two NO MATCH results from two different causes with the same pattern, and say what you would print next to tell them apart.
Exercise 5 — build a bisect list. Write a five-stage prefix list for a pattern of your own and run it against a line that works. Confirm every stage is ok and read the group(0) column.
Exercise 6 — bisect a failure. Run the same list against a line that fails. Report the first failing prefix and name the symbols that differ between it and the previous stage.
Exercise 7 — break early. Add the break from Section 7 to your bisect. Say what the longer prefixes would have told you.
Exercise 8 — a believable false positive. Write a loose pattern that extracts a plausible wrong number from a line of prose. Print group(1) and group(0) and say which one a code review would have caught.
Exercise 9 — the context window. Use span() to print eight characters either side of a match that took the wrong number. Say what the window shows that the group does not.
Exercise 10 — the renamed field. Keep two sample lines from two tool versions and show one pattern failing on the newer one. Fix it by naming both spellings, and prove the fix still rejects a third line that should not match.
Exercise 11 — widened, not loosened. Take your Exercise 10 fix and write the lazy alternative that loosens the pattern instead. Say what it would now accept that it should not.
Exercise 12 — the whole routine. Write your own diagnose function and run it against one working line and two failing lines with different causes. Say how many commands you needed per diagnosis.
10. Summary
- Two symptoms, at least four causes.
Noneor a wrong value, from the data, the tool version, the flags or the pattern. - Three of the four causes are not in the regex, which is why editing the pattern first is the wrong instinct.
print(repr(line))goes first, always. It needs no hypothesis and eliminates two causes at once.- Tabs are invisible everywhere, and a tool that aligns one column with spaces and the next with a tab is normal.
rstrip("\n")leaves a\ron a log written on another operating system, and\S+with$then returnsNoneon a correct pattern. Userstrip().print(m.groups() if m else "NO MATCH")is the branch point, and theif m elsekeeps a failed match from raisingAttributeError.- Bisect the pattern — run progressively longer prefixes and find the first that fails. The difference between the last
okand the firstFAILis the fault. - The
group(0)column of a bisect shows what each symbol consumed, including the tab you did not know was there. - A pattern built in stages comes with its bisect list already written, which is a second reason to build patterns the way Chapter 19.12 does.
- For a wrong value, print
group(0). A loose pattern reading0.000out of a policy note returns a believable slack value. - Print a context window around
span()when the match is mid-line — a bare\d+usually takes a number you did not mean. - A false positive is worse than a
None, because it reaches a report instead of a guard. - A renamed field looks exactly like every other cause, and the diagnosis is a comparison against a sample from each tool version you support.
- Widen a pattern, never loosen it.
slack(?:_ns)?accepts both spellings and still rejects prose; dropping the word accepts everything.
The next chapter turns the sample lines this one kept for diagnosis into something a regression can run before a pattern ever reaches a nightly.
Where this fits
Part of the Python curriculum.
