From b26b3d5f5df743382b1a10e0f6e14a57d5a35506 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:40:41 +0700 Subject: [PATCH 1/2] three wheels per platform, and a gate that counts them The wheel matrix, which is twenty-one wheels and an sdist: the stable ABI from 3.11 up, free-threaded 3.14, and PEP 803's free-threaded stable ABI from 3.15 up, each on manylinux_2_28 and musllinux for x86_64 and aarch64, macOS universal2, and Windows x64 and arm64. Three per platform is CPython's arithmetic rather than ours: the free-threaded build had no stable ABI until 3.15, so 3.14t is a version-specific wheel for as long as 3.14 is supported. Which ABI a build gets is now a feature of this crate rather than a feature of the pyo3 dependency, because a wheel cannot be told which ABI it is after it is built and the three builds have to differ somewhere. `abi3` is the default, so the ordinary build and every `pip install .` is unchanged; the other two rows turn it off, one building against whichever interpreter it is given and the other against `abi3t-py315`, which compiles with no interpreter present at all. That last part is the point of a stable ABI and the only reason a 3.15 wheel can be built before 3.15 is installable. The gate is `tools/wheel_tags.py`, run twice. Each build is held to the cell it asked for, and then the grid is checked as a grid: every cell filled, nothing outside it, exactly one sdist. Both halves matter, and the first one especially: a build that cannot find the interpreter it wants does not fail, it falls back, and what comes out is a wheel that works on the machine that built it and claims nothing about any other version. That is invisible until somebody's install resolves to it. Verified against real maturin output rather than only against strings. A universal2 build here produced `macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2`, a tag set and not a tag, which the first draft refused; a platform is now the one whose pattern any member of the set matches, and a wheel for one architecture matches none of them. A `--no-default-features` build against the 3.14 on this machine produced `cp314-cp314`, and the check caught it as the cp314-cp314t row it was not. 18 tests for the rule, 194 in the client. The lint job now compiles both of the other two ABIs, so a change that breaks one is found on the pull request rather than on the tag. --- .github/workflows/ci.yml | 10 ++ .github/workflows/wheels.yml | 176 +++++++++++++++++++++++++++++++++ Cargo.toml | 15 ++- README.md | 4 +- pyproject.toml | 3 + tests/test_wheels.py | 184 +++++++++++++++++++++++++++++++++++ tools/wheel_tags.py | 169 ++++++++++++++++++++++++++++++++ 7 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/wheels.yml create mode 100644 tests/test_wheels.py create mode 100644 tools/wheel_tags.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b2b66c..4e890d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,16 @@ jobs: # every machine, so nothing installs one. - run: cargo fmt --all --check - run: cargo clippy --all-targets -- -D warnings + # The other two ABIs the release builds, each a feature of this + # crate, compiled here so that a change which breaks one is found + # on the pull request rather than on the tag. The free-threaded + # stable ABI needs no interpreter at all, which is the point of a + # stable ABI and the only reason 3.15 can be built for before it + # is installable. + - run: cargo check --no-default-features --features pyo3/extension-module + - run: cargo check --no-default-features --features abi3t,pyo3/extension-module + env: + PYO3_NO_PYTHON: 1 test: strategy: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..0b11111 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,176 @@ +name: Wheels + +on: + workflow_dispatch: + push: + tags: ["v*"] + # A change to how a wheel is built is exercised on the pull request + # that makes it. The alternative is finding out on the tag, which is + # the one moment a wheel cannot be rebuilt quietly. + pull_request: + paths: + - .github/workflows/wheels.yml + - Cargo.toml + - pyproject.toml + - rust-toolchain.toml + - tools/wheel_tags.py + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + # Three wheels per platform, and the reason is CPython's rather than + # ours: the free-threaded build had no stable ABI until 3.15 and PEP + # 803's `abi3t`, so 3.14t is a version-specific wheel of its own and + # will be for as long as 3.14 is supported. + wheel: + name: ${{ matrix.abi }} ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + abi: [abi3, ft314, abi3t] + platform: + - manylinux_2_28_x86_64 + - manylinux_2_28_aarch64 + - musllinux_1_2_x86_64 + - musllinux_1_2_aarch64 + - macosx_universal2 + - win_amd64 + - win_arm64 + include: + # Which ABI, which is a Cargo feature and an interpreter. + # `abi3` is the default feature, so the first row asks for + # nothing; the other two turn it off, because a wheel built + # against the stable ABI cannot also be built against one + # version's. + - abi: abi3 + tag: cp311-abi3 + features: "" + interpreter: "3.11" + freethreaded: false + - abi: ft314 + tag: cp314-cp314t + features: "--no-default-features" + interpreter: "3.14" + freethreaded: true + - abi: abi3t + tag: cp315-abi3t + features: "--no-default-features --features abi3t" + interpreter: "3.15-dev" + freethreaded: true + # Which platform, which is a runner and a target. Every row + # is built on its own architecture: an emulated build takes + # twenty minutes to produce the same bytes. + - platform: manylinux_2_28_x86_64 + runner: ubuntu-latest + target: x86_64 + container: "2_28" + installable: true + - platform: manylinux_2_28_aarch64 + runner: ubuntu-24.04-arm + target: aarch64 + container: "2_28" + installable: true + - platform: musllinux_1_2_x86_64 + runner: ubuntu-latest + target: x86_64 + container: musllinux_1_2 + installable: false + - platform: musllinux_1_2_aarch64 + runner: ubuntu-24.04-arm + target: aarch64 + container: musllinux_1_2 + installable: false + - platform: macosx_universal2 + runner: macos-latest + target: universal2-apple-darwin + container: auto + installable: true + - platform: win_amd64 + runner: windows-latest + target: x64 + container: auto + installable: true + - platform: win_arm64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + container: auto + installable: true + steps: + - uses: actions/checkout@v7 + # The interpreter maturin names the wheel after on macOS and + # Windows, and the one that installs it below everywhere it can + # be installed. The Linux rows build inside the manylinux and + # musllinux images, which carry their own. + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.interpreter }} + freethreaded: ${{ matrix.freethreaded }} + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + manylinux: ${{ matrix.container }} + # Named rather than left to the toolchain file, which the + # build container does not read. + rust-toolchain: 1.97.1 + args: --release --out dist ${{ matrix.features }} + # The wheel is named by the interpreter that built it and by + # nothing that was checked, so this checks it: the tag has to be + # the one this row asked for. A build that fell back to a + # version-specific wheel produces a wheel that works and claims + # nothing about any version but its own. + - run: python tools/wheel_tags.py dist ${{ matrix.tag }} ${{ matrix.platform }} + - name: the wheel imports + if: matrix.installable + run: | + python -m pip install --no-index --find-links dist zudb + python -c "import zudb; print(zudb.__version__, zudb.__abi_version__)" + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.abi }}-${{ matrix.platform }} + path: dist/*.whl + + # Every platform with no row above, and the one everybody who + # packages this for something else starts from. Built once, from the + # same checkout, and tested by building it back into a wheel. + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - run: pip install --no-binary zudb dist/*.tar.gz + - run: python -c "import zudb; print(zudb.__version__)" + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz + + # The grid, checked as a grid. Every cell filled and nothing outside + # it, which is the check a single row cannot do. + tags: + needs: [wheel, sdist] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - run: python tools/wheel_tags.py dist diff --git a/Cargo.toml b/Cargo.toml index c55c31c..656a815 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,13 +24,26 @@ zu-common = { git = "https://github.com/tamnd/zu", rev = "67afd055032932eec36f4e # not here. Only the build backend knows how an extension is linked on # the platform it is building for, and a crate that turns the feature # on by default is a crate `cargo build` cannot link on its own. -pyo3 = { version = "0.29", features = ["abi3-py311"] } +pyo3 = { version = "0.29" } # Arrow, for the columns a result leaves as. Only `ffi` is asked for: # the readers and writers are the engine's business and what is wanted # here is the C Data Interface, which is how a result reaches pyarrow, # pandas and polars without a Python object per cell. arrow = { version = "59", default-features = false, features = ["ffi"] } +# Which ABI to build against, which is the one thing a wheel cannot be +# told after it is built. The default is the stable ABI from 3.11 up, +# which is the wheel almost everybody installs. The other two are +# asked for by the release job and by nothing else: +# `--no-default-features` builds against whichever interpreter is +# given, which is what the free-threaded 3.14 wheel needs because that +# build has no stable ABI at all, and `abi3t` is PEP 803's, which is +# the one that ends the need for a wheel per free-threaded version. +[features] +default = ["abi3"] +abi3 = ["pyo3/abi3-py311"] +abi3t = ["pyo3/abi3t-py315"] + [profile.release] lto = "thin" strip = true diff --git a/README.md b/README.md index 330536d..ff53897 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ Three per platform, which is more than it sounds like it should be and is not op | `cp314-cp314t` | free-threaded 3.14 | | `cp315-abi3t` | 3.15 and every later 3.x, both builds | -Platforms: manylinux_2_28 and musllinux on x86_64 and aarch64, macOS universal2, Windows x64 and arm64. An `sdist` that builds with only a Rust toolchain is published too, and is tested in CI on a clean container. +Platforms: manylinux_2_28 and musllinux on x86_64 and aarch64, macOS universal2, Windows x64 and arm64. An `sdist` that builds with only a Rust toolchain is published too, and is built back into a wheel in CI. + +That is twenty-one wheels and the release checks all twenty-one, twice. Each build is held to the tag it asked for, and then the grid is checked as a grid: every cell filled and nothing outside it. A build that cannot find the interpreter it wants does not fail, it falls back and produces a version-specific wheel that works on the machine that built it and claims nothing about any other version, which is the kind of thing nobody notices until somebody's install resolves to it. Optional extras, none required: `zudb[pandas]`, `[polars]`, `[arrow]`, `[viz]`, `[all]`. The base wheel depends on nothing. diff --git a/pyproject.toml b/pyproject.toml index bccda08..a6ea159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,9 @@ strip = true [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" +# `tools` holds the checks the release job runs, which are tested here +# rather than only on the tag that would fail because of one. +pythonpath = ["tools"] [tool.ruff] target-version = "py311" diff --git a/tests/test_wheels.py b/tests/test_wheels.py new file mode 100644 index 0000000..8cd7933 --- /dev/null +++ b/tests/test_wheels.py @@ -0,0 +1,184 @@ +"""The check the release job runs over what it built. + +A gate that only runs on a tag is a gate nobody sees fail until the +release it fails, so the rule it enforces is written as a function and +the function is tested here. What the release job does with it is read +a directory and print the complaints. +""" + +from __future__ import annotations + +import wheel_tags + +VERSION = "0.0.1" + + +def grid() -> list[str]: + """A complete release: every ABI on every platform, and the sdist.""" + platforms = { + "manylinux_2_28_x86_64": "manylinux_2_28_x86_64", + "manylinux_2_28_aarch64": "manylinux_2_28_aarch64", + "musllinux_1_2_x86_64": "musllinux_1_2_x86_64", + "musllinux_1_2_aarch64": "musllinux_1_2_aarch64", + # The tag set maturin writes for a universal2 build, which is + # the wheel saying the three true things about itself and not + # three wheels. + "macosx_universal2": "macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2", + "win_amd64": "win_amd64", + "win_arm64": "win_arm64", + } + return [f"zudb-{VERSION}.tar.gz"] + [ + f"zudb-{VERSION}-{abi}-{platform}.whl" + for abi in wheel_tags.ABIS + for platform in platforms.values() + ] + + +def test_a_complete_release_has_nothing_wrong_with_it() -> None: + assert wheel_tags.check(grid()) == [] + + +def test_the_grid_is_three_wheels_on_seven_platforms() -> None: + assert len(grid()) == 3 * 7 + 1 + + +def test_a_missing_wheel_is_named() -> None: + built = [name for name in grid() if "cp314-cp314t-win_arm64" not in name] + assert wheel_tags.check(built) == [ + "no cp314-cp314t wheel for win_arm64, which is free-threaded 3.14, which has no stable ABI" + ] + + +def test_a_missing_abi_is_named_once_per_platform() -> None: + built = [name for name in grid() if "cp315-abi3t" not in name] + complaints = wheel_tags.check(built) + assert len(complaints) == 7 + assert all("no cp315-abi3t wheel" in complaint for complaint in complaints) + + +def test_a_version_specific_wheel_nobody_asked_for_is_refused() -> None: + # What a build that found the wrong interpreter produces, and the + # reason this check exists: `--no-default-features` against a + # 3.13 that happened to be on the path builds a wheel that works + # and claims nothing about any other version. + built = [*grid(), f"zudb-{VERSION}-cp313-cp313-manylinux_2_28_x86_64.whl"] + complaints = wheel_tags.check(built) + assert len(complaints) == 1 + assert complaints[0].startswith( + f"zudb-{VERSION}-cp313-cp313-manylinux_2_28_x86_64.whl is a cp313-cp313 wheel" + ) + + +def test_a_gil_enabled_wheel_for_a_single_version_is_refused_too() -> None: + # The abi3 row falling back to a version-specific build is the same + # accident and reads the same way in the name. + built = [*grid(), f"zudb-{VERSION}-cp311-cp311-win_amd64.whl"] + assert len(wheel_tags.check(built)) == 1 + + +def test_a_platform_this_release_does_not_build_for_is_refused() -> None: + built = [*grid(), f"zudb-{VERSION}-cp311-abi3-manylinux_2_17_i686.whl"] + complaints = wheel_tags.check(built) + assert complaints == [ + f"zudb-{VERSION}-cp311-abi3-manylinux_2_17_i686.whl is for manylinux_2_17_i686, " + "which is not a platform this release builds for" + ] + + +def test_the_macos_deployment_target_is_not_this_check_s_business() -> None: + # Whichever one the toolchain picked, as long as the wheel is + # universal2 and there is one of it. + for target in ("macosx_10_12_universal2", "macosx_11_0_universal2", "macosx_15_0_universal2"): + built = [name.replace("macosx_10_12_universal2", target) for name in grid()] + assert wheel_tags.check(built) == [] + + +def test_a_mac_wheel_for_one_architecture_is_not_the_universal_one() -> None: + # The whole point of the row: two half wheels install on half the + # machines each, and pip picks one of them without saying so. + built = [ + name.replace( + "macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2", "macosx_11_0_arm64" + ) + for name in grid() + ] + complaints = wheel_tags.check(built) + assert len(complaints) == 6 + assert complaints[0].endswith("which is not a platform this release builds for") + + +def test_two_wheels_for_one_cell_are_refused() -> None: + # A universal2 wheel beside the two halves it was fused from is a + # release that ships the same code twice and installs whichever pip + # picked. + built = [*grid(), f"zudb-{VERSION}-cp311-abi3-macosx_14_0_universal2.whl"] + complaints = wheel_tags.check(built) + assert len(complaints) == 1 + assert complaints[0].startswith("2 cp311-abi3 wheels for macosx_universal2") + + +def test_a_release_with_no_sdist_is_refused() -> None: + built = [name for name in grid() if not name.endswith(".tar.gz")] + assert wheel_tags.check(built) == [ + "no sdist, and a platform with no wheel of its own builds from one" + ] + + +def test_something_that_is_not_ours_is_refused() -> None: + built = [*grid(), "zu-0.0.1-cp311-abi3-win_amd64.whl"] + assert wheel_tags.check(built) == [ + "zu-0.0.1-cp311-abi3-win_amd64.whl is neither a wheel of this project nor its sdist" + ] + + +def test_one_build_is_held_to_the_cell_it_was_asked_for() -> None: + built = [f"zudb-{VERSION}-cp314-cp314t-macosx_11_0_universal2.whl"] + assert wheel_tags.check_one(built, "cp314-cp314t", "macosx_universal2") == [] + + +def test_a_build_that_found_the_wrong_interpreter_is_caught_where_it_happened() -> None: + # What `--no-default-features` does when the free-threaded 3.14 it + # asked for is not there and a 3.13 is. + built = [f"zudb-{VERSION}-cp313-cp313-manylinux_2_28_x86_64.whl"] + assert wheel_tags.check_one(built, "cp314-cp314t", "manylinux_2_28_x86_64") == [ + f"zudb-{VERSION}-cp313-cp313-manylinux_2_28_x86_64.whl is a cp313-cp313 wheel " + "and this build asked for cp314-cp314t" + ] + + +def test_a_build_for_another_platform_is_caught_too() -> None: + built = [f"zudb-{VERSION}-cp311-abi3-macosx_11_0_arm64.whl"] + assert wheel_tags.check_one(built, "cp311-abi3", "macosx_universal2") == [ + f"zudb-{VERSION}-cp311-abi3-macosx_11_0_arm64.whl is for macosx_11_0_arm64 " + "and this build asked for macosx_universal2" + ] + + +def test_a_build_that_produced_nothing_is_caught() -> None: + assert wheel_tags.check_one([], "cp311-abi3", "win_amd64") == [ + "no cp311-abi3 wheel for win_amd64 was built at all" + ] + + +def test_a_build_that_produced_two_wheels_is_caught() -> None: + # A `dist` that was not emptied between builds, which is two + # answers to which wheel this row produced and no way to say which + # of them is the one that gets uploaded. + built = [ + f"zudb-{VERSION}-cp314-cp314t-manylinux_2_28_x86_64.whl", + "zudb-0.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", + ] + assert wheel_tags.check_one(built, "cp314-cp314t", "manylinux_2_28_x86_64") == [ + "2 wheels out of one build: zudb-0.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl, " + "zudb-0.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl" + ] + + +def test_the_readme_and_the_check_agree_on_the_three_tags() -> None: + # The table in the README is what a reader believes, so it is worth + # one assertion that it says what the gate enforces. + from pathlib import Path + + readme = (Path(__file__).resolve().parent.parent / "README.md").read_text() + for abi in wheel_tags.ABIS: + assert f"`{abi}`" in readme diff --git a/tools/wheel_tags.py b/tools/wheel_tags.py new file mode 100644 index 0000000..6ebf43b --- /dev/null +++ b/tools/wheel_tags.py @@ -0,0 +1,169 @@ +"""What a release is allowed to have built. + +Three wheels per platform, which is more than it sounds like it should +be and is not optional: the free-threaded build has no stable ABI until +CPython 3.15 and PEP 803's `abi3t`, so 3.14t needs a version-specific +wheel of its own. Three, and no more than three. A version-specific +wheel nobody asked for is what happens when a build finds the wrong +interpreter and quietly falls back, and the failure is silent in every +other way: the wheel installs, it works on the machine that built it, +and it is wrong for every version it claims nothing about. + +So the release job builds the grid and this checks it, by name, both +ways: every cell filled, and nothing outside the grid. + + python tools/wheel_tags.py dist the grid + python tools/wheel_tags.py dist cp311-abi3 win_amd64 one cell +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +#: The ABIs, as `{python tag}-{abi tag}`, which is what the middle of a +#: wheel's name says and the only part of it that says which +#: interpreters the wheel is for. +ABIS = { + "cp311-abi3": "the stable ABI, CPython 3.11 through 3.14", + "cp314-cp314t": "free-threaded 3.14, which has no stable ABI", + "cp315-abi3t": "the free-threaded stable ABI, 3.15 and every later 3.x", +} + +#: The platforms, as patterns rather than strings, because the macOS +#: tag carries the deployment target the toolchain picked and that is +#: not this file's business. +PLATFORMS = { + "manylinux_2_28_x86_64": r"manylinux_2_28_x86_64", + "manylinux_2_28_aarch64": r"manylinux_2_28_aarch64", + "musllinux_1_2_x86_64": r"musllinux_1_2_x86_64", + "musllinux_1_2_aarch64": r"musllinux_1_2_aarch64", + "macosx_universal2": r"macosx_\d+_\d+_universal2", + "win_amd64": r"win_amd64", + "win_arm64": r"win_arm64", +} + +#: The source distribution, which is the wheel for everything else: a +#: platform with no row above builds from it with a Rust toolchain and +#: nothing else. +SDIST = re.compile(r"^zudb-[^-]+\.tar\.gz$") + +WHEEL = re.compile(r"^zudb-[^-]+-([^-]+-[^-]+)-(.+)\.whl$") + + +def platform_of(tag: str) -> str | None: + """Which platform a tag is, or `None` for one that is no platform + this release builds for. + + A platform tag is a set rather than a tag, dots between its + members, and a universal2 wheel is where that stops being a detail: + it arrives as + `macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2`, + which is one wheel saying the three true things about itself. A set + is the platform whose pattern one of its members matches, and a + wheel for one architecture matches none of them. + """ + for name, pattern in PLATFORMS.items(): + if any(re.fullmatch(pattern, one) for one in tag.split(".")): + return name + return None + + +def check(names: list[str]) -> list[str]: + """Every complaint about what was built, in the order they are worth + reading. An empty list is a release.""" + complaints = [] + built: dict[tuple[str, str], list[str]] = {} + sdists = [name for name in names if SDIST.fullmatch(name)] + + for name in sorted(names): + if name in sdists: + continue + found = WHEEL.fullmatch(name) + if not found: + complaints.append(f"{name} is neither a wheel of this project nor its sdist") + continue + abi, platform = found.group(1), found.group(2) + if abi not in ABIS: + wanted = ", ".join(ABIS) + complaints.append( + f"{name} is a {abi} wheel, and this release builds {wanted} and nothing else" + ) + continue + where = platform_of(platform) + if where is None: + complaints.append( + f"{name} is for {platform}, which is not a platform this release builds for" + ) + continue + built.setdefault((abi, where), []).append(name) + + for abi, what in ABIS.items(): + for platform in PLATFORMS: + made = built.get((abi, platform), []) + if not made: + complaints.append(f"no {abi} wheel for {platform}, which is {what}") + elif len(made) > 1: + complaints.append(f"{len(made)} {abi} wheels for {platform}: {', '.join(made)}") + + if not sdists: + complaints.append("no sdist, and a platform with no wheel of its own builds from one") + elif len(sdists) > 1: + complaints.append(f"{len(sdists)} sdists: {', '.join(sorted(sdists))}") + + return complaints + + +def check_one(names: list[str], abi: str, platform: str) -> list[str]: + """Every complaint about one cell of the grid, which is what a + single build produced and all it can be held to. + + Worth checking there and not only at the end, because a build that + fell back to another interpreter says so in the name of the wheel + and nowhere else, and the row that did it is the one that knows + which interpreter it asked for. + """ + if abi not in ABIS: + return [f"{abi} is not one of the ABIs this release builds: {', '.join(ABIS)}"] + if platform not in PLATFORMS: + return [f"{platform} is not one of the platforms: {', '.join(PLATFORMS)}"] + complaints = [] + for name in sorted(names): + found = WHEEL.fullmatch(name) + if not found: + complaints.append(f"{name} is not a wheel of this project") + elif found.group(1) != abi: + complaints.append(f"{name} is a {found.group(1)} wheel and this build asked for {abi}") + elif platform_of(found.group(2)) != platform: + complaints.append(f"{name} is for {found.group(2)} and this build asked for {platform}") + if not names: + complaints.append(f"no {abi} wheel for {platform} was built at all") + elif len(names) > 1 and not complaints: + complaints.append(f"{len(names)} wheels out of one build: {', '.join(sorted(names))}") + return complaints + + +def main(argv: list[str]) -> int: + if len(argv) not in (2, 4): + print(f"usage: {argv[0]} [ ]", file=sys.stderr) + return 2 + into = Path(argv[1]) + names = sorted(path.name for path in into.rglob("*") if path.is_file()) + if len(argv) == 4: + complaints = check_one(names, argv[2], argv[3]) + expected = 1 + else: + if not names: + print(f"{into} holds nothing, so nothing was built", file=sys.stderr) + return 1 + complaints = check(names) + expected = len(ABIS) * len(PLATFORMS) + 1 + for complaint in complaints: + print(complaint, file=sys.stderr) + print(f"{len(names)} built, {expected} expected, {len(complaints)} complaints") + return 1 if complaints else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) From 10bae3565d2514dbc5bc4de565e63bbf6d0ee29a Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:54:48 +0700 Subject: [PATCH 2/2] the interpreter each row asked for, and the tag PEP 803 writes The first run of this workflow taught it two things. Four Linux rows built a wheel for the image's own Python 3.12: the free-threaded and 3.15 rows never had the interpreter they asked for, because the container is not the runner and setup-python is not visible inside it. The rows now name their interpreter under /opt/python, list what the image carries before building, and fail on the spot when the one they asked for is not there. The musl rows take the pypa image rather than the cross image the action picks by default, which carries four GIL-enabled interpreters and no free-threaded one at all. The other thing is the tag. A PEP 803 wheel is cp315-abi3.abi3t, not cp315-abi3t: one wheel that is the stable ABI for the GIL-enabled build and for the free-threaded build at once, which is the whole point of the PEP and the reason three wheels a platform does not become four. An ABI tag is a set the same way a platform tag is, so the check compares it as one and holds a wheel that claims only abi3 to be a wheel the free-threaded interpreter will not load. --- .github/workflows/wheels.yml | 69 +++++++++++++++++++++++++++--------- README.md | 4 +-- tests/test_wheels.py | 38 ++++++++++++++++++-- tools/wheel_tags.py | 38 +++++++++++++++++--- 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0b11111..4c9e714 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -50,59 +50,77 @@ jobs: # `abi3` is the default feature, so the first row asks for # nothing; the other two turn it off, because a wheel built # against the stable ABI cannot also be built against one - # version's. + # version's. `inside` is the same interpreter as `version` for + # the rows that build in a container, where the runner's + # Python is not visible and the image's own `python3` is + # whatever the image happens to ship. - abi: abi3 tag: cp311-abi3 features: "" - interpreter: "3.11" + version: "3.11" freethreaded: false + inside: /opt/python/cp311-cp311/bin/python - abi: ft314 tag: cp314-cp314t features: "--no-default-features" - interpreter: "3.14" + version: "3.14" freethreaded: true + inside: /opt/python/cp314-cp314t/bin/python - abi: abi3t - tag: cp315-abi3t + tag: cp315-abi3.abi3t features: "--no-default-features --features abi3t" - interpreter: "3.15-dev" + version: "3.15-dev" freethreaded: true + inside: /opt/python/cp315-cp315t/bin/python # Which platform, which is a runner and a target. Every row # is built on its own architecture: an emulated build takes - # twenty minutes to produce the same bytes. + # twenty minutes to produce the same bytes. The Linux rows + # name their image rather than taking the default, because + # the default for musl is a cross image that carries four + # GIL-enabled interpreters and no free-threaded one, and the + # pypa images are the ones that carry every interpreter this + # matrix asks for under /opt/python. - platform: manylinux_2_28_x86_64 runner: ubuntu-latest target: x86_64 - container: "2_28" + libc: "2_28" + image: quay.io/pypa/manylinux_2_28_x86_64 installable: true - platform: manylinux_2_28_aarch64 runner: ubuntu-24.04-arm target: aarch64 - container: "2_28" + libc: "2_28" + image: quay.io/pypa/manylinux_2_28_aarch64 installable: true - platform: musllinux_1_2_x86_64 runner: ubuntu-latest target: x86_64 - container: musllinux_1_2 + libc: musllinux_1_2 + image: quay.io/pypa/musllinux_1_2_x86_64 installable: false - platform: musllinux_1_2_aarch64 runner: ubuntu-24.04-arm target: aarch64 - container: musllinux_1_2 + libc: musllinux_1_2 + image: quay.io/pypa/musllinux_1_2_aarch64 installable: false - platform: macosx_universal2 runner: macos-latest target: universal2-apple-darwin - container: auto + libc: auto + image: "" installable: true - platform: win_amd64 runner: windows-latest target: x64 - container: auto + libc: auto + image: "" installable: true - platform: win_arm64 runner: windows-11-arm target: aarch64-pc-windows-msvc - container: auto + libc: auto + image: "" installable: true steps: - uses: actions/checkout@v7 @@ -112,16 +130,35 @@ jobs: # musllinux images, which carry their own. - uses: actions/setup-python@v6 with: - python-version: ${{ matrix.interpreter }} + python-version: ${{ matrix.version }} freethreaded: ${{ matrix.freethreaded }} - uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - manylinux: ${{ matrix.container }} + manylinux: ${{ matrix.libc }} + container: ${{ matrix.image || 'auto' }} # Named rather than left to the toolchain file, which the # build container does not read. rust-toolchain: 1.97.1 - args: --release --out dist ${{ matrix.features }} + # The interpreter, named, on the rows that build in a + # container. Left to the path everywhere else, where + # setup-python above put the row's interpreter first. An + # unnamed interpreter is how the first run of this workflow + # built four Linux wheels for the image's own Python 3.12. + args: >- + --release --out dist ${{ matrix.features }} + ${{ matrix.image != '' && format('-i {0}', matrix.inside) || '' }} + # Which interpreters the image carries, which is the first + # thing worth reading when a row builds the wrong wheel, and + # then the one this row asked for, so that a missing + # interpreter fails here rather than further down. The + # extension is a shared object and a static C runtime cannot + # be linked into one, which is the musl default and a no-op + # everywhere else. + before-script-linux: | + ls -1 /opt/python + test -x ${{ matrix.inside }} + export RUSTFLAGS="-C target-feature=-crt-static" # The wheel is named by the interpreter that built it and by # nothing that was checked, so this checks it: the tag has to be # the one this row asked for. A build that fell back to a diff --git a/README.md b/README.md index ff53897..f477876 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,13 @@ The list above is what this client is for. What it does so far is the core of it ## Wheels -Three per platform, which is more than it sounds like it should be and is not optional. The free-threaded CPython build has no stable ABI until 3.15 and [PEP 803](https://peps.python.org/pep-0803/)'s `abi3t`, so 3.14t needs a version-specific wheel of its own. +Three per platform, which is more than it sounds like it should be and is not optional. The free-threaded CPython build has no stable ABI until 3.15 and [PEP 803](https://peps.python.org/pep-0803/)'s `abi3t`, so 3.14t needs a version-specific wheel of its own. From 3.15 one wheel serves both builds and carries both ABI tags, which is what PEP 803 is for and the reason this stops at three. | Tag | Covers | |---|---| | `cp311-abi3` | CPython 3.11 through 3.14, GIL-enabled | | `cp314-cp314t` | free-threaded 3.14 | -| `cp315-abi3t` | 3.15 and every later 3.x, both builds | +| `cp315-abi3.abi3t` | 3.15 and every later 3.x, both builds | Platforms: manylinux_2_28 and musllinux on x86_64 and aarch64, macOS universal2, Windows x64 and arm64. An `sdist` that builds with only a Rust toolchain is published too, and is built back into a wheel in CI. diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 8cd7933..5d2ec66 100644 --- a/tests/test_wheels.py +++ b/tests/test_wheels.py @@ -50,10 +50,28 @@ def test_a_missing_wheel_is_named() -> None: def test_a_missing_abi_is_named_once_per_platform() -> None: - built = [name for name in grid() if "cp315-abi3t" not in name] + built = [name for name in grid() if "cp315-abi3.abi3t" not in name] complaints = wheel_tags.check(built) assert len(complaints) == 7 - assert all("no cp315-abi3t wheel" in complaint for complaint in complaints) + assert all("no cp315-abi3.abi3t wheel" in complaint for complaint in complaints) + + +def test_the_two_abi_tags_of_the_3_15_wheel_are_a_set_and_not_an_order() -> None: + # What maturin writes is `abi3.abi3t`, and a release is not the + # place to care which of the two it wrote first. + built = [name.replace("cp315-abi3.abi3t", "cp315-abi3t.abi3") for name in grid()] + assert wheel_tags.check(built) == [] + + +def test_the_stable_abi_alone_is_not_the_free_threaded_one() -> None: + # `abi3-py315` instead of `abi3t-py315` builds a wheel that installs + # on 3.15 and every later 3.x and refuses to load on the + # free-threaded build of any of them, which is half the row and + # reads in the name as one tag short. + built = [name.replace("cp315-abi3.abi3t", "cp315-abi3") for name in grid()] + complaints = wheel_tags.check(built) + assert len(complaints) == 14 + assert complaints[0].endswith("and nothing else") def test_a_version_specific_wheel_nobody_asked_for_is_refused() -> None: @@ -146,6 +164,22 @@ def test_a_build_that_found_the_wrong_interpreter_is_caught_where_it_happened() ] +def test_a_row_that_built_against_the_wrong_stable_abi_is_caught_too() -> None: + # The 3.15 row asks for both tags and a build that produced one of + # them found `abi3-py315` where `abi3t-py315` was meant, which is a + # wheel the free-threaded interpreter will not load. + built = [f"zudb-{VERSION}-cp315-abi3-manylinux_2_28_x86_64.whl"] + assert wheel_tags.check_one(built, "cp315-abi3.abi3t", "manylinux_2_28_x86_64") == [ + f"zudb-{VERSION}-cp315-abi3-manylinux_2_28_x86_64.whl is a cp315-abi3 wheel " + "and this build asked for cp315-abi3.abi3t" + ] + + +def test_a_row_that_built_both_tags_in_the_other_order_is_the_row_it_asked_for() -> None: + built = [f"zudb-{VERSION}-cp315-abi3t.abi3-win_amd64.whl"] + assert wheel_tags.check_one(built, "cp315-abi3.abi3t", "win_amd64") == [] + + def test_a_build_for_another_platform_is_caught_too() -> None: built = [f"zudb-{VERSION}-cp311-abi3-macosx_11_0_arm64.whl"] assert wheel_tags.check_one(built, "cp311-abi3", "macosx_universal2") == [ diff --git a/tools/wheel_tags.py b/tools/wheel_tags.py index 6ebf43b..0889dec 100644 --- a/tools/wheel_tags.py +++ b/tools/wheel_tags.py @@ -3,7 +3,10 @@ Three wheels per platform, which is more than it sounds like it should be and is not optional: the free-threaded build has no stable ABI until CPython 3.15 and PEP 803's `abi3t`, so 3.14t needs a version-specific -wheel of its own. Three, and no more than three. A version-specific +wheel of its own. From 3.15 one wheel serves both builds and says so in +two ABI tags at once, `cp315-abi3.abi3t`, which is the whole point of +PEP 803 and the reason this stops at three rather than growing a fourth. +Three, and no more than three. A version-specific wheel nobody asked for is what happens when a build finds the wrong interpreter and quietly falls back, and the failure is silent in every other way: the wheel installs, it works on the machine that built it, @@ -28,7 +31,7 @@ ABIS = { "cp311-abi3": "the stable ABI, CPython 3.11 through 3.14", "cp314-cp314t": "free-threaded 3.14, which has no stable ABI", - "cp315-abi3t": "the free-threaded stable ABI, 3.15 and every later 3.x", + "cp315-abi3.abi3t": "the stable ABI from 3.15, GIL-enabled and free-threaded at once", } #: The platforms, as patterns rather than strings, because the macOS @@ -52,6 +55,30 @@ WHEEL = re.compile(r"^zudb-[^-]+-([^-]+-[^-]+)-(.+)\.whl$") +def members(tag: str) -> tuple[str, frozenset[str]]: + """A `{python tag}-{abi tag}` as what it means: the interpreter it + starts from, and the set of ABIs it claims.""" + python, _, abis = tag.partition("-") + return python, frozenset(abis.split(".")) + + +def abi_of(tag: str) -> str | None: + """Which ABI a tag is, or `None` for one this release does not build. + + An ABI tag is a set, dots between its members, the same way a + platform tag is, and PEP 803's wheel is where that stops being a + detail: it arrives as `cp315-abi3.abi3t`, which is one wheel saying + it is the stable ABI for the GIL-enabled build and for the + free-threaded build at once. Which member maturin writes first is + not something to hold a release to, so the set is what is compared, + and a wheel that claims only one of the two is not this one. + """ + for name in ABIS: + if members(tag) == members(name): + return name + return None + + def platform_of(tag: str) -> str | None: """Which platform a tag is, or `None` for one that is no platform this release builds for. @@ -85,7 +112,8 @@ def check(names: list[str]) -> list[str]: complaints.append(f"{name} is neither a wheel of this project nor its sdist") continue abi, platform = found.group(1), found.group(2) - if abi not in ABIS: + which = abi_of(abi) + if which is None: wanted = ", ".join(ABIS) complaints.append( f"{name} is a {abi} wheel, and this release builds {wanted} and nothing else" @@ -97,7 +125,7 @@ def check(names: list[str]) -> list[str]: f"{name} is for {platform}, which is not a platform this release builds for" ) continue - built.setdefault((abi, where), []).append(name) + built.setdefault((which, where), []).append(name) for abi, what in ABIS.items(): for platform in PLATFORMS: @@ -133,7 +161,7 @@ def check_one(names: list[str], abi: str, platform: str) -> list[str]: found = WHEEL.fullmatch(name) if not found: complaints.append(f"{name} is not a wheel of this project") - elif found.group(1) != abi: + elif abi_of(found.group(1)) != abi: complaints.append(f"{name} is a {found.group(1)} wheel and this build asked for {abi}") elif platform_of(found.group(2)) != platform: complaints.append(f"{name} is for {found.group(2)} and this build asked for {platform}")