Perl Part II · Module 28 · Legacy Perl and Python Migration
Perl Regex Features Python Does Not Have
Chapter 16.8 compared the two regular-expression engines and found them mostly the same. Porting a Perl script still takes real work around its patterns, because in Perl a pattern is part of the language and in Python it is a library. Perl can compute a replacement inline, transliterate characters with an operator that is not a regex at all, scan a string token by token from where the last match stopped, collect named captures into a hash, and match against a variable nobody wrote down. This chapter takes each of those constructs, measures a literal Python port against the Perl original, and gives the rewrite that behaves the same.
Advanced25 min readPerlPart IIEDA Flows
Part II · Module 28 · Chapter 28.7 · Perl Regex Features Python Does Not Have
1. The Engine Is Not the Problem
Chapter 16.8 ran sixteen regex features through both engines and found eleven identical, four Perl-only (variable-length lookbehind, \p{...}, (?R) and \K) and one available in neither. If you are porting a pattern, its conclusion still holds: the pattern itself almost always moves across unchanged, or with a spelling change such as (?<name> to (?P<name>.
The porting work is in the operators. Perl gives a pattern a set of operators that Python provides only as library functions, if at all:
s///e, which runs code to build each replacement;tr///, which is not a regex and only looks like one;\Gwith/gc, which continues from where the last match stopped and refuses to skip;%+, a hash of named captures;- the implicit
$_, and$1that survives a failed match.
Each section below runs a Perl original and a Python port side by side, in a setting from flow scripts: constraint files, hierarchical names, netlist lines, timing reports and report headers.
2. /e: Computing the Replacement
With /e, the replacement part of s/// is Perl code, run once per match, and its value is what gets inserted (Chapter 11.14). Here it converts nanosecond values in constraint lines to picoseconds:
use strict;
use warnings;
# convert nanosecond values in constraint lines to picoseconds
my @sdc = ("set_input_delay 0.5ns -clock clk [get_ports din]",
"set_output_delay 1.25ns -clock clk [get_ports dout]",
"set_max_delay 0.07ns -from u_a -to u_b");
for my $line (@sdc) {
(my $ps = $line) =~ s/(\d+(?:\.\d+)?)ns\b/($1 * 1000) . "ps"/ge;
print "$ps\n";
}import re
sdc = ["set_input_delay 0.5ns -clock clk [get_ports din]",
"set_output_delay 1.25ns -clock clk [get_ports dout]",
"set_max_delay 0.07ns -from u_a -to u_b"]
PAT = r"(\d+(?:\.\d+)?)ns\b"
print("-- a replacement STRING cannot compute:")
print(re.sub(PAT, r"\1*1000ps", sdc[0]))
print("-- a FUNCTION can, but it prints Python's float:")
for line in sdc:
print(re.sub(PAT, lambda m: f"{float(m.group(1)) * 1000}ps", line))
print("-- a function that prints the number the way Perl does:")
for line in sdc:
print(re.sub(PAT, lambda m: "%.15gps" % (float(m.group(1)) * 1000), line))#!/bin/bash
echo "--- perl rx/eval_subst.pl"
perl rx/eval_subst.pl
echo "--- python3 rx/eval_subst.py"
python3 rx/eval_subst.py--- perl rx/eval_subst.pl
set_input_delay 500ps -clock clk [get_ports din]
set_output_delay 1250ps -clock clk [get_ports dout]
set_max_delay 70ps -from u_a -to u_b
--- python3 rx/eval_subst.py
-- a replacement STRING cannot compute:
set_input_delay 0.5*1000ps -clock clk [get_ports din]
-- a FUNCTION can, but it prints Python's float:
set_input_delay 500.0ps -clock clk [get_ports din]
set_output_delay 1250.0ps -clock clk [get_ports dout]
set_max_delay 70.0ps -from u_a -to u_b
-- a function that prints the number the way Perl does:
set_input_delay 500ps -clock clk [get_ports din]
set_output_delay 1250ps -clock clk [get_ports dout]
set_max_delay 70ps -from u_a -to u_bThree ports, three results:
- A replacement string (
r"\1*1000ps") cannot compute anything. Python inserts the captured text and the literal characters*1000, giving0.5*1000ps: a constraint line no tool would accept, and no error from Python. - A replacement function is the real equivalent of
/e:re.subcalls it with the match object for every match. But it returns Python's float formatting,500.0pswhere Perl wrote500ps. A timing tool may accept both. A diff against the Perl output will not. - A function that formats like Perl, with
"%.15g"(the rule from Chapter 28.5), reproduces the original exactly, including70psfor0.07ns.
The rule: /e becomes a function, and the function must format its result the way the Perl expression stringified it.
3. tr///: Not a Regex at All
tr/// (Chapter 11.15) maps characters one for one. Its two halves are lists of characters, not patterns. And it returns a count, which is often the only reason it is there:
use strict;
use warnings;
my $path = "u_core/u_alu/acc_reg[7]";
my $depth = ($path =~ tr{/}{}); # tr returns a COUNT; empty list = count only
(my $dotted = $path) =~ tr{/}{.}; # character-for-character mapping
(my $safe = $path) =~ tr/a-zA-Z0-9_//cd; # c = complement, d = delete
(my $squeezed = "u_core//u_alu///acc") =~ tr{/}{}s; # s = squeeze repeats
print "depth=$depth\ndotted=$dotted\nsafe=$safe\nsqueezed=$squeezed\n";
(my $dots = "ss_0.72v.lib") =~ tr/./_/; # tr is NOT a regex: "." is just a dot
print "dots=$dots\n";import re
path = "u_core/u_alu/acc_reg[7]"
print(f"depth={path.count('/')}")
print(f"dotted={path.translate(str.maketrans('/', '.'))}")
print(f"safe={re.sub(r'[^a-zA-Z0-9_]', '', path)}")
print(f"squeezed={re.sub(r'/+', '/', 'u_core//u_alu///acc')}")
# tr/./_/ translated as a regex substitution, character for character:
print(f"dots={re.sub('.', '_', 'ss_0.72v.lib')}")#!/bin/bash
echo "--- perl rx/translit.pl"
perl rx/translit.pl
echo "--- python3 rx/translit.py"
python3 rx/translit.py--- perl rx/translit.pl
depth=2
dotted=u_core.u_alu.acc_reg[7]
safe=u_coreu_aluacc_reg7
squeezed=u_core/u_alu/acc
dots=ss_0_72v_lib
--- python3 rx/translit.py
depth=2
dotted=u_core.u_alu.acc_reg[7]
safe=u_coreu_aluacc_reg7
squeezed=u_core/u_alu/acc
dots=____________The first four results match, and each needs a different Python tool:
| Perl | What it does | Python |
|---|---|---|
$n = ($s =~ tr{/}{}) | counts characters (hierarchy depth here) | s.count("/") |
tr{/}{.} | maps one character to another | s.translate(str.maketrans("/", ".")) |
tr/a-zA-Z0-9_//cd | deletes everything not in the list | re.sub(r"[^a-zA-Z0-9_]", "", s) |
tr{/}{}s | squeezes runs of a character | re.sub(r"/+", "/", s) |
The last line of the output is the trap. In tr/./_/ the dot is a dot: Perl turned ss_0.72v.lib into ss_0_72v_lib. A translator who reads it as a substitution writes re.sub('.', '_', ...), where the dot is any character, and the library name becomes twelve underscores. The fix is to stop treating tr as a regex at all: use str.translate for mappings, and if a regex is genuinely needed, escape the characters (re.escape) or put them in a character class.
4. \G: A Lexer That Stops, and One That Skips
\G anchors a match at the position where the previous /g match on the same string ended, and the /c flag keeps that position when a match fails (Chapter 11.20). Together they make a lexer: try each token pattern at the current position, and if none fits, stop and report the column. Here it tokenises a gate-level instance, and the second line has a stray #, the kind of corruption a broken netlist writer produces:
use strict;
use warnings;
# tokenise one gate-level instance; the second line has a stray '#'
for my $s ("AND2X1 u_and (.A(n1), .B(n2), .Y(n3));",
"AND2X1 u_and (.A(n1), #.B(n2), .Y(n3));") {
my @tok;
pos($s) = 0;
while (pos($s) < length $s) {
if ($s =~ /\G\s+/gc) { } # skip spaces
elsif ($s =~ /\G(\.\w+)/gc) { push @tok, "PIN:$1" }
elsif ($s =~ /\G(\w+)/gc) { push @tok, "ID:$1" }
elsif ($s =~ /\G([(),;])/gc) { push @tok, $1 }
else { push @tok, "ERROR at column " . (pos($s) + 1); last } # \G: no skipping
}
print scalar(@tok), " tokens: @tok\n";
}import re
lines = ["AND2X1 u_and (.A(n1), .B(n2), .Y(n3));",
"AND2X1 u_and (.A(n1), #.B(n2), .Y(n3));"]
TOKEN = re.compile(r"(\s+)|(\.\w+)|(\w+)|([(),;])")
def kind(m):
if m.group(2): return "PIN:" + m.group(2)
if m.group(3): return "ID:" + m.group(3)
return m.group(4)
print("-- translated with finditer (it SEARCHES, so it skips what it cannot match):")
for s in lines:
tok = [kind(m) for m in TOKEN.finditer(s) if not m.group(1)]
print(len(tok), "tokens:", " ".join(tok))
print("-- translated with match(s, pos), the \\G equivalent:")
for s in lines:
tok, pos = [], 0
while pos < len(s):
m = TOKEN.match(s, pos) # anchored AT pos, like \G
if not m:
tok.append(f"ERROR at column {pos + 1}")
break
if not m.group(1):
tok.append(kind(m))
pos = m.end()
print(len(tok), "tokens:", " ".join(tok))#!/bin/bash
echo "--- perl rx/lexer.pl"
perl rx/lexer.pl
echo "--- python3 rx/lexer.py"
python3 rx/lexer.py--- perl rx/lexer.pl
19 tokens: ID:AND2X1 ID:u_and ( PIN:.A ( ID:n1 ) , PIN:.B ( ID:n2 ) , PIN:.Y ( ID:n3 ) ) ;
9 tokens: ID:AND2X1 ID:u_and ( PIN:.A ( ID:n1 ) , ERROR at column 23
--- python3 rx/lexer.py
-- translated with finditer (it SEARCHES, so it skips what it cannot match):
19 tokens: ID:AND2X1 ID:u_and ( PIN:.A ( ID:n1 ) , PIN:.B ( ID:n2 ) , PIN:.Y ( ID:n3 ) ) ;
19 tokens: ID:AND2X1 ID:u_and ( PIN:.A ( ID:n1 ) , PIN:.B ( ID:n2 ) , PIN:.Y ( ID:n3 ) ) ;
-- translated with match(s, pos), the \G equivalent:
19 tokens: ID:AND2X1 ID:u_and ( PIN:.A ( ID:n1 ) , PIN:.B ( ID:n2 ) , PIN:.Y ( ID:n3 ) ) ;
9 tokens: ID:AND2X1 ID:u_and ( PIN:.A ( ID:n1 ) , ERROR at column 23The Perl lexer reads the clean line as 19 tokens and stops on the corrupt line with ERROR at column 23: exactly where the # is.
The most natural Python port uses re.finditer with the same alternation. On the clean line it agrees. On the corrupt line it also reports 19 tokens, with no error, because finditer searches: when nothing matches at the #, it moves on and finds the next token. The corruption is silently skipped, and a netlist checker built on it would pass a file the original rejected.
The faithful port is pattern.match(s, pos), which anchors the attempt at pos, which is exactly \G's job. A failed match returns None, and the loop reports the column, just as the Perl else branch does. This is the construct in this chapter where a plausible port is most dangerous, because the port is more forgiving than the original, and nothing about the output looks wrong.
5. Named Captures: the Syntax, and %+
Perl writes a named group as (?<name>...) and reads it back from the %+ hash (Chapter 11.11). Python 3.13 spells the group (?P<name>...) and reads it from the match object:
use strict;
use warnings;
my $re = qr/^\s+slack \((?<status>\w+)\)\s+(?<slack>-?[\d.]+)/;
for my $line (" slack (VIOLATED) -0.043", " slack (MET) 0.120") {
next unless $line =~ $re;
print "$+{status} $+{slack}\n"; # %+ holds the named captures
}import re
lines = [" slack (VIOLATED) -0.043", " slack (MET) 0.120"]
try: # the Perl pattern, copied as it is
re.compile(r"^\s+slack \((?<status>\w+)\)\s+(?<slack>-?[\d.]+)")
except re.error as e:
print(f"copied as-is: re.error: {e}")
RE = re.compile(r"^\s+slack \((?P<status>\w+)\)\s+(?P<slack>-?[\d.]+)")
for line in lines:
m = RE.match(line)
if m:
print(m["status"], m["slack"]) # %+ becomes m["name"]#!/bin/bash
echo "--- perl rx/named.pl"
perl rx/named.pl
echo "--- python3 rx/named.py"
python3 rx/named.py--- perl rx/named.pl
VIOLATED -0.043
MET 0.120
--- python3 rx/named.py
copied as-is: re.error: unknown extension ?<s at position 13
VIOLATED -0.043
MET 0.120This one fails loudly, which makes it the easiest of the five: the copied pattern raises re.error: unknown extension ?<s at compile time. Change the spelling, and replace $+{status} with m["status"] (or m.group("status")). The one subtlety is absent groups. In Perl, $+{name} for a group that did not participate is undef, and in Python m["name"] is None. Both are falsy, but None is not the empty string, so any code that concatenates or prints it behaves differently. Chapter 28.6 measures the rest of that truth table in its section on the string "0".
6. The Implicit $_, and a Stale $1
Perl matches against $_ when no target is named (Chapter 9.7). Python has no implicit variable, so every match needs an explicit subject. That part is a mechanical change. The danger is the habit that usually comes with it: running a match without checking whether it succeeded, and reading $1 anyway. After a failed match, $1 still holds the value from the last successful match in scope (Chapter 11.10):
use strict;
use warnings;
# the implicit $_ and a stale $1: two report headers, the second malformed
my @headers = ("Corner : ss_0p72v_125c", "Corner: tt_0p80v_25c");
my $corner;
for (@headers) {
/^Corner\s+:\s+(\S+)/; # matches $_; result not checked
$corner = $1; # after a FAILED match: the old $1
print "$_ -> corner=$corner\n";
}import re
headers = ["Corner : ss_0p72v_125c", "Corner: tt_0p80v_25c"]
print("-- literal translation:")
for h in headers:
try:
corner = re.match(r"^Corner\s+:\s+(\S+)", h).group(1)
print(f"{h} -> corner={corner}")
except AttributeError as e:
print(f"{h} -> AttributeError: {e}")
print("-- careful translation:")
for h in headers:
m = re.match(r"^Corner\s+:\s+(\S+)", h)
corner = m.group(1) if m else None
print(f"{h} -> corner={corner}")#!/bin/bash
echo "--- perl rx/default_var.pl"
perl rx/default_var.pl
echo "--- python3 rx/default_var.py"
python3 rx/default_var.py--- perl rx/default_var.pl
Corner : ss_0p72v_125c -> corner=ss_0p72v_125c
Corner: tt_0p80v_25c -> corner=ss_0p72v_125c
--- python3 rx/default_var.py
-- literal translation:
Corner : ss_0p72v_125c -> corner=ss_0p72v_125c
Corner: tt_0p80v_25c -> AttributeError: 'NoneType' object has no attribute 'group'
-- careful translation:
Corner : ss_0p72v_125c -> corner=ss_0p72v_125c
Corner: tt_0p80v_25c -> corner=NoneThe second header is malformed (Corner: without the space), so the pattern fails on it. Perl reports corner=ss_0p72v_125c: the previous header's corner, silently reused. A summary built this way attributes the second report's paths to the first corner.
The two Python ports split. The literal port calls .group(1) on None and raises AttributeError, which is loud, and arguably better than the original. The careful port checks the match and gives None, which is correct and different. Neither reproduces the Perl. That is a decision for the inventory (Chapter 28.2), not something to settle inside a translation. If the migration must be byte-identical first, the faithful port keeps the previous value on purpose (corner = m.group(1) if m else corner) with a comment that it is reproducing a defect.
7. Five Smaller Traps in One Run
use strict;
use warnings;
for my $lib ('C:\new', 'C:\data') { # values that happen to hold backslashes
(my $s = "lib=PATH") =~ s/PATH/$lib/;
print "1 replacement value $lib : $s\n";
}
print "2 unanchored match : ", (" slack (MET) 0.1" =~ /slack/ ? "found" : "none"), "\n";
my @pairs = ("a=1 b=2" =~ /(\w+)=(\d+)/g);
print "3 /g with two groups : @pairs\n";
my @whole = ("a=1 b=2" =~ /\w+=\d+/g);
print "4 /g with no groups : @whole\n";
print "5 \\z end of string : ", ("42\n" =~ /^\d+\z/ ? "match" : "no match"), "\n";import re
for lib in ["C:\\new", "C:\\data"]: # the same characters as the Perl values
try:
s = re.sub("PATH", lib, "lib=PATH")
print("1 replacement value", lib, ":", s.replace("\n", "<NEWLINE>"))
except re.error as e:
print("1 replacement value", lib, ": re.error:", e)
found = re.match("slack", " slack (MET) 0.1")
print("2 unanchored match :", "found" if found else "none")
print("3 /g with two groups :", re.findall(r"(\w+)=(\d+)", "a=1 b=2"))
print("4 /g with no groups :", re.findall(r"\w+=\d+", "a=1 b=2"))
try:
hit = re.search(r"^\d+\z", "42\n")
print("5 \\z end of string :", "match" if hit else "no match")
except re.error as e:
print("5 \\z end of string : re.error:", e)#!/bin/bash
echo "--- perl rx/traps.pl"
perl rx/traps.pl
echo "--- python3 rx/traps.py"
python3 rx/traps.py--- perl rx/traps.pl
1 replacement value C:\new : lib=C:\new
1 replacement value C:\data : lib=C:\data
2 unanchored match : found
3 /g with two groups : a 1 b 2
4 /g with no groups : a=1 b=2
5 \z end of string : no match
--- python3 rx/traps.py
1 replacement value C:\new : lib=C:<NEWLINE>ew
1 replacement value C:\data : re.error: bad escape \d at position 2
2 unanchored match : none
3 /g with two groups : [('a', '1'), ('b', '2')]
4 /g with no groups : ['a=1', 'b=2']
5 \z end of string : re.error: bad escape \z at position 4re.subprocesses backslashes in the replacement. Perl interpolates$liband inserts its characters as they are. Python treats the replacement as a template.C:\newbecameC:, a newline, andew, with no error.C:\dataraisedbad escape \d. Any replacement that comes from data (a path, a user string, a value read from a file) must be passed as a function (lambda m: lib), not as a string.re.matchanchors at the start of the string; a Perl match does not (Chapter 11.8)./slack/found the word in an indented line, andre.match('slack', ...)did not. Usere.searchunless the Perl pattern starts with^.- and 4.
re.findallchanges shape with the number of groups. Perl's list-context/greturns all captures flattened, in order (Chapter 11.12). Python returns tuples when there are two or more groups, and whole matches when there are none. Code that indexes the result has to be rewritten for the shape. \zis a syntax error in Python 3.13. Use\Z, which in Python means what\zmeans in Perl: the absolute end of the string. (Python 3.14 adds\zas an alias.) Note that Perl's\Zand Python's\Zare not the same; Perl's allows a final newline.
8. A Parity Check for Every Pattern
Sections 2 to 7 each port one construct in isolation. A real port has many patterns, and the question that matters is whether all of them capture the same things on the inputs the script will meet. That can be checked directly. Run the original patterns and the ported ones over every line of a corpus, print what each captured, and compare the two listings. Here it is for the two patterns at the heart of the module's case study, sta_summary.pl:
if (/^Endpoint\s*:\s*(\S+)/) { ... }
if (/slack \((\w+)\)\s+(\S+)/) { ... }The corpus is the two sample reports plus a file of line variants: the kinds of small format differences that appear between tool versions and report options. Each variant is plausible, and none is in the sample reports.
Report : timing (synthetic teaching format: line variants seen across tool versions)
Endpoint: u_core/cnt_reg[5]
Endpoint : u_io/q_reg
slack (MET) .5
slack (VIOLATED) -0.0430
clock skew slack (MET) 0.002
slack (MET) 0.120The original patterns, a first port written the way many people tidy input before matching (strip the line, then match from the start), and a faithful port:
use strict;
use warnings;
# parity.pl FILE... - what the case study's two patterns capture, line by line
for my $file (@ARGV) {
open my $fh, '<', $file or die "cannot read $file: $!\n";
while (my $line = <$fh>) {
my @hit;
push @hit, "endpoint=$1" if $line =~ /^Endpoint\s*:\s*(\S+)/;
push @hit, "slack=$1,$2" if $line =~ /slack \((\w+)\)\s+(\S+)/;
print "$file:$.: @hit\n" if @hit;
}
}import re
import sys
# a FIRST port: strip each line, then match from the start
for name in sys.argv[1:]:
with open(name) as fh:
for n, line in enumerate(fh, 1):
text = line.strip()
hit = []
m = re.match(r"Endpoint\s*:\s*(\S+)", text)
if m:
hit.append(f"endpoint={m.group(1)}")
m = re.match(r"slack \((\w+)\)\s+(\S+)", text)
if m:
hit.append(f"slack={m.group(1)},{m.group(2)}")
if hit:
print(f"{name}:{n}: {' '.join(hit)}")import re
import sys
# the faithful port: the raw line, re.search, and ^ only where Perl had ^
ENDPOINT = re.compile(r"^Endpoint\s*:\s*(\S+)")
SLACK = re.compile(r"slack \((\w+)\)\s+(\S+)")
for name in sys.argv[1:]:
with open(name) as fh:
for n, line in enumerate(fh, 1):
hit = []
m = ENDPOINT.search(line)
if m:
hit.append(f"endpoint={m.group(1)}")
m = SLACK.search(line)
if m:
hit.append(f"slack={m.group(1)},{m.group(2)}")
if hit:
print(f"{name}:{n}: {' '.join(hit)}")The two sample reports are needed on this page too:
Report : timing (synthetic teaching format)
Design : chip_top
Corner : ss_0p72v_125c
Startpoint: u_core/r_reg[3]
Endpoint : u_core/acc_reg[7]
slack (VIOLATED) -0.043
Startpoint: u_core/r_reg[1]
Endpoint : u_core/acc_reg[2]
slack (VIOLATED) -0.011
Startpoint: u_core/cnt_reg[0]
Endpoint : u_core/cnt_reg[5]
slack (MET) 0.120
End of reportReport : timing (synthetic teaching format)
Design : chip_top
Corner : tt_0p80v_25c
Startpoint: u_core/r_reg[3]
Endpoint : u_core/acc_reg[7]
slack (MET) 0.087
Startpoint: u_core/cnt_reg[0]
Endpoint : u_core/cnt_reg[5]
slack (MET) 0.240
End of report#!/bin/bash
# Run the original patterns and each port over every line of the corpus,
# and compare what they captured. Same lines, same captures, or a diff.
files="reports/ss_0p72v_125c.rpt reports/tt_0p80v_25c.rpt rx/variants.rpt"
perl rx/parity.pl $files > perl.txt
echo "perl: $(wc -l < perl.txt | tr -d ' ') lines captured something"
for port in parity_first parity; do
python3 rx/$port.py $files > $port.txt
if cmp -s perl.txt $port.txt; then
echo "$port.py: identical captures"
else
echo "$port.py: differs"
diff perl.txt $port.txt | grep '^[<>]' | sed 's/^/ /'
fi
doneperl: 15 lines captured something
parity_first.py: differs
> rx/variants.rpt:3: endpoint=u_io/q_reg
< rx/variants.rpt:6: slack=MET,0.002
parity.py: identical capturesThe first port differs on exactly two lines, both variants, in opposite directions.
It gains an endpoint the original never saw. Endpoint : u_io/q_reg is indented. The Perl pattern is anchored with ^, so it does not match, and the original never records that endpoint. Stripping the line first removes the indentation, and the port matches it. In sta_summary.pl that changes which endpoint a later violation is attributed to under -v.
It loses a slack the original counted. clock skew slack (MET) 0.002 contains slack (MET) in the middle. The unanchored Perl pattern finds it and counts it as a timing path, which is almost certainly a defect in the original: a skew value is being counted as a path slack. re.match on the stripped line requires slack at the start, so the port does not count it. It has silently fixed a defect, which changes the paths= count of any corner whose report contains such a line.
Both differences come from two habits, stripping and re.match, and both are invisible on the sample reports, which capture identically under all three implementations. That is Chapter 28.8's lesson about corpora, at the level of a single pattern. The faithful port (the raw line, re.search, and ^ only where the Perl had ^) captures identically on all fifteen lines.
Keep this check after the migration. A file of line variants with the original's captures recorded next to it is a small, permanent test for every pattern in the port. When the next tool version adds a new line format, it goes into the variants file, and the check shows at once whether the pattern still does what the original did.
9. A Porting Table
| Perl construct | Python rewrite | What a literal port does instead |
|---|---|---|
s/(..)/EXPR/e | re.sub(pat, lambda m: ..., s), formatted like Perl | inserts the expression as text, or Python's float format |
$n = tr/x// | s.count("x") | — |
tr/abc/xyz/ | str.translate(str.maketrans(...)) | a regex where . and - mean something |
tr/...//cd, tr/x//s | re.sub with a negated class / x+ | — |
/\G.../gc loop | pattern.match(s, pos) loop | finditer silently skips what it cannot match |
(?<n>...), $+{n} | (?P<n>...), m["n"] | re.error (loud) |
/pat/ on $_, then $1 | explicit subject; check the match | AttributeError, or a correct value the original never produced |
s/x/$value/ | re.sub(pat, lambda m: value, s) | backslashes in value interpreted, or re.error |
/pat/ (unanchored) | re.search | re.match anchors at the start |
list-context /g | re.findall, then reshape | tuples or whole matches, by group count |
\z | \Z | re.error before Python 3.14 |
Use it the way Chapter 28.6's checklist is used: as a list of lines to stop at during translation, and as the review list for someone else's port.
10. Mistakes
Assuming the engine comparison settles the port. Chapter 16.8's table is about patterns. Every row above is about the code around them.
Porting /e with a replacement string. It cannot compute; it produced 0.5*1000ps without complaint.
Reading tr as a regex. tr/./_/ maps dots; re.sub('.', '_') replaced every character.
Porting a \G lexer with finditer. Measured: the corrupt netlist line produced the same 19 tokens as the clean one.
Passing data as a replacement string. A path with \n in it became a newline.
Using re.match for an unanchored Perl match. It quietly finds nothing in an indented line.
Stripping lines before matching. Measured: it made an anchored pattern match an indented line the original ignored.
11. Exercises
1. /e with a lookup. A Perl script renames clocks with s/\b(clk_\w+)/$map{$1} \/\/ $1/ge. Port it to Python, keeping unknown names unchanged, and write the three-line test that would have caught a port that replaced unknown names with None.
2. Count with tr in a condition. next if ($line =~ tr/,//) != 3; skips CSV rows that do not have exactly four fields. Port it. Then explain how Chapter 28.6's split difference (trailing empty fields) would have made a split-based port disagree with this one on a,b,,.
3. Extend the lexer. Add escaped identifiers (\bus[3] in Verilog: a backslash, any non-space characters, then a space) to both lexers. Test both on a line containing one, and on a line where the terminating space is missing. Do the two lexers still agree?
4. The stale $1 audit. Search a Perl script from your flow for matches whose result is not tested (a match statement on its own line, followed by a use of $1). For each, decide whether a failed match is possible, and what the script does when it happens.
5. Replacement safety. Find every s/// in a Perl script whose replacement interpolates a variable. Write the Python port of each with a function replacement, and a test with a value containing \1 and \n.
12. What To Remember
- The engines mostly agree (Chapter 16.8); the operators around them are where porting takes work.
/ebecomes a function, and the function must format numbers the way Perl did:500ps, not500.0ps.tr///is not a regex. Usecount,str.translate, or an escaped character class. Measured:re.sub('.', '_')erased a library name.- A
\Glexer becomes apattern.match(s, pos)loop. Measured:finditerskipped a corrupt character and reported a clean parse. - Named groups become
(?P<name>...)andm["name"]; the copied syntax fails loudly in Python 3.13. - There is no
$_in Python, and no stale$1. Measured: Perl reused the previous corner for a malformed header. - Never pass data as a
re.subreplacement string; use a function. Measured:C:\newgained a newline. re.matchanchors; Perl's match does not. Usere.search.findallchanges shape with the group count;\zis\Zbefore Python 3.14.- Check every pattern over a corpus of line variants. Measured: a stripped
re.matchport gained one endpoint and lost one slack, and the sample reports showed neither.
Chapter 28.8 turns the last three chapters into a single gate: run the old and the new implementation over the same corpus and compare everything they produce, byte for byte, including the exit status and STDERR, before anything is switched over.
Where this fits
Part of the Perl curriculum.
