From 5ab451a8689002e58afa0500b0b2b7345190d49c Mon Sep 17 00:00:00 2001 From: Jonathan Yoder Date: Thu, 27 Aug 2026 14:29:39 -0400 Subject: [PATCH 1/4] tags: export the per-OS arch lists via Archs (#45) linuxArchs, macosArchs and windowsArchs were package-private with no accessor, so a caller validating an operator-supplied "os/arch" string could only construct a Target and inspect Compile's ErrUnsupportedTarget. That is enough to decline a value and not enough to explain it: an error message cannot list what would have been accepted, and generated documentation cannot be derived from the source of truth. The spellings are neither uniform nor guessable, which is exactly when a good message matters. windows spells it "amd64" where linux spells it "x86_64"; macOS spells it "arm64" where linux spells it "aarch64". Archs returns a copy, so a caller cannot widen what Compile accepts by mutating it, and TestArchs_ReturnsACopy pins that. TestArchs_AgreesWith- Validate pins the property that motivates exporting at all: every arch Archs reports compiles, and a value it does not report does not. Without that test the exported view could drift from validate(), which is the failure this replaces. OSes() ships alongside because a caller validating "os/arch" needs both halves and would otherwise hardcode the OS list instead. Co-Authored-By: Claude Opus 5 (1M context) --- tags/archs_test.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++ tags/target.go | 33 ++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tags/archs_test.go diff --git a/tags/archs_test.go b/tags/archs_test.go new file mode 100644 index 0000000..a1576f0 --- /dev/null +++ b/tags/archs_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +package tags + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestArchs_AgreesWithValidate is the point of exporting these lists: the +// exported view and the value Compile actually accepts must not drift. Every +// arch Archs reports must compile, and a value it does not report must not. +func TestArchs_AgreesWithValidate(t *testing.T) { + for _, os := range OSes() { + archs := Archs(os) + require.NotEmpty(t, archs, "%s must report at least one arch", os) + + for _, arch := range archs { + t.Run(os+"/"+arch, func(t *testing.T) { + _, err := targetFor(os, arch).Compile() + assert.NoError(t, err, "Archs reported %s/%s, so it must compile", os, arch) + }) + } + + t.Run(os+"/unsupported", func(t *testing.T) { + _, err := targetFor(os, "nosucharch").Compile() + assert.ErrorIs(t, err, ErrUnsupportedTarget) + }) + } +} + +// targetFor fills in the per-OS mandatory version axes so the only thing under +// test is the arch. +func targetFor(os, arch string) Target { + t := Target{Implementation: "cp", PyMajor: 3, PyMinor: 12, OS: os, Arch: arch} + switch os { + case "linux": + t.Libc, t.LibcMajor, t.LibcMinor = "glibc", 2, 28 + case "macos": + t.MacMajor, t.MacMinor = 14, 0 + if arch == "x86_64" { + t.MacMajor = 11 + } + } + return t +} + +func TestArchs_UnknownOS(t *testing.T) { + assert.Nil(t, Archs("solaris")) + assert.Nil(t, Archs("")) +} + +// TestArchs_ReturnsACopy guards the stated contract: a caller must not be able +// to widen what Compile accepts by mutating the returned slice. +func TestArchs_ReturnsACopy(t *testing.T) { + first := Archs("windows") + require.NotEmpty(t, first) + first[0] = "tampered" + + second := Archs("windows") + assert.NotContains(t, second, "tampered") + + _, err := targetFor("windows", "tampered").Compile() + assert.ErrorIs(t, err, ErrUnsupportedTarget) +} + +// TestArchs_SpellingsDiffer records the reason a caller needs to enumerate +// rather than guess. +func TestArchs_SpellingsDiffer(t *testing.T) { + assert.Contains(t, Archs("linux"), "x86_64") + assert.NotContains(t, Archs("windows"), "x86_64", "windows spells it amd64") + assert.Contains(t, Archs("windows"), "amd64") + + assert.Contains(t, Archs("linux"), "aarch64") + assert.NotContains(t, Archs("macos"), "aarch64", "macOS spells it arm64") + assert.Contains(t, Archs("macos"), "arm64") +} diff --git a/tags/target.go b/tags/target.go index 1ecb7e8..b399432 100644 --- a/tags/target.go +++ b/tags/target.go @@ -205,6 +205,39 @@ var ( windowsArchs = []string{"amd64", "x86", "arm64"} ) +// OSes returns the operating systems a Target may name, in no meaningful order. +func OSes() []string { + return []string{"linux", "macos", "windows"} +} + +// Archs returns the architectures valid for os, or nil if os is not one of +// OSes(). The result is a copy: these are the same lists Compile validates +// against, and a caller must not be able to widen them. +// +// This exists so a caller validating an operator-supplied "os/arch" string can +// report what it WOULD have accepted. Compile already rejects an unsupported +// arch with ErrUnsupportedTarget, which is enough to decline a value but not to +// explain it, and the spellings are neither uniform across operating systems nor +// guessable: windows uses "amd64" where linux uses "x86_64", and macOS uses +// "arm64" where linux uses "aarch64". Without this, every caller keeps a private +// copy that drifts from validate(). +func Archs(os string) []string { + var src []string + switch os { + case "linux": + src = linuxArchs + case "macos": + src = macosArchs + case "windows": + src = windowsArchs + default: + return nil + } + out := make([]string, len(src)) + copy(out, src) + return out +} + func contains(list []string, s string) bool { for _, v := range list { if v == s { From 2f1d7c1070f8c42ad2409cb2d1a4fbb8521d30c3 Mon Sep 17 00:00:00 2001 From: Jonathan Yoder Date: Thu, 27 Aug 2026 14:32:28 -0400 Subject: [PATCH 2/4] tags: add IsCompatibleOrNewer for platforms above the declared version (#45) The platform walks run DOWNWARD from the declared version to a floor, so a Matcher answers "can the host I described run this wheel" -- correct for an installer. A mirror or an offline bundle asks a different question: "should a durable declaration of that host collect this wheel". The two diverge over time, because a set compiled once silently stops matching wheels built for platforms released since. Accepting a too-new platform costs bytes; rejecting one costs availability, and for an air-gapped mirror a wheel not collected is one the client cannot obtain at all. IsCompatibleOrNewer also accepts a same-family, same-arch platform tag whose version is ABOVE the declared one: manylinux/musllinux by libc version, macosx by macOS version. Windows platform tags carry no version axis, so there it degrades to IsCompatible. The interpreter and ABI axes are NOT relaxed. Matcher now retains the set of (Interpreter, ABI) pairs it accepts on any platform, and a candidate qualifies as newer only if its pair is already in that set. Without this a cp314 wheel would ride in on a newer platform tag. Covered for the plain case and for the abi3/abi3t substitution, where a free-threaded target must not gain abi3 wheels at any platform version. No rank is returned, deliberately: these tags fall outside the ordered set the Matcher generated, so there is no defensible priority for them. Legacy aliases are judged by the glibc version they name rather than by being old, so manylinux2014 is newer than a 2.12 declaration and merely compatible with a 2.28 one. I had asserted the opposite first and the test caught it; the rule is "requires more platform than declared", not "is recent". parseMacosPlatformTag is new because distro.go's parseLibcPlatformTag has no macOS counterpart. It is tested against the macosx_10_9_x86_64 shape that defeats a naive "_x86_64" suffix test. Co-Authored-By: Claude Opus 5 (1M context) --- tags/newer.go | 137 +++++++++++++++++++++++++++++++ tags/newer_test.go | 197 +++++++++++++++++++++++++++++++++++++++++++++ tags/target.go | 15 +++- 3 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 tags/newer.go create mode 100644 tags/newer_test.go diff --git a/tags/newer.go b/tags/newer.go new file mode 100644 index 0000000..3f27240 --- /dev/null +++ b/tags/newer.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT + +package tags + +import ( + "fmt" + "strconv" + "strings" +) + +// IsCompatibleOrNewer reports whether any of w is compatible with this Matcher, +// OR names a platform of the same family that this target is simply too old to +// know about: a manylinux/musllinux tag whose glibc/musl floor is above the +// declared libc version, or a macosx tag whose version is above the declared +// macOS version. +// +// The interpreter and ABI axes are NOT relaxed. A candidate is accepted as +// "newer" only if its (Interpreter, ABI) pair is one this Matcher already +// accepts on some platform, so a cp314 wheel is still rejected by a cp313 +// target however new its platform tag is. +// +// Rank is undefined for the newer case and IsCompatibleOrNewer deliberately +// returns no rank: the whole point is that these tags fall outside the ordered +// set this Matcher generated, so there is no defensible priority for them. +// +// # When to use this instead of IsCompatible +// +// IsCompatible answers "can the host I described run this wheel", which is what +// an installer wants. This method answers "should a durable declaration of that +// host collect this wheel", which is what a mirror or an offline bundle wants, +// and the two differ over time. +// +// The platform walks run DOWNWARD from the declared version to a floor +// (manylinuxTags, musllinuxTags, macosPlatformTags), so a set compiled once and +// used for months silently stops matching wheels built for platforms released +// since. For an installer that is correct. For a mirror it means quietly +// collecting less and less, and for an air-gapped mirror a wheel not collected +// is a package the client cannot obtain at all. Accepting a too-new platform +// costs bytes; rejecting one costs availability. +func (m *Matcher) IsCompatibleOrNewer(w []Tag) bool { + if m.IsCompatible(w) { + return true + } + for _, tag := range w { + if _, ok := m.abis[[2]string{tag.Interpreter, tag.ABI}]; !ok { + continue + } + if m.platformIsNewer(tag.Platform) { + return true + } + } + return false +} + +// platformIsNewer reports whether platformTag names the same platform family as +// this Matcher's target, for the same architecture, at a version ABOVE the +// declared one. +func (m *Matcher) platformIsNewer(platformTag string) bool { + switch m.target.OS { + case "linux": + libc, major, minor, err := parseLibcPlatformTag(platformTag) + if err != nil { + return false + } + if !strings.HasSuffix(platformTag, "_"+m.target.Arch) { + return false + } + // Legacy aliases (manylinux1/2010/2014) resolve to their fixed glibc + // version here, so one whose version exceeds the declared floor counts as + // newer on the same footing as a PEP 600 tag. That is deliberate: the + // question is "does this wheel require a newer platform than I declared", + // not "is this platform recent". + if !m.libcFamilyMatches(libc) { + return false + } + return versionAbove(major, minor, m.target.LibcMajor, m.target.LibcMinor) + + case "macos": + major, minor, format, err := parseMacosPlatformTag(platformTag) + if err != nil { + return false + } + if !contains(macosBinaryFormats[m.target.Arch], format) { + return false + } + return versionAbove(major, minor, m.target.MacMajor, m.target.MacMinor) + + default: + // Windows platform tags carry no version axis, so "newer" has no meaning. + return false + } +} + +// libcFamilyMatches reports whether libc is a family this Matcher covers. An +// any-libc Matcher (CompileAnyLibc) covers both. +func (m *Matcher) libcFamilyMatches(libc string) bool { + if m.anyLibc { + return libc == "glibc" || libc == "musl" + } + return libc == m.target.Libc +} + +// versionAbove reports whether (major, minor) is strictly greater than +// (floorMajor, floorMinor), compared as a version rather than per-component -- +// the same shape as versionAtLeast, negated and made strict. +func versionAbove(major, minor, floorMajor, floorMinor int) bool { + if major != floorMajor { + return major > floorMajor + } + return minor > floorMinor +} + +// parseMacosPlatformTag decomposes "macosx___", the shape +// macosPlatformTags emits. The format component is returned rather than +// validated, since which formats are legal depends on the target's arch. +func parseMacosPlatformTag(platformTag string) (major, minor int, format string, err error) { + rest, ok := strings.CutPrefix(platformTag, "macosx_") + if !ok { + return 0, 0, "", fmt.Errorf("%w: %q is not a macosx platform tag", ErrInvalidTag, platformTag) + } + // The format itself can contain underscores ("fat64", "universal2" do not, + // but splitting from the left keeps this robust to ones that might). + parts := strings.SplitN(rest, "_", 3) + if len(parts) != 3 { + return 0, 0, "", fmt.Errorf("%w: %q has no __", ErrInvalidTag, platformTag) + } + if major, err = strconv.Atoi(parts[0]); err != nil { + return 0, 0, "", fmt.Errorf("%w: %q has a non-numeric major", ErrInvalidTag, platformTag) + } + if minor, err = strconv.Atoi(parts[1]); err != nil { + return 0, 0, "", fmt.Errorf("%w: %q has a non-numeric minor", ErrInvalidTag, platformTag) + } + if parts[2] == "" { + return 0, 0, "", fmt.Errorf("%w: %q has an empty format", ErrInvalidTag, platformTag) + } + return major, minor, parts[2], nil +} diff --git a/tags/newer_test.go b/tags/newer_test.go new file mode 100644 index 0000000..a44d1ee --- /dev/null +++ b/tags/newer_test.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +package tags + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustCompile(t *testing.T, tg Target) *Matcher { + t.Helper() + m, err := tg.Compile() + require.NoError(t, err) + return m +} + +func linuxTarget(libcMajor, libcMinor int) Target { + return Target{ + Implementation: "cp", PyMajor: 3, PyMinor: 13, + OS: "linux", Arch: "x86_64", + Libc: "glibc", LibcMajor: libcMajor, LibcMinor: libcMinor, + } +} + +// mustTags parses a wheel's tag component the way a caller would. +func mustTags(t *testing.T, s string) []Tag { + t.Helper() + parsed, err := ParseTag(s) + require.NoError(t, err) + return parsed +} + +func TestIsCompatibleOrNewer_Linux(t *testing.T) { + m := mustCompile(t, linuxTarget(2, 28)) + + for _, tc := range []struct { + tag string + compatible bool + compatOrNewer bool + why string + }{ + {"cp313-cp313-manylinux_2_17_x86_64", true, true, "at or below the declared floor"}, + {"cp313-cp313-manylinux_2_28_x86_64", true, true, "exactly the declared version"}, + {"cp313-cp313-manylinux_2_45_x86_64", false, true, "newer glibc than declared"}, + {"cp313-cp313-manylinux_3_0_x86_64", false, true, "newer glibc major"}, + {"cp313-cp313-musllinux_1_2_x86_64", false, false, "wrong libc family"}, + {"cp313-cp313-manylinux_2_45_aarch64", false, false, "wrong arch"}, + {"cp313-cp313-win_amd64", false, false, "wrong OS"}, + {"py3-none-any", true, true, "pure python"}, + } { + t.Run(tc.tag, func(t *testing.T) { + w := mustTags(t, tc.tag) + assert.Equal(t, tc.compatible, m.IsCompatible(w), "IsCompatible: %s", tc.why) + assert.Equal(t, tc.compatOrNewer, m.IsCompatibleOrNewer(w), "IsCompatibleOrNewer: %s", tc.why) + }) + } +} + +// TestIsCompatibleOrNewer_DoesNotRelaxTheABI is the guardrail: relaxing the +// platform axis must not smuggle in a wheel built for an interpreter this target +// cannot run. +func TestIsCompatibleOrNewer_DoesNotRelaxTheABI(t *testing.T) { + m := mustCompile(t, linuxTarget(2, 28)) + + for _, tag := range []string{ + "cp314-cp314-manylinux_2_45_x86_64", // newer platform AND newer interpreter + "cp312-cp312-manylinux_2_45_x86_64", // newer platform, older interpreter ABI + "pp310-pypy310_pp73-manylinux_2_45_x86_64", + } { + t.Run(tag, func(t *testing.T) { + w := mustTags(t, tag) + require.False(t, m.IsCompatible(w)) + assert.False(t, m.IsCompatibleOrNewer(w), + "a newer platform must not excuse an ABI this target does not accept") + }) + } +} + +// TestIsCompatibleOrNewer_FreeThreadedKeepsItsABI checks the interaction with +// the abi3/abi3t substitution: a free-threaded target must not gain abi3 wheels +// just because their platform is newer. +func TestIsCompatibleOrNewer_FreeThreadedKeepsItsABI(t *testing.T) { + ft := linuxTarget(2, 28) + ft.FreeThreaded = true + m := mustCompile(t, ft) + + abi3 := mustTags(t, "cp37-abi3-manylinux_2_45_x86_64") + assert.False(t, m.IsCompatible(abi3)) + assert.False(t, m.IsCompatibleOrNewer(abi3), + "a free-threaded target does not accept abi3 at any platform version") + + abi3t := mustTags(t, "cp313-abi3t-manylinux_2_45_x86_64") + assert.False(t, m.IsCompatible(abi3t), "the platform is above the declared floor") + assert.True(t, m.IsCompatibleOrNewer(abi3t), "but the ABI is one this target accepts") +} + +func TestIsCompatibleOrNewer_MacOS(t *testing.T) { + m := mustCompile(t, Target{ + Implementation: "cp", PyMajor: 3, PyMinor: 13, + OS: "macos", Arch: "arm64", MacMajor: 14, MacMinor: 0, + }) + + for _, tc := range []struct { + tag string + compatible bool + compatOrNewer bool + why string + }{ + {"cp313-cp313-macosx_14_0_arm64", true, true, "exactly the declared version"}, + {"cp313-cp313-macosx_11_0_arm64", true, true, "older, inside the walk"}, + {"cp313-cp313-macosx_15_0_arm64", false, true, "newer macOS"}, + {"cp313-cp313-macosx_26_0_arm64", false, true, "much newer macOS"}, + {"cp313-cp313-macosx_15_0_universal2", false, true, "newer, arm64 accepts universal2"}, + {"cp313-cp313-macosx_15_0_x86_64", false, false, "newer but arm64 does not accept x86_64"}, + // The trap: a naive "_x86_64" suffix test on the whole tag would treat + // this as an x86_64 linux tag. It is a macOS tag, and for an arm64 + // target it is not acceptable at any version. + {"cp313-cp313-macosx_10_9_x86_64", false, false, "macOS 10.9 x86_64 on an arm64 target"}, + } { + t.Run(tc.tag, func(t *testing.T) { + w := mustTags(t, tc.tag) + assert.Equal(t, tc.compatible, m.IsCompatible(w), "IsCompatible: %s", tc.why) + assert.Equal(t, tc.compatOrNewer, m.IsCompatibleOrNewer(w), "IsCompatibleOrNewer: %s", tc.why) + }) + } +} + +// TestIsCompatibleOrNewer_WindowsHasNoVersionAxis records that "newer" is +// meaningless for windows platform tags, so the method degrades to IsCompatible. +func TestIsCompatibleOrNewer_WindowsHasNoVersionAxis(t *testing.T) { + m := mustCompile(t, Target{ + Implementation: "cp", PyMajor: 3, PyMinor: 13, + OS: "windows", Arch: "amd64", + }) + + ok := mustTags(t, "cp313-cp313-win_amd64") + assert.True(t, m.IsCompatibleOrNewer(ok)) + + wrong := mustTags(t, "cp313-cp313-win32") + assert.Equal(t, m.IsCompatible(wrong), m.IsCompatibleOrNewer(wrong)) +} + +// TestIsCompatibleOrNewer_LegacyAliasesResolveToTheirGlibc pins that a pre-PEP +// 600 alias is judged by the glibc version it names, not by being "old". The +// question this method answers is whether a wheel requires a newer platform than +// was declared, so manylinux2014 (glibc 2.17) is newer than a 2.12 declaration +// and merely compatible with a 2.28 one. +func TestIsCompatibleOrNewer_LegacyAliasesResolveToTheirGlibc(t *testing.T) { + below := mustCompile(t, linuxTarget(2, 12)) + w := mustTags(t, "cp313-cp313-manylinux2014_x86_64") + require.False(t, below.IsCompatible(w), "2.17 is above a 2.12 declaration") + assert.True(t, below.IsCompatibleOrNewer(w), + "it requires more glibc than declared, which is what newer means here") + + above := mustCompile(t, linuxTarget(2, 28)) + assert.True(t, above.IsCompatible(w), "and at 2.28 it is simply compatible") +} + +func TestParseMacosPlatformTag(t *testing.T) { + for _, tc := range []struct { + in string + major int + minor int + format string + bad bool + }{ + {in: "macosx_14_0_arm64", major: 14, minor: 0, format: "arm64"}, + {in: "macosx_10_9_x86_64", major: 10, minor: 9, format: "x86_64"}, + {in: "macosx_11_0_universal2", major: 11, minor: 0, format: "universal2"}, + {in: "manylinux_2_28_x86_64", bad: true}, + {in: "macosx_14_arm64", bad: true}, + {in: "macosx_x_0_arm64", bad: true}, + {in: "macosx_14_y_arm64", bad: true}, + {in: "macosx_14_0_", bad: true}, + } { + t.Run(tc.in, func(t *testing.T) { + major, minor, format, err := parseMacosPlatformTag(tc.in) + if tc.bad { + assert.ErrorIs(t, err, ErrInvalidTag) + return + } + require.NoError(t, err) + assert.Equal(t, tc.major, major) + assert.Equal(t, tc.minor, minor) + assert.Equal(t, tc.format, format) + }) + } +} + +func TestVersionAbove(t *testing.T) { + assert.True(t, versionAbove(2, 45, 2, 28)) + assert.True(t, versionAbove(3, 0, 2, 99), "a version comparison, not per-component") + assert.False(t, versionAbove(2, 28, 2, 28), "strict") + assert.False(t, versionAbove(2, 17, 2, 28)) + assert.False(t, versionAbove(1, 99, 2, 0)) +} diff --git a/tags/target.go b/tags/target.go index b399432..e7ec237 100644 --- a/tags/target.go +++ b/tags/target.go @@ -81,12 +81,14 @@ func (t Target) Compile() (*Matcher, error) { return nil, err } rank := make(map[Tag]int, len(ordered)) + abis := make(map[[2]string]struct{}) for i, tag := range ordered { if _, exists := rank[tag]; !exists { rank[tag] = i } + abis[[2]string{tag.Interpreter, tag.ABI}] = struct{}{} } - return &Matcher{tags: ordered, rank: rank}, nil + return &Matcher{tags: ordered, rank: rank, target: t, abis: abis}, nil } func (t Target) validate() error { @@ -253,6 +255,17 @@ func contains(list []string, s string) bool { type Matcher struct { tags []Tag rank map[Tag]int + // target is retained so IsCompatibleOrNewer can compare a candidate + // platform tag against the version this Matcher was declared at. + target Target + // abis is the set of (Interpreter, ABI) pairs this Matcher accepts on ANY + // platform, so IsCompatibleOrNewer can relax the platform axis alone + // without also accepting a wheel built for the wrong interpreter. + abis map[[2]string]struct{} + // anyLibc records that this Matcher covers both glibc and musl, so + // IsCompatibleOrNewer must treat a newer tag from either family as newer. + // Set only by CompileAnyLibc. + anyLibc bool } // Tags returns a copy of the full ordered list of compatible tags, most From f60a6d8801d6356e36293ecf385360d070e96304 Mon Sep 17 00:00:00 2001 From: Jonathan Yoder Date: Thu, 27 Aug 2026 14:34:21 -0400 Subject: [PATCH 3/4] tags: add CompileAnyLibc for a Linux target with no libc preference (#45) Compile requires a Libc for a linux Target and linuxPlatformTags emits manylinux XOR musllinux depending on it, so a caller expressing "linux x86_64, either libc" has to compile two Targets and union them by hand. Omitting the musl one is silent: every musllinux wheel stops matching with nothing to indicate that a whole wheel family was dropped. TestCompileAnyLibc_SingleFamilyDropsTheOther pins that failure so the helper's value is executable rather than argued in prose. The union is deduplicated, because the overlap is real: the bare linux_ tag and the entire compatible tier are emitted by both families. glibc tiers rank ahead of musl ones, and Rank is part of the published contract, so that ordering is asserted. Two things worth review attention. muslMajor/muslMinor are stored on the Matcher separately from target.LibcMajor/Minor rather than reusing them. My first version reused them and TestCompileAnyLibc_NewerWorksForBothFamilies failed: comparing musllinux_2_0 against a glibc 2.28 floor reads as "older" and rejects the wheel. musl and glibc version numbers have no correspondence, and collapsing them is a category error. glibcToMusl does not try to invent a correspondence either. It returns the newest musl version this package generates, because musllinux tags are floors and the newest value therefore accepts the widest set. A caller needing an exact musl floor should compile that Target itself; the doc comment says so. t.Libc is ignored rather than honoured, so the call cannot half-apply, and TestCompileAnyLibc_IgnoresLibcField pins it. Validation is not bypassed: a bad arch or a missing libc version still returns ErrUnsupportedTarget. Co-Authored-By: Claude Opus 5 (1M context) --- tags/anylibc.go | 92 +++++++++++++++++++++++++++ tags/anylibc_test.go | 147 +++++++++++++++++++++++++++++++++++++++++++ tags/newer.go | 14 ++++- tags/target.go | 5 ++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 tags/anylibc.go create mode 100644 tags/anylibc_test.go diff --git a/tags/anylibc.go b/tags/anylibc.go new file mode 100644 index 0000000..dc81e0d --- /dev/null +++ b/tags/anylibc.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT + +package tags + +import "fmt" + +// CompileAnyLibc compiles a linux Target for BOTH libc families and returns a +// single Matcher accepting either, with the glibc tiers ranked ahead of the musl +// ones. t.Libc is ignored; t.LibcMajor/LibcMinor are used as the glibc version +// and glibcToMusl maps them to a musl version. +// +// Compile requires a Libc and linuxPlatformTags emits manylinux XOR musllinux +// depending on it, so a caller expressing "linux x86_64, either libc" otherwise +// has to compile two Targets and union them by hand. Omitting the musl one is +// silent: every musllinux wheel simply stops matching, with nothing to indicate +// a whole wheel family was dropped. That is the mistake this exists to remove. +// +// Returns ErrUnsupportedTarget for a non-linux Target, since no other OS has a +// libc axis to be agnostic about. +func (t Target) CompileAnyLibc() (*Matcher, error) { + if t.OS != "linux" { + return nil, fmt.Errorf("%w: CompileAnyLibc requires OS \"linux\", got %q", + ErrUnsupportedTarget, t.OS) + } + + glibc := t + glibc.Libc = "glibc" + gm, err := glibc.Compile() + if err != nil { + return nil, err + } + + musl := t + musl.Libc = "musl" + musl.LibcMajor, musl.LibcMinor = glibcToMusl(t.LibcMajor, t.LibcMinor) + mm, err := musl.Compile() + if err != nil { + return nil, err + } + + // Union preserving glibc order first, then musl tags not already present. + // The overlap is real and must not be double-counted: the bare "linux_" + // tag and the whole compatible tier (py3-none-any and friends) are emitted by + // both families. + ordered := gm.Tags() + seen := make(map[Tag]struct{}, len(ordered)) + for _, tag := range ordered { + seen[tag] = struct{}{} + } + for _, tag := range mm.Tags() { + if _, dup := seen[tag]; dup { + continue + } + seen[tag] = struct{}{} + ordered = append(ordered, tag) + } + + rank := make(map[Tag]int, len(ordered)) + abis := make(map[[2]string]struct{}) + for i, tag := range ordered { + if _, exists := rank[tag]; !exists { + rank[tag] = i + } + abis[[2]string{tag.Interpreter, tag.ABI}] = struct{}{} + } + + // target keeps the glibc version so IsCompatibleOrNewer compares manylinux + // tags against it, and muslMajor/Minor carry the musl version separately so a + // musllinux tag is compared against the right floor. Collapsing the two would + // make musllinux_2_0 read as older than glibc 2.28. + return &Matcher{ + tags: ordered, rank: rank, target: glibc, abis: abis, + anyLibc: true, + muslMajor: musl.LibcMajor, muslMinor: musl.LibcMinor, + }, nil +} + +// muslLatest is the newest musl version this package generates tags for. musl +// has no published glibc-equivalence table, and its 1.x series moves slowly +// enough that a single current value is a better answer than a fabricated +// mapping: musllinux tags are floors, so the newest value accepts every older +// musllinux tag as well. +var muslLatest = struct{ major, minor int }{1, 2} + +// glibcToMusl chooses the musl version to pair with a declared glibc version. +// There is no meaningful correspondence between the two, so this does not try to +// invent one: it returns the newest musl version, which accepts the widest set +// of musllinux tags. A caller that needs an exact musl floor should compile that +// Target itself rather than use CompileAnyLibc. +func glibcToMusl(_, _ int) (major, minor int) { + return muslLatest.major, muslLatest.minor +} diff --git a/tags/anylibc_test.go b/tags/anylibc_test.go new file mode 100644 index 0000000..e3efe1b --- /dev/null +++ b/tags/anylibc_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +package tags + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCompileAnyLibc_AcceptsBothFamilies is the whole point: one Matcher that +// does not silently drop a wheel family. +func TestCompileAnyLibc_AcceptsBothFamilies(t *testing.T) { + m, err := linuxTarget(2, 28).CompileAnyLibc() + require.NoError(t, err) + + for _, tag := range []string{ + "cp313-cp313-manylinux_2_17_x86_64", + "cp313-cp313-manylinux_2_28_x86_64", + "cp313-cp313-manylinux2014_x86_64", + "cp313-cp313-musllinux_1_1_x86_64", + "cp313-cp313-musllinux_1_2_x86_64", + "cp313-cp313-linux_x86_64", + "py3-none-any", + } { + t.Run(tag, func(t *testing.T) { + assert.True(t, m.IsCompatible(mustTags(t, tag))) + }) + } +} + +// TestCompileAnyLibc_SingleFamilyDropsTheOther records the mistake being +// removed, so the value of the helper is executable rather than asserted in prose. +func TestCompileAnyLibc_SingleFamilyDropsTheOther(t *testing.T) { + glibcOnly := mustCompile(t, linuxTarget(2, 28)) + musl := mustTags(t, "cp313-cp313-musllinux_1_2_x86_64") + assert.False(t, glibcOnly.IsCompatible(musl), + "a glibc-only Matcher silently rejects every musllinux wheel") + + any, err := linuxTarget(2, 28).CompileAnyLibc() + require.NoError(t, err) + assert.True(t, any.IsCompatible(musl)) +} + +// TestCompileAnyLibc_NoDuplicateTags guards the union: the bare linux_ tag +// and the whole compatible tier are emitted by both families. +func TestCompileAnyLibc_NoDuplicateTags(t *testing.T) { + m, err := linuxTarget(2, 28).CompileAnyLibc() + require.NoError(t, err) + + all := m.Tags() + seen := make(map[Tag]int, len(all)) + for _, tag := range all { + seen[tag]++ + } + for tag, n := range seen { + assert.Equal(t, 1, n, "tag %s appears %d times", tag, n) + } + + // And it really is a union, not just one family's list. + var many, musl int + for _, tag := range all { + if strings.HasPrefix(tag.Platform, "manylinux") { + many++ + } + if strings.HasPrefix(tag.Platform, "musllinux") { + musl++ + } + } + assert.NotZero(t, many) + assert.NotZero(t, musl) +} + +// TestCompileAnyLibc_GlibcRanksAhead pins the stated ordering, since Rank is a +// published part of the contract. +func TestCompileAnyLibc_GlibcRanksAhead(t *testing.T) { + m, err := linuxTarget(2, 28).CompileAnyLibc() + require.NoError(t, err) + + gRank, ok := m.Rank(mustTags(t, "cp313-cp313-manylinux_2_28_x86_64")) + require.True(t, ok) + mRank, ok := m.Rank(mustTags(t, "cp313-cp313-musllinux_1_2_x86_64")) + require.True(t, ok) + assert.Less(t, gRank, mRank, "glibc tiers are ranked ahead of musl") +} + +// TestCompileAnyLibc_NewerWorksForBothFamilies checks the interaction with +// IsCompatibleOrNewer: an any-libc Matcher must treat a too-new tag from EITHER +// family as newer, not just from glibc. +func TestCompileAnyLibc_NewerWorksForBothFamilies(t *testing.T) { + m, err := linuxTarget(2, 28).CompileAnyLibc() + require.NoError(t, err) + + newGlibc := mustTags(t, "cp313-cp313-manylinux_2_45_x86_64") + require.False(t, m.IsCompatible(newGlibc)) + assert.True(t, m.IsCompatibleOrNewer(newGlibc)) + + newMusl := mustTags(t, "cp313-cp313-musllinux_2_0_x86_64") + require.False(t, m.IsCompatible(newMusl)) + assert.True(t, m.IsCompatibleOrNewer(newMusl)) + + // The ABI guardrail still holds on the any-libc path. + wrongABI := mustTags(t, "cp314-cp314-musllinux_2_0_x86_64") + assert.False(t, m.IsCompatibleOrNewer(wrongABI)) +} + +func TestCompileAnyLibc_RejectsNonLinux(t *testing.T) { + for _, tg := range []Target{ + {Implementation: "cp", PyMajor: 3, PyMinor: 13, OS: "windows", Arch: "amd64"}, + {Implementation: "cp", PyMajor: 3, PyMinor: 13, OS: "macos", Arch: "arm64", MacMajor: 14}, + } { + t.Run(tg.OS, func(t *testing.T) { + _, err := tg.CompileAnyLibc() + assert.ErrorIs(t, err, ErrUnsupportedTarget) + }) + } +} + +// TestCompileAnyLibc_IgnoresLibcField documents that t.Libc is not consulted, so +// setting it does not change the result and cannot half-apply. +func TestCompileAnyLibc_IgnoresLibcField(t *testing.T) { + asGlibc := linuxTarget(2, 28) + asGlibc.Libc = "glibc" + a, err := asGlibc.CompileAnyLibc() + require.NoError(t, err) + + asMusl := linuxTarget(2, 28) + asMusl.Libc = "musl" + b, err := asMusl.CompileAnyLibc() + require.NoError(t, err) + + assert.Equal(t, a.Tags(), b.Tags()) +} + +// TestCompileAnyLibc_StillValidates confirms the underlying validation is not +// bypassed by filling in Libc internally. +func TestCompileAnyLibc_StillValidates(t *testing.T) { + bad := linuxTarget(2, 28) + bad.Arch = "nosucharch" + _, err := bad.CompileAnyLibc() + assert.ErrorIs(t, err, ErrUnsupportedTarget) + + noVersion := linuxTarget(0, 0) + _, err = noVersion.CompileAnyLibc() + assert.ErrorIs(t, err, ErrUnsupportedTarget) +} diff --git a/tags/newer.go b/tags/newer.go index 3f27240..1784604 100644 --- a/tags/newer.go +++ b/tags/newer.go @@ -73,7 +73,8 @@ func (m *Matcher) platformIsNewer(platformTag string) bool { if !m.libcFamilyMatches(libc) { return false } - return versionAbove(major, minor, m.target.LibcMajor, m.target.LibcMinor) + floorMajor, floorMinor := m.libcFloorFor(libc) + return versionAbove(major, minor, floorMajor, floorMinor) case "macos": major, minor, format, err := parseMacosPlatformTag(platformTag) @@ -100,6 +101,17 @@ func (m *Matcher) libcFamilyMatches(libc string) bool { return libc == m.target.Libc } +// libcFloorFor returns the declared version to compare a tag of the given libc +// family against. musl and glibc version numbers have no correspondence, so an +// any-libc Matcher must not compare a musllinux tag against its glibc floor -- +// musllinux_2_0 against glibc 2.28 would read as "older" and be rejected. +func (m *Matcher) libcFloorFor(libc string) (major, minor int) { + if m.anyLibc && libc == "musl" { + return m.muslMajor, m.muslMinor + } + return m.target.LibcMajor, m.target.LibcMinor +} + // versionAbove reports whether (major, minor) is strictly greater than // (floorMajor, floorMinor), compared as a version rather than per-component -- // the same shape as versionAtLeast, negated and made strict. diff --git a/tags/target.go b/tags/target.go index e7ec237..a30b379 100644 --- a/tags/target.go +++ b/tags/target.go @@ -266,6 +266,11 @@ type Matcher struct { // IsCompatibleOrNewer must treat a newer tag from either family as newer. // Set only by CompileAnyLibc. anyLibc bool + // muslMajor and muslMinor are the musl version this Matcher covers, set only + // by CompileAnyLibc. They are NOT interchangeable with target.LibcMajor/Minor: + // musl and glibc version numbers have no correspondence, so comparing a + // musllinux tag against a glibc floor is a category error. + muslMajor, muslMinor int } // Tags returns a copy of the full ordered list of compatible tags, most From df3210bd321010922f76ec39d43966ca685a8331 Mon Sep 17 00:00:00 2001 From: Jonathan Yoder Date: Thu, 27 Aug 2026 14:36:14 -0400 Subject: [PATCH 4/4] tags: changelog for #45 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1719885..ddd97e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,39 @@ mistaken for a safe patch upgrade. `Evaluate` is now a wrapper and its behaviour is unchanged. +- `tags`: `Matcher.IsCompatibleOrNewer` additionally accepts a same-family, + same-architecture platform tag whose version is **above** the declared one. + + The platform walks run downward from the declared version to a floor, so a + `Matcher` answers "can the host I described run this wheel". A mirror or an + offline bundle asks whether a *durable declaration* of that host should collect + the wheel, and the two diverge over time: a set compiled once quietly stops + matching wheels built for platforms released since. Accepting a too-new + platform costs bytes; rejecting one costs availability, and for an air-gapped + mirror a wheel not collected cannot be obtained at all. + + The interpreter and ABI axes are not relaxed, so a `cp314` wheel is still + rejected by a `cp313` target however new its platform tag, and a free-threaded + target does not gain `abi3` wheels. No rank is reported, deliberately: these + tags fall outside the ordered set the `Matcher` generated. + +- `tags`: `Target.CompileAnyLibc` compiles a linux target for both libc families + and returns one `Matcher` accepting either, glibc ranked ahead of musl. + + `Compile` requires a `Libc`, and platform tags are manylinux **xor** musllinux + depending on it, so "linux x86_64, either libc" previously meant compiling two + targets and unioning them by hand. Omitting the musl one is silent: every + `musllinux` wheel stops matching with nothing to say a whole family was dropped. + +- `tags`: `Archs(os)` and `OSes()` expose the architecture lists `Compile` + validates against, as copies. + + Previously package-private, so a caller validating an operator-supplied + `os/arch` string could reject a value but not report what it would have + accepted. The spellings are neither uniform nor guessable: windows uses + `amd64` where linux uses `x86_64`, and macOS uses `arm64` where linux uses + `aarch64`. + ### Fixed - `tags`: `riscv64` and `loongarch64` targets now claim the same manylinux