Cybersecurity-Projects/PROJECTS/simple-vulnerability-scanner/learn/PEP440-VERSION-PARSING.md

4.7 KiB

PEP 440: Python Version Parsing from Scratch

This document explains how angela parses Python version strings — one of the hardest parts of the project. If you're building tools that interact with the Python ecosystem, understanding PEP 440 is non-negotiable.


The problem

Python versions are not semantic versioning. They follow PEP 440, which allows components that semver doesn't:

[epoch!] release [pre-release] [.postN] [.devN] [+local]

Real examples from PyPI:

Version What it means
1.2.3 Normal release
2!1.0 Epoch 2 — beats any version with epoch 0 or 1
1.0a1 Alpha pre-release
1.0b2 Beta pre-release
1.0rc1 Release candidate
1.0.dev3 Development snapshot
1.0.post1 Post-release (stable — fixes to docs or metadata)
1.0+ubuntu1 Local version label (never on PyPI)

Why this matters for dependency tools

If you naively compare version strings, 1.0a1 looks "newer" than 1.0 because a > `` in ASCII. But in Python's world:

1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1

A tool that doesn't understand this will upgrade users to unstable pre-releases. That's why angela implements the full PEP 440 parser.


How angela's parser works

The parser lives in internal/pypi/version.go. It uses a single compiled regex to extract all components in one pass:

(?i)^v?
(?:(\d+)!)?                          # epoch
(\d+(?:\.\d+)*)                      # release segments
(?:[-_.]?(alpha|a|beta|b|...|rc)[-_.]?(\d*))?  # pre-release
(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))?     # post-release
(?:[-_.]?(dev)[-_.]?(\d*))?          # dev release
(?:\+([a-z0-9]...))?$                # local version

Each named section maps to a field in the Version struct:

type Version struct {
    Raw     string
    Epoch   int
    Release []int    // [1, 2, 3] for "1.2.3"
    PreKind string   // "a", "b", or "rc"
    PreNum  int
    Post    int      // -1 means absent
    Dev     int      // -1 means absent
    Local   string
}

The sentinel value -1 for Post and Dev is critical — it distinguishes "not present" from "present with value 0". Both 1.0.post0 and 1.0.post are valid (implicit zero), but they're different from 1.0 (no post-release at all).


Normalization edge cases

PEP 440 allows multiple spellings that all mean the same thing:

Input Normalized
alpha a
beta b
c, pre, preview rc
rev, r post
1.0-1 1.0.post1 (implicit post via dash-number)
1.0a 1.0a0 (implicit zero)
v1.0 1.0 (strip leading v)

The parser handles all of these by normalizing during extraction.


Version comparison: the tuple trick

Python's packaging library compares versions by converting them into tuples that sort naturally. angela uses the same idea:

func (v Version) Compare(other Version) int {
    // 1. Compare epochs
    // 2. Compare release segments (with implicit zero extension)
    // 3. Compare pre-release (using sentinel values)
    // 4. Compare post-release
    // 5. Compare dev-release
}

The key insight is the sentinel values for sorting:

Component state Sort key
Dev-only (no pre, no post) MinInt, MinInt — sorts before everything
Pre-release a1 0, 1 — alpha rank 0
Pre-release b2 1, 2 — beta rank 1
Pre-release rc1 2, 1 — rc rank 2
Final release (no pre) MaxInt, MaxInt — sorts after all pre-releases

This produces the correct PEP 440 ordering without any special-case branching in the comparison loop.


Stability detection

A version is stable if it has no pre-release tag AND no dev tag:

func (v Version) IsStable() bool {
    return v.PreKind == "" && v.Dev < 0
}

Post-releases are stable — 1.0.post1 is a documentation fix or metadata correction, not an unstable build.


What to learn from this

  1. Regex can be the right tool — a single compiled regex handles all PEP 440 forms in one pass. The alternative (hand-written state machine) would be 3x the code for no real benefit.

  2. Sentinel values simplify comparison — using math.MinInt and math.MaxInt lets the comparison function be a clean sequence of cmp.Compare calls without branches.

  3. Test-driven development — the version parser has 67 test cases across 10 functions. When implementing something spec-driven like PEP 440, write the test cases first from the spec, then make them pass.

  4. Normalization at parse time — by normalizing spellings during parsing (alpha→a, preview→rc), the rest of the codebase never has to think about variant forms.