Python Part II · Module 19 · Regular Expressions for VLSI
How Many: * + ? {n} {m,n}
Chapter 19.4 ended on a pattern that found 8 where the author wanted 88, because a character class matches exactly one character. The fix is a second kind of symbol that says how many times the thing before it may repeat — and it is worth treating as more than a convenience, because a quantifier is a claim about the data. Saying eight hex characters rejects a line with seven. Saying one or more rejects an empty field. Saying zero or more, which is the one people reach for by habit, accepts both and reports success either way.
Foundation14 min readPythonPart IIRegexLog ParsingVerification Flow
Part II · Module 19 · Chapter 19.6 · How Many: * + ? {n} {m,n}
1. The Five Quantifiers
A quantifier follows the thing it repeats — a class, a literal, or later a group:
| Quantifier | Means |
|---|---|
* | zero or more |
+ | one or more |
? | zero or one — optional |
{n} | exactly n |
{m,n} | between m and n |
{m,} | m or more |
import re
print("* zero ok :", re.search(r"ab*c", "ac").group())
print("* many ok :", re.search(r"ab*c", "abbbc").group())
print("+ needs one:", re.search(r"ab+c", "ac"))
print("+ many ok :", re.search(r"ab+c", "abbbc").group())
print("? optional :", re.search(r"ab?c", "ac").group(), re.search(r"ab?c", "abc").group())
print("{n} exactly:", re.search(r"ab{3}c", "abbbc").group())
print("{m,n} range:", re.search(r"\d{2,4}", "1420567").group())
print("{m,} open :", re.search(r"\d{3,}", "1420567").group())* zero ok : ac
* many ok : abbbc
+ needs one: None
+ many ok : abbbc
? optional : ac abc
{n} exactly: abbbc
{m,n} range: 1420
{m,} open : 1420567Note {2,4} returned 1420 and not 14 — a quantifier takes as many as it can within its limit. Section 6 is about that.
2. The Fix for Chapter 19.4
import re
LINE = "UVM_ERROR drv.sv(88) @ 1420: bad handshake"
print("one digit :", re.search(r"\d", LINE).group())
print("one or more:", re.search(r"\d+", LINE).group())
print("all numbers:", re.findall(r"\d+", LINE))
print("the file :", re.search(r"\w+\.\w+", LINE).group())one digit : 8
one or more: 88
all numbers: ['88', '1420']
the file : drv.sv\d+ is probably the most-written pattern in log parsing, and \w+\.\w+ is the filename fix from Chapter 19.4 Section 3 — a run of word characters, a literal dot, another run.
3. The Optional Sign
The canonical annotation's second example, and the fix for the dropped minus from Chapter 19.5 Section 8:
import re
for subject in ["slack 0.142ns", "slack -0.142ns", "slack -12ns"]:
unsigned = re.search(r"\d+\.\d+", subject)
signed = re.search(r"-?\d+\.\d+", subject)
print(f"{subject:<18} unsigned={str(unsigned.group() if unsigned else None):<8} signed={signed.group() if signed else None}")slack 0.142ns unsigned=0.142 signed=0.142
slack -0.142ns unsigned=0.142 signed=-0.142
slack -12ns unsigned=None signed=NoneThe middle row is the bug. Without -?, a negative slack comes back positive — a timing violation read as passing. With it, the sign is captured when present and the pattern still matches when absent, which is what optional means.
The third row shows the pattern is still incomplete: -12ns has no decimal point, and \d+\.\d+ requires one. A real STA parser must decide whether an integer slack is possible, and if so the pattern needs the decimal part itself optional:
import re
for subject in ["slack 0.142ns", "slack -0.142ns", "slack -12ns", "slack 5ns"]:
m = re.search(r"-?\d+(\.\d+)?", subject)
print(f"{subject:<18} -> {m.group()}")slack 0.142ns -> 0.142
slack -0.142ns -> -0.142
slack -12ns -> -12
slack 5ns -> 5(\.\d+)? makes the whole decimal part optional — a group with a quantifier on it, which is Chapter 19.8's subject arriving a chapter early because the number genuinely needs it.
4. * Accepts Nothing and Says Yes
The quietest quantifier bug:
import re
print("x* on a string with no x:", re.findall(r"x*", "ab"))
print("the match object :", re.search(r"x*", "ab"))
print("what it matched :", repr(re.search(r"x*", "ab").group()))
print("it is truthy :", bool(re.search(r"x*", "ab")))
print()
print("x+ on the same string :", re.search(r"x+", "ab"))x* on a string with no x: ['', '', '']
the match object : <re.Match object; span=(0, 0), match=''>
what it matched : ''
it is truthy : True
x+ on the same string : Nonex* succeeded on a string containing no x. It matched the empty string at position zero, returned a truthy match object, and group() gave ''.
A parser checking if re.search(pattern, line): with a *-based pattern can therefore report success on every line of a log:
import re
LINES = ["UVM_INFO fine", "no errors here", "Simulation complete"]
star = sum(1 for l in LINES if re.search(r"UVM_ERROR\s*", l))
plus = sum(1 for l in LINES if re.search(r"UVM_ERROR\s+", l))
bare = sum(1 for l in LINES if re.search(r"UVM_ERROR", l))
print("with trailing \\s* :", star)
print("with trailing \\s+ :", plus)
print("with no trailing :", bare)with trailing \s* : 0
with trailing \s+ : 0
with no trailing : 0All three are zero here because the literal part UVM_ERROR is absent, which is reassuring — a * at the end of an otherwise-literal pattern is harmless.
The danger is a pattern that is * all the way down:
import re
LINES = ["UVM_INFO fine", "no errors here"]
print("pattern [A-Z]* matches every line:", [bool(re.search(r"[A-Z]*", l)) for l in LINES])
print("what it actually matched :", [re.search(r"[A-Z]*", l).group() for l in LINES])
print("pattern [A-Z]+ is honest :", [bool(re.search(r"[A-Z]+", l)) for l in LINES])pattern [A-Z]* matches every line: [True, True]
what it actually matched : ['UVM', '']
pattern [A-Z]+ is honest : [True, False][A-Z]* reported True for both lines, including the one with no uppercase letters at all, where it matched an empty string. [A-Z]+ correctly rejected it.
Default to
+. Reach for*only when zero occurrences is a genuinely valid answer — optional whitespace between fields, an optional sign, a padding run that may be absent.
5. {n} as a Format Assertion
Exact counts are where a quantifier stops being about flexibility and becomes a check:
import re
ADDRESSES = ["deadbeef", "DEADBEEF", "dead", "deadbeef00", "deadbeeg"]
for a in ADDRESSES:
exact = re.fullmatch(r"[0-9a-fA-F]{8}", a)
loose = re.search(r"[0-9a-fA-F]+", a)
print(f"{a:<12} exactly 8 hex: {str(bool(exact)):<6} any hex run: {loose.group() if loose else None}")deadbeef exactly 8 hex: True any hex run: deadbeef
DEADBEEF exactly 8 hex: True any hex run: DEADBEEF
dead exactly 8 hex: False any hex run: dead
deadbeef00 exactly 8 hex: False any hex run: deadbeef00
deadbeeg exactly 8 hex: False any hex run: deadbee{8} with fullmatch rejects three of the five. A 32-bit address has eight hex characters; one with four is truncated, one with ten is something else, and deadbeeg contains a non-hex letter.
The any hex run column shows what a permissive pattern does with the same inputs: it accepts all five, returning deadbee for the malformed one — a plausible-looking address that is wrong.
{m,n} covers a value with a known range:
import re
for seed in ["7", "1234", "99999", "123456789"]:
ok = bool(re.fullmatch(r"\d{1,6}", seed))
print(f"seed {seed:<12} within 1-6 digits: {ok}")seed 7 within 1-6 digits: True
seed 1234 within 1-6 digits: True
seed 99999 within 1-6 digits: True
seed 123456789 within 1-6 digits: FalseNote both examples use fullmatch rather than search. A length assertion is meaningless with search, which would happily find eight hex characters inside a longer string — Chapter 19.11 covers the distinction and Chapter 19.7's anchors are the other way to express it.
6. Quantifiers Are Greedy
One property to know now, because it changes what a pattern returns:
import re
print("digits stop at a letter:", re.search(r"\d+", "1420ns").group())
print("a class stops at its boundary:", re.search(r"[a-z]+", "abc123").group())
print("a dot run does not stop:", re.search(r".+", "1420ns and more").group())
print("bounded even so :", re.search(r"\d{2,4}", "1420567").group())digits stop at a letter: 1420
a class stops at its boundary: abc
a dot run does not stop: 1420ns and more
bounded even so : 1420A quantifier takes as many as it can. For a narrow class that is harmless — \d+ cannot run past ns because n is not a digit, so the boundary enforces itself.
For .+ it is not harmless. . matches almost everything, so .+ runs to the end of the line and takes every field with it. That is Chapter 19.13's subject, and the reason Chapter 19.4 recommended the narrowest class that works: with a narrow class, greediness is self-limiting; with a dot, it is not.
7. Predict, Run, Observe
import re
LINE = "test smoke_1234 ran 7 times, slack -0.142ns"
print("A:", re.findall(r"\d+", LINE))
print("B:", re.search(r"\w+", LINE).group())
print("C:", re.search(r"-?\d+\.\d+", LINE).group())
print("D:", re.findall(r"\d{4}", LINE))
print("E:", repr(re.search(r"z*", LINE).group()))
print("F:", re.search(r"[a-z_]+\d*", LINE).group())
print("G:", re.fullmatch(r".+", LINE) is not None)A: ['1234', '7', '0', '142']
B: test
C: -0.142
D: ['1234']
E: ''
F: test
G: TrueA is the one to study. \d+ found four runs, and -0.142 came back as two of them — 0 and 142 — because the minus sign and the decimal point are not digits, so each breaks the run. The value is unrecoverable from this list.
C gets it right by describing the whole shape including the sign and the point, which is the difference between collecting digits and parsing a number.
D found only 1234; 7 is one digit and 142 is three, so neither satisfies {4}. An exact count is a filter as much as a measurement.
E is the empty match from Section 4, on a line containing no z at all.
F stopped at test — the pattern is a run of lowercase-or-underscore followed by zero or more digits, and test satisfies it with zero digits. It never reached smoke_1234, because search returns the first match and the first match was acceptable. A + on the digits would have forced it onward.
8. Exercises
Exercise 1 — the five. Demonstrate *, +, ?, {n} and {m,n} on the same subject. Note which reject it.
Exercise 2 — one or more digits. Extract every number from a log line with \d+. Then find a line where the result is not what you wanted.
Exercise 3 — the optional sign. Extract a negative slack with and without -?. State what the unsigned version reports.
Exercise 4 — optional decimal. Make a pattern that accepts both -12 and -0.142. Test it on four inputs.
Exercise 5 — the empty match. Show x* succeeding on a string with no x. Print the span and explain why it is truthy.
Exercise 6 — star all the way down. Write a pattern of only starred classes and show it matches every line of a log. Fix it with +.
Exercise 7 — exactly eight. Validate a hex address with {8} and fullmatch. Find three inputs it correctly rejects.
Exercise 8 — greedy boundary. Compare \d+ and .+ on the same line. Say why one stops and the other does not.
9. Summary
- A quantifier says how many times the preceding thing may repeat, and follows it.
*is zero or more,+one or more,?optional,{n}exact and{m,n}a range.\d+is the most-written pattern in log parsing;\w+\.\w+is the filename shape.-?makes the sign optional and fixes a negative value coming back positive.*matches the empty string, so a*-based pattern can return a truthy match on a line containing none of what you wanted.- Default to
+; use*only where zero is genuinely valid. {n}is a format assertion — eight hex characters rejects a line with seven, where a permissive pattern extracts a plausible wrong value.- A length assertion needs
fullmatchor anchors, becausesearchfinds it inside a longer string. - Quantifiers are greedy: with a narrow class that is self-limiting, with a dot it is not.
- A quantifier is a claim about your data — make it the narrowest true one.
The next chapter pins a pattern to a position, and fixes the most common false positive in log parsing.
Where this fits
Part of the Python curriculum.
