DEV Community

Pranav Gupta
Pranav Gupta

Posted on Edited on

I Ported python-semanticversion to Rust in 72 Hours: What Broke, How I Proved Parity, and the 6-Hour Edge Case

Port Mortem 2026 Write-Up Submission · Track D (Python → Rust) · Solo build by Rahul Gupta

When participating in Port Mortem 2026 (a 72-hour hackathon to resurrect and rewrite dead or legacy libraries in memory-safe languages), I picked python-semanticversion for Track D (Python → Rust).

python-semanticversion is a foundational Python package implementing full SemVer 2.0 parsing, comparison, and npm-style range specification (SimpleSpec, NpmSpec, and LegacySpec).

Below is the honest post-mortem of how I ported it to 100% safe Rust (semanticversion-rs), proved behavioral equivalence, fixed subtle edge-case bugs, survived a 6-hour AST set-equality nightmare, and what I’d do differently next time.


1. What I Picked & The Strategy

The goal wasn't just to write a Rust library that looked like SemVer parsing—it was to create a drop-in replacement that could execute the original Python test suite unmodified while delivering multi-fold speedups.

Core Stack & Architecture

  • Language: 100% Safe Rust (grep -rn unsafe src/ returns 0 results).
  • Extension Bridge: PyO3 + Maturin producing a native Python extension named semantic_version.
  • Parsing: regex crate for version component extraction + custom recursive-descent parser for complex range grammar trees (Clause::AnyOf, Clause::AllOf, Clause::Range).

2. How I Proved Behavioral Equivalence

Claiming a port is "100% compatible" without proof is easy. To ensure zero behavioral divergence, I used a three-tier verification pipeline:

Tier 1: Zero-Edit Pytest Verification

Instead of porting tests to Rust assertions, I compiled the Rust engine as a PyO3 native module (semantic_version). Running pytest tests/original executes the original, unmodified test suite directly against compiled Rust code.

make # maturin develop && pytest tests/original -q
# Result: 54 passed, 16 skipped ("Django not installed"), 586 subtests green
Enter fullscreen mode Exit fullscreen mode

Zero lines of Python test code were modified.

Tier 2: 24,500-Pair Deterministic Differential Fuzzing

I built a seed-based differential oracle (fuzz/differential/driver.py) that generated 24,500 random Version, SimpleSpec, and NpmSpec inputs across both the original Python reference environment and the PyO3 Rust extension.

Field-by-field verification evaluated major, minor, patch, prerelease, build, str, repr, partial, valid, compare, and matches:

  • Hard Behavioral Divergences: 0
  • Soft Wording Differences (error message text formatting): 1,619 (expected minor string formatting differences).

Tier 3: 2.55 Million Run Crash Fuzzing

Using cargo-fuzz / libFuzzer targeting raw byte slices fed into all entry points:

  • Total Executions: 2,554,822 runs in 61 seconds (~41,882 exec/sec).
  • Panics / Crashes: 0

3. What Broke: 8 Latent & Port Bugs Caught by Fuzzing

Fuzzing didn't just verify parity; it caught 8 critical bugs during development:

  1. u64 Arithmetic Overflow Panics: In expressions like patch + 1, a version like 1.2.18446744073709551615 caused a panic.
    • Fix: Replaced all 18 addition sites across version.rs, simple_spec.rs, and npm_spec.rs with saturating_add(1).
  2. Empty Prerelease Identifiers (1.2.3-..): Python rejected empty identifier tokens (..), whereas Rust initially accepted them.
    • Fix: Enforced strict token validation in parse_prerelease_identifiers.
  3. Empty || Group Substitution: Python's NpmSpec parser turns empty || clauses into >=0.0.0, while Rust initially evaluated them as Clause::Never.
    • Fix: Added zero-length group fallback logic to AnyOf([AllOf([>=0.0.0])]).
  4. Wildcard Rejection on ~* / ^*: Python explicitly rejects caret/tilde modifiers combined with wildcard majors.
    • Fix: Inserted a validation gate prior to caret/tilde range expansion.
  5. Hyphen Range Bound Fences: 1.0.0-rc.1 - 2.0.0 used > for fence lower-bounds instead of >= major.minor.0.
    • Fix: Passed is_upper_bound context into expand_prerelease_or_hyphen.
  6. Equality vs. Ordering Semantics: SemVer 2.0 dictates that 1.0.0 and 1.0.0+build compare equal for precedence sorting, but are not equal for string equality.
    • Fix: Custom PartialEq comparing all fields (including build), while Ord / PartialOrd uses precedence keys ignoring build metadata.

4. The Edge Case That Ate 6 Hours: Python frozenset ASTs vs. Rust Vec<Clause>

The single hardest bug encountered during the 72 hours involved AST node equality in NpmSpec.

In Python's semantic_version, Clause nodes (AllOf, AnyOf) store child clauses in frozenset instances (base.py:745, 808). As a result, AST equality in Python is order-insensitive and auto-deduplicating:

$$\text{AllOf}([A, B]) == \text{AllOf}([B, A])$$

In Rust, however, AllOf(Vec<Clause>) uses standard vector comparison.

When parsing caret ranges like ^1.2.3, Python emitted AllOf([LT 2.0.0, GTE 1.2.3]), while Rust's parser generated AllOf([GTE 1.2.3, LT 2.0.0]).

When running pytest, assertions like:

assert NpmSpec('^1.2.3').clause == NpmSpec('>=1.2.3 <2.0.0').clause
Enter fullscreen mode Exit fullscreen mode

failed miserably! The versions matched identically, but the underlying AST clause trees failed equality checks simply because child nodes were ordered differently in the vector.

The Fix

Instead of forcing Rust's internal parser to match Python's exact construction sequence for every grammar edge case, I implemented set-equality and dedup hashing inside PyO3's Clause binding layer (src/bindings.rs):

// Custom set-equality for PyO3 Clause objects to mirror frozenset semantics
fn clause_eq_python(a: &Clause, b: &Clause) -> bool {
    match (a, b) {
        (Clause::AllOf(a_nodes), Clause::AllOf(b_nodes)) |
        (Clause::AnyOf(a_nodes), Clause::AnyOf(b_nodes)) => {
            let set_a: HashSet<_> = a_nodes.iter().collect();
            let set_b: HashSet<_> = b_nodes.iter().collect();
            set_a == set_b
        }
        _ => a == b,
    }
}
Enter fullscreen mode Exit fullscreen mode

Resolving this unlocked 100% pass rates across test_spec.py without modifying native Rust performance.


5. The Decision I'd Take Back: PyO3 Tuple Allocations for Precedence Comparison (D17)

If I could redo one decision from the hackathon, it would be Decision D17: PyO3 Precedence Key Returns.

To implement Python's _cmp_precedence_key dunder attribute, the PyO3 binding layer constructs Python tuples on the fly:

// Rust binding allocating a PyTuple on every precedence key read:
(self.major, self.minor, self.patch, pre_tuple, build_tuple).into_py(py)
Enter fullscreen mode Exit fullscreen mode

The Performance Cost

Because Python's comparison operators (<, >, <=, >=) rely on comparing these tuple objects, comparison operations executed from Python through PyO3 incurred heap allocation overhead:

Benchmark Scenario Latency / Throughput Speedup vs Python
Native Rust precedence_lt 386 ns p50 (2.3 Million ops/sec) N/A (Pure Rust)
PyO3 _cmp_precedence_key Python tuple construction overhead 0.27× (Slower than Python)

While parsing and matching achieved 11× to 60× speedups, Python-to-Rust comparison throughput lagged due to object allocations at the boundary.

What I Should Have Done

Instead of delegating comparison logic back to Python tuple comparison, I should have implemented __richcmp__ natively in C/Rust using Version::cmp_precedence_key() directly, bypassing Python tuple creation entirely.


6. Final Benchmark Summary

Benchmarked on an idle Linux VM (Intel x86_64 @ 2.20GHz, 2 physical cores, Rust 1.96, Python 3.11):

Operation Rust (Native p50) Python Baseline Speedup / Impact
Version::parse 1.91 µs 21.0 µs 11× faster
SimpleSpec::parse 5.79 µs 34.7 µs 6× faster
NpmSpec::parse 11.51 µs 126.6 µs 11× faster
match_version (npm) 1.52 µs 91.2 µs 60× faster
Peak Memory RSS 12.5 MB 15.9 MB 21% memory reduction

Conclusion & Code

Porting dead or legacy code isn't just about translating lines of syntax—it's about honoring original behavioral semantics while eliminating memory safety risks and performance bottlenecks.

Special thanks to Hackathon Raptors for organizing Port Mortem 2026!

Top comments (0)