Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,46 @@ mistaken for a safe patch upgrade.

## [Unreleased]

### Added

- `version`: `Version.ReleaseKey()` and the `ReleaseKey` type, for consumers
that need to group or bracket versions by their **release** — the epoch and
the release segments with trailing zeros stripped — ignoring the
pre/post/dev/local suffix. `ReleaseKey.Compare` orders two keys, and
`ReleaseKey.String` renders the smallest version string carrying a key.

Until now the only way to reach a version's release from outside this
package was `BaseVersion()`, which renders the version back to text (a
`bytes.Buffer` plus one `math/big` decimal conversion per segment) and
leaves the caller to split the result apart again. `ReleaseKey` derives the
same answer from the parsed fields: **~16 ns and zero allocations, against
~220 ns and 10 allocations** for the render-and-split it replaces, measured
on `2024.10.31`. Comparing two packed keys is ~3 ns; ~8 ns when either side
did not pack.

⚠️ The ~16 ns is for a `Version` held in a local. The receiver is by value
and the method does not inline, so a caller ranging over a `[]Version` pays
the struct copy too — about 24 ns.

The ordering is exact at every magnitude — PEP 440 puts no ceiling on an
epoch or a release segment, and datestamped and calendar versions push real
segments high — so keys that do not fit the packed layout compare as
arbitrary-precision integers. It is pinned to pypa/packaging 26.2's own
release key (`Version._key[0:2]`) by a frozen fixture.

⚠️ `ReleaseKey` is deliberately coarser than the version order: `1.0`,
`1.0.0`, `1.00`, `1.0a1`, `1.0.post3.dev2` and `1.0+ubuntu1` all share one
key. It is also coarser at the zero value — the zero `ReleaseKey` compares
equal to the key of `"0"`, where `Version.Compare` sorts an uninitialized
`Version` strictly below every real version.

⚠️ **`ReleaseKey.String()` is not a bound on the group.** It renders the
shortest version string carrying the key, not the smallest version carrying
it: `1.0a1` and `1.0.dev0` carry the key of `"1"` and sort strictly *below*
`Parse("1")`, and `.postN` and `+local` sort above it without limit. And
because a `ReleaseKey` holds a slice it is not comparable with `==` and
cannot be a map key — sort by `Compare` and scan the equal runs.

## [0.6.0] - 2026-08-14

### Notes
Expand Down
93 changes: 67 additions & 26 deletions version/packed.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ const (
packedDevMax = 1<<packedDevBits - 1

// ⚠️ packedMaxSegments is NOT a tunable knob: the release-word assembly
// below is hand-unrolled for exactly six segments (three words of two),
// so raising this constant alone would admit 7+ segment versions while
// silently dropping everything past segs[5]. The guards below pin it.
// in packRelease is hand-unrolled for exactly six segments (three words
// of two), so raising this constant alone would admit 7+ segment versions
// while silently dropping everything past segs[5]. The guards below pin
// it.
packedMaxSegments = 6

// preClass values, in PEP 440 order.
Expand All @@ -114,7 +115,7 @@ var (
// The w3 fields fill the word exactly.
_ [64 - (packedPreClassShift + packedPreClassBits)]struct{}
_ [(packedPreClassShift + packedPreClassBits) - 64]struct{}
// The word assembly in packVersion is unrolled for exactly 6 segments.
// The word assembly in packRelease is unrolled for exactly 6 segments.
_ [packedMaxSegments - 6]struct{}
_ [6 - packedMaxSegments]struct{}
// Every preClass value fits its field.
Expand Down Expand Up @@ -161,6 +162,65 @@ func smallUint(b part.BigInt, limit uint64) (uint64, bool) {
return u, true
}

// releaseLen returns the number of release segments left after PEP 440's
// trailing-zero stripping, i.e. the length of the prefix cmpkey compares. It
// is the length alone rather than a subslice so that callers who want the
// segments and callers who only want the count share one definition of where
// the release ends.
//
// The zero test is big.Int.Sign, which is exact at every magnitude. Asking
// smallUint whether the segment is a small zero would answer the same thing
// for every input the grammar admits -- a segment too large to extract is
// certainly not zero -- but only by accident of the two conditions coinciding.
func releaseLen(release []part.BigInt) int {
n := len(release)
for n > 0 {
if bi := big.Int(release[n-1]); bi.Sign() != 0 {
break
}
n--
}
return n
}

// packRelease packs a whole release — all of its segments — into three words
// of six 32-bit fields, most significant segment first, or ok=false when it
// does not fit: more than packedMaxSegments segments after stripping, or any
// segment at or above 2^32.
//
// Trailing zeros are stripped first and the remainder zero-padded to the fixed
// width, which preserves order: cmpkey strips the same zeros, and comparing
// two stripped tuples lexicographically gives the same answer as comparing
// their zero-padded fixed-width forms.
//
// An EMPTY release packs, to three zero words -- the same key version "0"
// gets, which is the right answer for a release-only key (see ReleaseKey) but
// NOT for a whole-version key. packVersion therefore rejects an empty release
// itself, before calling this; see the comment there.
//
// ⚠️ This is the single definition of the release word layout, shared by
// packVersion and ReleaseKey. Two copies could drift, and a drifted copy would
// order versions consistently within itself while disagreeing with the other,
// which is exactly the kind of split that no single-encoding test can see.
func packRelease(release []part.BigInt) (w [3]uint64, ok bool) {
n := releaseLen(release)
if n > packedMaxSegments {
return w, false
}
var segs [packedMaxSegments]uint64
for i := 0; i < n; i++ {
v, ok := smallUint(release[i], packedSegMax)
if !ok {
return [3]uint64{}, false
}
segs[i] = v
}
w[0] = segs[0]<<packedSegBits | segs[1]
w[1] = segs[2]<<packedSegBits | segs[3]
w[2] = segs[4]<<packedSegBits | segs[5]
return w, true
}

// packVersion computes the packed comparison key for a parsed version, or
// ok=false when the version does not fit the packed layout and must use the
// general comparison path.
Expand All @@ -187,30 +247,11 @@ func packVersion(epoch part.BigInt, release []part.BigInt, pre, post, dev letter
return k, false
}

// Strip trailing zero segments; cmpkey does the same, and zero-padding
// the remainder to fixed width preserves the resulting order.
n := len(release)
for n > 0 {
if v, ok := smallUint(release[n-1], packedSegMax); ok && v == 0 {
n--
continue
}
break
}
if n > packedMaxSegments {
w, relOK := packRelease(release)
if !relOK {
return k, false
}
var segs [packedMaxSegments]uint64
for i := 0; i < n; i++ {
v, ok := smallUint(release[i], packedSegMax)
if !ok {
return k, false
}
segs[i] = v
}
k.w0 = segs[0]<<packedSegBits | segs[1]
k.w1 = segs[2]<<packedSegBits | segs[3]
k.w2 = segs[4]<<packedSegBits | segs[5]
k.w0, k.w1, k.w2 = w[0], w[1], w[2]

// Suffix word. The class substitutions mirror cmpkey exactly.
var preClass, preN uint64
Expand Down
221 changes: 221 additions & 0 deletions version/releasekey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT

package version

import (
"math/big"
"strings"

"github.com/rstudio/go-version/pkg/part"
)

// ReleaseKey is a version's position in PEP 440's RELEASE order: its epoch and
// its release segments with trailing zeros stripped, and nothing else.
//
// It is deliberately COARSER than the version order. 1.0, 1.0.0, 1.00,
// 1.0a1, 1.0.post3.dev2 and 1.0+ubuntu1 all carry the same ReleaseKey, because
// PEP 440 gives every one of them epoch 0 and, after stripping, the release
// tuple (1). A consumer that needs the full order wants Compare on the Version;
// this key exists for consumers that need to ORDER or PARTITION versions by
// release, where the pre/post/dev/local suffix must not participate.
//
// ⚠️ "Partition", not "index": a ReleaseKey is not comparable with == and so
// cannot be a map key. Use Compare — sort by it and scan the runs of equal
// keys. String() is a stable scalar form, but it allocates, which is the cost
// this type exists to avoid.
//
// # Why this is not just "read the release segments"
//
// The obvious way to get at a version's release from outside this package is
// BaseVersion(), which renders "1!3.4.5" — and rendering means a bytes.Buffer
// and one math/big decimal conversion per segment, followed by the caller
// splitting the result back apart. go-pyresolver's PEP 440 set algebra did
// exactly that, once per candidate version per containment test.
//
// A key derived from the parsed fields costs no rendering, no parsing and no
// allocation, and answers the same question: BenchmarkReleaseKeyVsBaseVersion-
// Split measures 16 ns and 0 allocations against 220 ns and 10 allocations on
// "2024.10.31". Deriving one costs more when the Version has to be copied out
// of a slice first (~24 ns), because the receiver is by value; see ReleaseKey().
//
// # Ordering
//
// Compare orders keys by epoch, then segment by segment, with the shorter
// release smaller when it is a prefix of the longer. That is PEP 440's release
// ordering, verified against pypa/packaging 26.2's comparison key — its second
// tuple element is precisely the stripped release this type holds.
//
// The order is exact at every magnitude. PEP 440 puts no ceiling on an epoch
// or a release segment (the grammar is `[0-9]+`), and datestamped and calendar
// versions push real segments high, so the general path compares arbitrary-
// precision integers rather than machine words.
//
// # Zero value
//
// The zero ReleaseKey is the key of the zero Version, whose release is empty.
// It compares EQUAL to the key of "0" and "0.0", because those strip to an
// empty release too. This is a deliberate difference from Version.Compare,
// which sorts an uninitialized Version strictly below every real version: that
// distinction lives in the full version order, not in the release grouping,
// and the release grouping genuinely cannot see it.
//
// # Aliasing
//
// A ReleaseKey shares the Version's release segments rather than copying them.
// It never writes to them, its capacity is clipped so an append cannot either,
// and reading a math/big value does not mutate it — so a key is safe to share
// across goroutines exactly as the Version it came from is.
//
// # ⚠️ A key is not a bound on its own group
//
// String() renders the shortest version string carrying the key, but that
// version is NOT the least version in the group: 1.0a1 and 1.0.dev0 carry the
// key of "1" and sort strictly BELOW it. Neither is there a greatest — .postN
// and +local extend the group upward without limit. So
//
// lo, _ := Parse(k.String()) // ⚠️ NOT a lower bound on k's group
//
// silently excludes every pre-release and dev release of that release. A
// consumer bracketing a release group needs positions between versions, which
// is a different thing from a version; see go-pyresolver's pep440set for one
// treatment of it.
//
// ReleaseKey is NOT comparable with ==; it holds a slice. Use Compare.
type ReleaseKey struct {
// w is the packed encoding of the stripped release, valid when packed is
// true: six 32-bit segment fields across three words, most significant
// segment first, laid out by packRelease. Two packed keys are ordered by
// three integer comparisons.
w [3]uint64
packed bool

// epoch and release are the general path, used whenever either side of a
// comparison did not pack. release is the stripped prefix of the Version's
// own release slice.
epoch part.BigInt
release []part.BigInt
}

// ReleaseKey returns v's position in the release order. See ReleaseKey.
//
// It allocates nothing: the epoch is a by-value copy of a math/big header and
// the release is a subslice of v's own.
//
// ⚠️ The receiver is by VALUE, so calling this copies the whole Version — a
// large struct, and this method does not inline. That is deliberate, for
// consistency with every other Version method and because a by-value receiver
// cannot write through to the caller's Version; but it means a caller ranging
// over a []Version pays ~50% more per key than one holding a single Version in
// a local. compareVersions takes pointers precisely to dodge that copy in
// sort's inner loop, and a caller with the same problem can hoist the Version
// out of the loop once.
func (v Version) ReleaseKey() ReleaseKey {
n := releaseLen(v.release)
k := ReleaseKey{
epoch: v.epoch,
// Three-index slicing clips the capacity to n. Without it the key
// would carry the stripped trailing zeros as spare capacity, and an
// append by any future holder of the key would write THROUGH into the
// Version's own release segments. That is the same aliasing hazard
// that made go-version's Parts.Padding a data race between two copies
// of one Version; see padParts.
release: v.release[:n:n],
}
// Only an epoch of zero can pack: the packed words hold release segments
// and nothing else, so a nonzero epoch has nowhere to go. Reusing v.packed
// when v.packable would save this call, but v.packable is false for
// reasons that have nothing to do with the release (a local label, a large
// dev number), and buying a few nanoseconds once per key with a standing
// assumption about another key's layout is a bad trade.
//
// k.release rather than v.release: it is the same segments already
// stripped, so packRelease's own strip finds nothing left to do instead of
// rescanning the trailing zeros.
if bi := big.Int(v.epoch); bi.Sign() == 0 {
k.w, k.packed = packRelease(k.release)
}
return k
}

// Compare reports whether k is before (-1), at (0) or after (+1) o in the
// release order.
//
// Equality here means the two versions share a release group, NOT that they are
// the same version: ReleaseKey ignores the pre/post/dev/local suffix entirely.
func (k ReleaseKey) Compare(o ReleaseKey) int {
if k.packed && o.packed {
switch {
case k.w[0] != o.w[0]:
return boolCmp(k.w[0] > o.w[0])
case k.w[1] != o.w[1]:
return boolCmp(k.w[1] > o.w[1])
case k.w[2] != o.w[2]:
return boolCmp(k.w[2] > o.w[2])
}
return 0
}
return k.compareGeneral(o)
}

// compareGeneral is the arbitrary-precision path, taken whenever either side
// did not pack. It is also the reference the packed path is held to:
// TestReleaseKeyPackedAgreesWithGeneral runs both over every corpus pair, so a
// packing bug cannot hide behind the fast path.
func (k ReleaseKey) compareGeneral(o ReleaseKey) int {
// (*big.Int)(&…) rather than big.Int(…): part.BigInt IS a big.Int, so the
// pointer conversion is free, where the value conversion copies a header.
// That matters HERE, in a per-segment loop; elsewhere in this file the
// value conversion is used for a single Sign() or String() call, where one
// header copy reads more plainly than a pointer conversion. Cmp only reads
// through both pointers.
if c := (*big.Int)(&k.epoch).Cmp((*big.Int)(&o.epoch)); c != 0 {
return c
}
n := min(len(k.release), len(o.release))
for i := range n {
if c := (*big.Int)(&k.release[i]).Cmp((*big.Int)(&o.release[i])); c != 0 {
return c
}
}
// Both releases are stripped, so a shorter one is genuinely shorter and
// its missing segments are zeros: it is the smaller release.
switch {
case len(k.release) < len(o.release):
return -1
case len(k.release) > len(o.release):
return 1
}
return 0
}

// String renders the key in canonical shortest form: the stripped release,
// dot-joined, with an "N!" epoch prefix when the epoch is nonzero. The empty
// release renders as "0", so the result always parses and always round-trips —
// Parse(k.String()).ReleaseKey() compares equal to k.
//
// ⚠️ THE RESULT IS NOT A BOUND ON THE GROUP. It is the shortest spelling
// carrying the key, not the smallest version carrying it: 1.0a1 and 1.0.dev0
// carry the key of "1" and sort strictly below Parse("1"), and .postN and
// +local carry it and sort above with no upper limit. See the type doc.
//
// This exists so that a key appears legibly in a test failure or a %v; it is
// not on any hot path, and nothing in this package's ordering goes through it.
// It allocates, unlike everything else here.
func (k ReleaseKey) String() string {
var b strings.Builder
if bi := big.Int(k.epoch); bi.Sign() != 0 {
b.WriteString(bi.String())
b.WriteByte('!')
}
if len(k.release) == 0 {
b.WriteByte('0')
return b.String()
}
for i := range k.release {
if i > 0 {
b.WriteByte('.')
}
b.WriteString((*big.Int)(&k.release[i]).String())
}
return b.String()
}
Loading