/* ©AngelaMos | 2026 version.go PEP 440 version parsing, comparison, and bump classification Implements the full PEP 440 spec: epoch, release segments, pre-release (alpha/beta/rc), post-release, dev releases, and local identifiers. The Compare method follows PEP 440 ordering rules, including edge cases around dev-only suffixes, implicit trailing zeros, and epoch precedence. Key exports: Version - parsed version struct with all optional components ChangeKind - Patch, Minor, or Major bump classification ParseVersion - parses a raw PEP 440 string into Version LatestStable - finds the highest stable version from a list of strings ClassifyChange - determines the bump magnitude between two versions Connects to: update.go - calls ParseVersion, LatestStable, ClassifyChange output.go - uses ChangeKind constants for update color selection */ package pypi import ( "cmp" "errors" "fmt" "math" "regexp" "strconv" "strings" ) // ErrInvalidVersion indicates a string that does not conform to PEP 440 var ErrInvalidVersion = errors.New("invalid PEP 440 version") // ChangeKind classifies a version bump by semver magnitude type ChangeKind int const ( Patch ChangeKind = iota + 1 Minor Major ) // String returns the lowercase name of the change kind func (c ChangeKind) String() string { switch c { case Patch: return "patch" case Minor: return "minor" case Major: return "major" default: return "unknown" } } // Version represents a parsed PEP 440 version with all optional components type Version struct { Raw string Epoch int Release []int PreKind string PreNum int Post int Dev int Local string } var versionRe = regexp.MustCompile( `(?i)^v?` + `(?:(\d+)!)?` + `(\d+(?:\.\d+)*)` + `(?:[-_.]?(alpha|a|beta|b|preview|pre|c|rc)[-_.]?(\d*))?` + `(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))?` + `(?:[-_.]?(dev)[-_.]?(\d*))?` + `(?:\+([a-z0-9](?:[a-z0-9._-]*[a-z0-9])?))?$`, ) // ParseVersion parses a PEP 440 version string into its structured components func ParseVersion(s string) (Version, error) { normalized := strings.ToLower(strings.TrimSpace(s)) m := versionRe.FindStringSubmatch(normalized) if m == nil { return Version{}, fmt.Errorf("%w: %q", ErrInvalidVersion, s) } v := Version{ Raw: s, Post: -1, Dev: -1, } if m[1] != "" { v.Epoch = mustAtoi(m[1]) } for _, seg := range strings.Split(m[2], ".") { v.Release = append(v.Release, mustAtoi(seg)) } if m[3] != "" { v.PreKind = normalizePreKind(m[3]) v.PreNum = optionalAtoi(m[4]) } switch { case m[5] != "": v.Post = optionalAtoi(m[6]) case m[7] != "": v.Post = mustAtoi(m[7]) } if m[8] != "" { v.Dev = optionalAtoi(m[9]) } v.Local = m[10] return v, nil } // String returns the canonical PEP 440 representation func (v Version) String() string { var b strings.Builder if v.Epoch != 0 { fmt.Fprintf(&b, "%d!", v.Epoch) } for i, n := range v.Release { if i > 0 { b.WriteByte('.') } fmt.Fprintf(&b, "%d", n) } if v.PreKind != "" { fmt.Fprintf(&b, "%s%d", v.PreKind, v.PreNum) } if v.Post >= 0 { fmt.Fprintf(&b, ".post%d", v.Post) } if v.Dev >= 0 { fmt.Fprintf(&b, ".dev%d", v.Dev) } if v.Local != "" { fmt.Fprintf(&b, "+%s", v.Local) } return b.String() } // IsStable reports whether the version is a stable (non-pre, non-dev) release func (v Version) IsStable() bool { return v.PreKind == "" && v.Dev < 0 } // Compare returns -1, 0, or 1 following PEP 440 ordering rules. // // The ordering within a given release segment is: // // 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 func (v Version) Compare(other Version) int { if c := cmp.Compare(v.Epoch, other.Epoch); c != 0 { return c } if c := compareRelease(v.Release, other.Release); c != 0 { return c } vpt, vpn := preKey(v) opt, opn := preKey(other) if c := cmp.Compare(vpt, opt); c != 0 { return c } if c := cmp.Compare(vpn, opn); c != 0 { return c } if c := cmp.Compare(postKey(v), postKey(other)); c != 0 { return c } return cmp.Compare(devKey(v), devKey(other)) } // ClassifyChange determines whether moving from v to other is a major, minor, // or patch bump based on their release segments func ClassifyChange(from, to Version) ChangeKind { fromR := padRelease(from.Release, 3) toR := padRelease(to.Release, 3) if fromR[0] != toR[0] { return Major } if fromR[1] != toR[1] { return Minor } return Patch } // LatestStable finds the highest stable version from a list of version strings func LatestStable(versions []string) (Version, error) { var latest Version var found bool for _, raw := range versions { v, err := ParseVersion(raw) if err != nil { continue } if !v.IsStable() { continue } if !found || v.Compare(latest) > 0 { latest = v found = true } } if !found { return Version{}, errors.New("no stable version found") } return latest, nil } func compareRelease(a, b []int) int { maxLen := max(len(a), len(b)) for i := range maxLen { av, bv := 0, 0 if i < len(a) { av = a[i] } if i < len(b) { bv = b[i] } if c := cmp.Compare(av, bv); c != 0 { return c } } return 0 } // preKey produces a sortable tuple for the pre-release component. // // Versions with only a dev suffix (no pre, no post) sort before all // pre-releases. Pre-releases sort by kind (a < b < rc) then number. // Final and post-releases sort after all pre-releases. func preKey(v Version) (int, int) { hasPre := v.PreKind != "" hasDev := v.Dev >= 0 hasPost := v.Post >= 0 switch { case !hasPre && !hasPost && hasDev: return math.MinInt, math.MinInt case hasPre: return preKindRank(v.PreKind), v.PreNum default: return math.MaxInt, math.MaxInt } } func postKey(v Version) int { if v.Post < 0 { return math.MinInt } return v.Post } func devKey(v Version) int { if v.Dev < 0 { return math.MaxInt } return v.Dev } func preKindRank(kind string) int { switch kind { case "a": return 0 case "b": return 1 case "rc": return 2 default: return -1 } } func normalizePreKind(s string) string { switch strings.ToLower(s) { case "a", "alpha": return "a" case "b", "beta": return "b" case "rc", "c", "pre", "preview": return "rc" default: return s } } func padRelease(r []int, n int) []int { if len(r) >= n { return r[:n] } padded := make([]int, n) copy(padded, r) return padded } func mustAtoi(s string) int { n, _ := strconv.Atoi(s) //nolint:errcheck return n } func optionalAtoi(s string) int { if s == "" { return 0 } n, _ := strconv.Atoi(s) //nolint:errcheck return n }