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 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/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/newer.go b/tags/newer.go new file mode 100644 index 0000000..1784604 --- /dev/null +++ b/tags/newer.go @@ -0,0 +1,149 @@ +// 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 + } + floorMajor, floorMinor := m.libcFloorFor(libc) + return versionAbove(major, minor, floorMajor, floorMinor) + + 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 +} + +// 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. +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 1ecb7e8..a30b379 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 { @@ -205,6 +207,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 { @@ -220,6 +255,22 @@ 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 + // 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