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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tags/anylibc.go
Original file line number Diff line number Diff line change
@@ -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_<arch>"
// 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
}
147 changes: 147 additions & 0 deletions tags/anylibc_test.go
Original file line number Diff line number Diff line change
@@ -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_<arch> 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)
}
78 changes: 78 additions & 0 deletions tags/archs_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading