334 lines
6.5 KiB
Go
334 lines
6.5 KiB
Go
/*
|
|
©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
|
|
}
|