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
-
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.
-
Sentinel values simplify comparison — using
math.MinIntandmath.MaxIntlets the comparison function be a clean sequence ofcmp.Comparecalls without branches. -
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.
-
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.