DEV Community

Pranav Gupta
Pranav Gupta

Posted on

I ported `python-semanticversion` to Rust in 72 hours

For Port Mortem 2026 (a 72-hour "resurrect dead code" hackathon, Track D: Python → Rust), I rewrote python-semanticversion — SemVer 2.0 parsing/comparison plus npm-style SimpleSpec / NpmSpec / LegacySpec range matching — as a from-scratch Rust port. Solo.

The result: the original, unmodified pytest suite passes against the Rust build54 passed, 16 skipped, 586 subtests, zero test edits — with zero unsafe, 0 fuzz divergences over 24,500 differential pairs, and 0 panics over 2.5M crash-fuzz runs.

This is the story of how, and the mistakes that nearly sank it.

The one constraint that shaped everything

The rules said the original test suite must pass unmodified, hashed at kickoff. That's a brutal constraint, and it's the best thing about the event — you can't fudge your way to green.

It forced the central design decision: I built a PyO3/maturin extension named semantic_version. In the test venv, import semantic_version resolves to my Rust code, so the original tests run byte-for-byte as written against the port. No shims, no test edits, no "adapted" suite. make does the whole thing in one command:

$ make
VIRTUAL_ENV=... maturin develop
pytest tests/original/ -q
54 passed, 16 skipped, 586 subtests passed in 0.37s
Enter fullscreen mode Exit fullscreen mode

(The 16 skips are the Django tests, which skip identically in the original baseline — "Django not installed". Parity, not exclusion.)

Method: ground truth first, port second

The fastest way to fail a port is to port your assumptions. Python's semantic_version is full of deliberate quirks, so before writing a line of Rust I probed the original and captured its exact behavior — AST shapes, match results, error strings — and treated that as the spec.

A few things the probes revealed that I would have gotten wrong:

  • __eq__ includes build metadata; ordering does not. Version("1.0.0+a") == Version("1.0.0+b") is False, but neither is < nor > the other. That violates Rust's Ord contract (a == b ⟺ cmp(a,b) == Equal), so I removed Ord from Version and exposed explicit precedence_lt/le/gt/ge helpers instead.
  • __ne__ compares raw tuples, not !eq — a Python-2-era relic where a partial and non-partial version can be eq yet ne.
  • Error messages are single-quoted (Invalid version string: 'garbage'), matching Python's %r.
  • Spec is just LegacySpec. One class, two names. The binding exposes one pyclass + an alias.

The hardest 40 lines: npm's prerelease OR-expansion

npm ranges with a prerelease bound don't expand to a simple interval. >=1.0.0-rc.1 <2.0.0 becomes a two-branch AnyOf:

AnyOf(
  AllOf(<1.0.1 [always],  >=1.0.0-rc.1 [same-patch]),  # prerelease branch
  AllOf(>=1.0.0 [same-patch], <2.0.0 [same-patch])     # release branch
)
Enter fullscreen mode Exit fullscreen mode

My first pass flattened this and silently diverged on ||-joined specs with a single-block group. The differential fuzzer is what caught it — more on that below.

The proof layer

Passing the suite is necessary but not sufficient; the suite only covers what the original authors thought to test. So I built a proof layer:

  • Differential fuzz: 49 seeds × 500 pairs = 24,500 random (version, spec) inputs run through the original Python and the Rust binding, compared on parse/match/compare/str/repr/hash. 0 hard divergences. (1,619 soft diffs are error-wording only, both sides raising ValueError — documented, not hidden.)
  • Crash fuzz: 2,554,822 libFuzzer runs over arbitrary bytes → 0 panics.
  • Zero unsafe. The whole port is safe Rust; grep -rn unsafe src/ is empty.
  • A 20-entry decision log (DECISIONS.md, D00–D19), every non-trivial divergence with Python behavior → Rust choice → rationale → tradeoff → test impact.

What the fuzzer caught (honesty section)

The differential fuzzer found 8 latent bugs — all in my port, none in the original. Including 18 u64 overflow-panic sites (Python has bignums; Rust doesn't) that I hardened with saturating_add, an empty-prerelease acceptance, ~*/^* wildcard gates, and the || empty-group case above.

I'm not claiming a "bug catcher" bonus: the original library was correct, and my job was to converge to it. But the fuzzer turning my own blind spots into a fix-list is exactly why differential testing is the only oracle that matters.

Benchmarks, with the boring parts included

On a hackathon cloud VM (16GB RAM 2 physical / 4 logical cores, not bare metal):

  • ~9× aggregate speedup, 60× on npm spec matching, ~11× on parsing
  • 21% lower peak RSS (12.5 MB vs 15.9 MB)
  • And the honest caveat: the PyO3 precedence_key path drags one aggregate number down (Python tuple overhead); native precedence runs at ~386 ns p50.

Throughput-only benchmarks are marketing. Distributions + confounders are engineering.

What I'd tell myself at hour 0

  1. Probe before you port. The original is the spec; your memory of it is not.
  2. Let a fuzzer argue with you. It will find the cases your tests never imagined.
  3. Honesty is a feature. Judges trust "94% and here's why" over "100%" that won't reproduce.
  4. An AI agent is a force multiplier only if a human gates every commit. Multi-model, single-writer, review-everything.

Try it

git clone https://github.com/rahulgupta0-dev/semanticversion-rs
cd semanticversion-rs && make   # builds + runs the ORIGINAL suite against Rust
Enter fullscreen mode Exit fullscreen mode

3,683 lines of safe Rust, 20 decisions, one command to believe it. Whether or not it places, it's the most rigorously verified thing I've ever shipped in 72 hours.

Top comments (0)