DEV Community

Cover image for I Ported a Python Library to Rust. All 1,059 Original Tests Passed — and It Was Still Broken.
Shivharakh
Shivharakh

Posted on • Originally published at Medium

I Ported a Python Library to Rust. All 1,059 Original Tests Passed — and It Was Still Broken.

The rule for this hackathon is blunt: take the original project's test suite, don't touch it, make it pass against your rewrite. One edited test file is an automatic zero.

Mine passed. 1,059 tests, green, on a file I never opened. The port was still wrong, and the thing that found it wasn't a test — it was 500,000 randomly generated price strings run through both implementations side by side.

Key takeaways

  • 1,059 passed, 134 xfailed from upstream's own suite, byte-for-byte unmodified — SHA-256 verified on every CI run, and one commit in the entire repository history for that directory
  • 500,000 differential fuzz cases in 100.4 seconds, zero divergences, seed 20260803, replayable (fuzz/log.txt)
  • Zero unsafeforbid(unsafe_code) makes it a compile error, not a promise
  • Around 4× throughput, and around 13× startup for a native Rust caller (about 3× from Python) — deliberately imprecise, because the measurement is noisier than a clean multiple would suggest

What this is

price-parser is Scrapinghub's Python library for pulling a price and a currency out of scraped text — "US$ 1,234.56" in, 1234.56 and "US$" out. Small library, unglamorous job, used in a lot of scrapers.

I picked it deliberately. Almost all of its behaviour lives in two places where Python and Rust quietly disagree: regular expressions and Decimal. That makes it a bad afternoon project and a good test of the actual question, which isn't "can I write Rust" — it's "can I reproduce someone else's exact behaviour, including the parts they probably never intended?"

Track D (Python → Rust), Port Mortem / Code Resurrection 2026.

The suite is frozen, and it passed

"Don't touch it" is enforced, not trusted. The original test file is hashed at kickoff, and the hash is checked on every push:

$ python tools/verify_hashes.py
OK: 1 original test file(s) verified unmodified
  92c72582cca6b9d0201782ddc2665538d9c30602619a4bc06c5afc7fd5f17966  test_price_parsing.py
Enter fullscreen mode Exit fullscreen mode

The history says the same thing from a different direction:

$ git log --oneline -- tests/original/
0118b95 test: vendor original suite with SHA-256 manifest
Enter fullscreen mode Exit fullscreen mode

One commit. Added once, never edited — not "I only changed the imports."

$ pytest tests/original -q
1059 passed, 134 xfailed in 23.87s
Enter fullscreen mode Exit fullscreen mode

The 134 xfails are upstream's own, marked inside that same hashed file — there is no conftest.py in this repository to add them, and they're strict=True, so if one of them unexpectedly passed here, pytest would report it as a failure. They aren't a place to hide anything.

The bug 1,059 tests could not find

That green line is where a port is easy to declare finished. Mine sat there for about a day. Then the differential fuzzer ran — generate a price string, feed the same bytes to upstream Python and to the Rust, compare every field — and found it on the first real run.

Decimal("٥") is 5. Python's Decimal accepts any Unicode decimal digit. rust_decimal accepts ASCII and nothing else.

The nasty part is where the divergence sat. Both regex engines match \p{Nd} for \d, so extraction agreed perfectly — the port found the digits, correctly, every time. The failure was entirely in the conversion afterwards. No exception, no error, no warning. A price written in Arabic-Indic, Devanagari or Bengali numerals just came back with no amount at all.

Upstream's test corpus is scraped Western storefronts. It is effectively all ASCII. There is no test among those 1,059 that could have caught this, and there never would have been — not because the suite is bad, but because a test suite can only cover inputs somebody thought of.

The fix leans on a fact about Unicode: every character in general category Nd sits in a contiguous run of ten starting at its own script's zero. So you don't need a table of digits, only the 68 run starts — generated from the Unicode data, not typed by hand — and the value falls out by subtraction.

/// Code point of the zero character for each decimal-digit run, ascending.
/// Unicode 15.0.0, 68 runs. Generated by tools/gen_unicode_digits.py.
const DECIMAL_RUN_STARTS: [u32; 68] = [
    0x00030, // DIGIT ZERO
    0x00660, // ARABIC-INDIC DIGIT ZERO
    0x006f0, // EXTENDED ARABIC-INDIC DIGIT ZERO
    0x00966, // DEVANAGARI DIGIT ZERO
    0x009e6, // BENGALI DIGIT ZERO
    // ... 63 more
];

pub fn decimal_digit_value(c: char) -> Option<u32> {
    let cp = c as u32;
    // ASCII is overwhelmingly the common case; skip the search for it.
    if c.is_ascii_digit() {
        return Some(cp - u32::from(b'0'));
    }
    let index = DECIMAL_RUN_STARTS.partition_point(|&start| start <= cp);
    let start = DECIMAL_RUN_STARTS.get(index.checked_sub(1)?)?;
    let value = cp - start;
    (value < 10).then_some(value)
}
Enter fullscreen mode Exit fullscreen mode

amount_text still returns the original digits untouched, because that is what upstream does. Only the numeric conversion folds.

Four ways Python and Rust quietly disagree

These are the dangerous ones. Each compiles, survives a careful reading, and is wrong.

1. len() counts characters. str::len() counts bytes.

Upstream sorts currency symbols by length, longest first, so US$ is tried before $. Port that with Rust's .len() and — one character, three bytes — gets ranked alongside US$, silently reordering the alternation and matching the wrong currency. The fix is .chars().count(), and nothing about the code looks different.

2. Python's regex \s matches U+001CU+001F. Rust's doesn't.

Rust's \s is the Unicode White_Space property, which excludes the file, group, record and unit separators. Upstream normalises "1\x1c234" to "1 234". The character class had to be widened to [\s\x{1c}-\x{1f}]. str.strip() has exactly the same gap, so parse_number strips with a predicate matching Python's notion of whitespace rather than Rust's.

I verified this by probing CPython rather than trusting the docs, and I'd recommend that habit to anyone doing a port.

3. Python's $ also matches before a single trailing newline.

Rust's $ matches only at end-of-haystack. Upstream returns '.' as the decimal separator for "12.99\n"; a direct translation returns None. One trailing newline gets stripped before matching — exactly one, and trailing spaces are left alone, because "12.99 " gives None on both sides.

4. Thirty-two currency symbols carry an invisible U+200F.

Right-to-left marks on Arabic currency symbols: 16 in the national-symbol table, 16 more in the safe list. A generator that dropped them would look perfectly correct on inspection while failing to match real input. They're escaped as \u{…} in the generated source, and a test pins the count at 16 so a silent loss breaks the build instead of shipping.

There's a fifth that isn't a language difference but bit just as hard: two of upstream's three currency lists are built with list({…}) over a set. I ran the generator three times and got three different orderings. Emitting that directly would have produced a different file on every run, so both are sorted — safe here, because order only matters between candidates of different lengths, and length precedence is applied separately.

The regex Rust cannot express

Upstream's euro-as-decimal-separator pattern contains this:

\d(?(1)\d|\d*?)
Enter fullscreen mode Exit fullscreen mode

(?(1)yes|no) is a conditional group: if group 1 participated, match this, otherwise match that. Rust's regex crate has no conditionals — and no lookaround, and no backreferences. That's a deliberate design choice, not a gap; it's what buys the linear-time guarantee.

It also doesn't need one here. A conditional has exactly two outcomes, so the pattern splits into two, tried in the order the engine itself would try them:

/// The *yes* arm. `(\s*?)?` participates -- matching zero or more
/// whitespace -- so exactly two digits must follow.
fn euro_participating_regex() -> &'static Regex {
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"[\d\s.,']*?\d\s*?€\s*?\d\d(?:$|[^\d])")
            .expect("euro participating pattern must compile")
    });
    &RE
}

/// The *no* arm. Reached only by backtracking, when the arm above fails.
/// Skipping the group consumes no whitespace, so a digit must follow the
/// euro sign immediately -- and the run is lazy and unbounded, not fixed at two.
fn euro_skipped_regex() -> &'static Regex {
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"[\d\s.,']*?\d\s*?€\d\d*?(?:$|[^\d])")
            .expect("euro skipped pattern must compile")
    });
    &RE
}
Enter fullscreen mode Exit fullscreen mode

The ordering rule is the part that's easy to get wrong, and I got it wrong first: the leftmost starting position always wins, and arm order only decides ties. $|US$ and US$|$ both yield "US$" on "US$100", because at index 0 only US$ can match at all. A failing test corrected me.
Match that and the behaviour lines up exactly — "12€345" matches the skipped arm with three digits, while "12€ 345" matches neither and falls through to the ordinary rule.

About that PyO3 layer

Worth being direct about, since it's the first thing anyone reading the repo will notice.
The core crate has no PyO3 in it at all. parse_price is ordinary Rust, and a Rust caller never touches Python. The CPython extension module is an optional feature — it exists so upstream's suite can run against this code unmodified, which is the whole scoring criterion.

That's also why the unsafe story has an asterisk:

// Unsafe is forbidden outright in the pure-Rust build. With the `python`
// feature it must be permitted, since PyO3's macro expansion relies on it at
// the FFI boundary -- but no hand-written unsafe exists in this crate.
#![cfg_attr(not(feature = "python"), forbid(unsafe_code))]
Enter fullscreen mode Exit fullscreen mode

forbid rather than deny is the point: it can't be overridden by an inner allow, so this is a compile error, not a lint someone switches off later. And the differential fuzzer doesn't link Python either — it spawns real CPython as a separate process and compares across the process boundary.

The benchmark that lied to me twice

Neither lie was caught by a test. Both were caught by asking whether a number could possibly be true.

First lie: the build profile. maturin develop defaults to a debug build, which is roughly twenty times slower. The first benchmark run compared a debug extension against a release binary and concluded the port was five times slower than the Python it replaces. The module now reports its own profile and the benchmark refuses to run against debug.

Second lie: peak memory, twice, on two platforms. On Windows a ctypes call returned a constant ~3.4 MiB no matter the workload. I didn't take that on faith — I made a child process allocate 200 MB and watched the reported figure not move. On Linux,
getrusage(RUSAGE_CHILDREN).ru_maxrss returned an identical 393 MiB for all three implementations, because it's a high-water mark across every child the process has ever reaped — it was reporting an earlier cargo build, forever. It reads like a real measurement and is nothing of the kind.
Linux now samples /proc/<pid>/status VmHWM while the child is alive. Windows reports null, because publishing nothing beats publishing a number you know is wrong.

Both broken versions returned a constant. That's the tell, and it's the one thing here worth stealing.

The numbers that survived:

Python Port (from Python) Port (native Rust)
per parse 21.98 µs 4.56 µs 4.65 µs
parses/sec 45,498 219,261 215,258
startup 324.3 ms 108.1 ms 25.3 ms
p50 18.30 µs 4.50 µs 3.50 µs
p99 70.00 µs 10.60 µs 9.50 µs
p99.9 586.10 µs 59.80 µs 65.90 µs
max 16,499 µs 881 µs 1,515 µs

Read the bottom two rows: native Rust loses to the FFI path that wraps it. That's impossible as a real result — a wrapper can't beat the thing it wraps — which is exactly why those rows stay in. It means the two Rust paths can't be told apart on this hardware, and the honest headline is around 4×, not 4.82×.

Watch it live

The frozen suite running, the hash check, the fuzz log, and the zero unsafe claim demonstrated rather than asserted:

All the numbers in one place

  • 1,059 passed, 134 xfailed — upstream's suite, unmodified and hash-verified
  • 500,000 fuzz cases, 100.4 s, 4,982 per second, zero divergences — seed 20260803
  • 390,723 of 500,000 generated cases carried a parseable amount
  • 1,178 price strings in the benchmark corpus, extracted from the frozen suite with ast
  • 68 Unicode Nd run starts · 32 symbols carrying an invisible U+200F
  • Zero unsafe, compiler-enforced
  • 34 documented decisions, including the two where I was wrong and Python settled it
  • No bug claimed in the original. Differential testing finds places where I diverge from upstream, not places upstream is wrong. Every divergence found was mine. I looked, and I'm not going to invent one.

Go poke at it yourself

git clone https://github.com/ShivharakhYadav/price-parser-rs
cd price-parser-rs
docker build -t price-parser-rs .
docker run --rm price-parser-rs
Enter fullscreen mode Exit fullscreen mode

No toolchain of your own required. That last command verifies the test-file hashes, runs the Rust tests, runs the original Python suite, and then verifies the hashes a second time — so a suite that had been quietly edited somewhere in the middle couldn't produce a green run either side of it.

Repo: github.com/ShivharakhYadav/price-parser-rs

Every number above is a committed file — fuzz/log.txt, bench/results.json, and DECISIONS.md, which is 34 entries and not a highlight reel.

Thanks for reading. 🦀

Top comments (0)