# 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](https://peps.python.org/pep-0440/), 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: ```go 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: ```go 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: ```go 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.