From 6a9d51080c8fa7b725d517250f04c59435365971 Mon Sep 17 00:00:00 2001 From: Derek Date: Wed, 23 Sep 2026 12:49:02 +1000 Subject: [PATCH] fix(build): pick the smoke venv from both bounds, not the wheel tag alone The 1.0.8 release failed three of four wheel builds. The wheel is tagged cp312-abi3 and declares Requires-Python >=3.14, so the smoke check built a 3.12 venv from the tag and uv then refused the install: Because the current Python version (3.12.14) does not satisfy Python>=3.14 and vector-vrl==1.0.8 depends on Python>=3.14, we can conclude that vector-vrl==1.0.8 cannot be used. The floor sweep to 3.14 moved requires-python and left pyo3 on abi3-py312. That combination is legitimate -- an abi3 wheel is forward compatible, so its tag is the LOWER of the two bounds -- and the check was reading the lower one as if it were the only one. The venv now satisfies both: the tag version, and the Requires-Python floor read from the wheel's own dist-info METADATA. Reading it from the artefact rather than from pyproject keeps one source of truth, the same file uv will install. Proven on the wheel that failed, downloaded from run 35810804378: tag version (3, 12) Requires-Python (3, 14) venv resolves to 3.14 Eleven tests, each writing a real .whl rather than a mock. Restoring the old one-bound behaviour fails test_the_declared_floor_wins_over_an_abi3_tag with 3.12 against 3.14, reproducing the CI failure; restored immediately. 19 pass. Moving pyo3 to abi3-py314 would also resolve it, and is the larger change: pyo3 is at 0.29.2 and this repo pins 0.22. --- build/tests/test_wheel_smoke_check.py | 85 +++++++++++++++++++++++++++ build/wheel_smoke_check.py | 49 +++++++++++---- 2 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 build/tests/test_wheel_smoke_check.py diff --git a/build/tests/test_wheel_smoke_check.py b/build/tests/test_wheel_smoke_check.py new file mode 100644 index 00000000..284e14a4 --- /dev/null +++ b/build/tests/test_wheel_smoke_check.py @@ -0,0 +1,85 @@ +"""Unit tests for wheel_smoke_check.py's interpreter choice. + +No mocks - each test writes a real .whl (a zip carrying a real dist-info +METADATA) and the code under test reads it exactly as it reads a wheel +maturin produced. The install and the smoke run itself need a real +interpreter and a real compiled extension, so they are exercised by the +CI job that runs this script against the built dist/, not here. +""" + +import zipfile +from pathlib import Path + +import pytest + +from wheel_smoke_check import _python_version_for, _requires_python_floor, _tag_version + + +def _wheel(tmp_path: Path, name: str, requires_python: str | None) -> Path: + """A real wheel file: the filename tag plus a dist-info METADATA.""" + path = tmp_path / name + metadata = "Metadata-Version: 2.1\nName: vector-vrl\nVersion: 1.0.8\n" + if requires_python is not None: + metadata += f"Requires-Python: {requires_python}\n" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("vector_vrl-1.0.8.dist-info/METADATA", metadata) + return path + + +class TestTagVersion: + """The CPython version the filename tag names.""" + + def test_reads_the_tag(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", None) + assert _tag_version(wheel) == (3, 12) + + def test_reads_a_two_digit_minor(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp314-abi3-linux_x86_64.whl", None) + assert _tag_version(wheel) == (3, 14) + + def test_refuses_a_tag_it_cannot_parse(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-py3-none-any.whl", None) + with pytest.raises(ValueError, match="can't parse a CPython version"): + _tag_version(wheel) + + +class TestRequiresPythonFloor: + """The lower bound the wheel's own metadata declares.""" + + def test_reads_the_floor(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", ">=3.14") + assert _requires_python_floor(wheel) == (3, 14) + + def test_tolerates_whitespace(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", ">= 3.14") + assert _requires_python_floor(wheel) == (3, 14) + + def test_none_when_undeclared(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", None) + assert _requires_python_floor(wheel) is None + + def test_none_when_the_bound_has_no_lower_half(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", "<4.0") + assert _requires_python_floor(wheel) is None + + +class TestPythonVersionFor: + """The venv must satisfy both bounds, and the abi3 tag is the lesser one.""" + + def test_the_declared_floor_wins_over_an_abi3_tag(self, tmp_path): + # The 1.0.8 release: abi3-py312 wheel, >=3.14 metadata. Building the + # venv on the tag gave a 3.12 interpreter the wheel then refused. + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", ">=3.14") + assert _python_version_for(wheel) == "3.14" + + def test_the_tag_wins_when_it_is_the_higher_bound(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp314-abi3-linux_x86_64.whl", ">=3.12") + assert _python_version_for(wheel) == "3.14" + + def test_the_tag_stands_alone_when_nothing_is_declared(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp312-abi3-linux_x86_64.whl", None) + assert _python_version_for(wheel) == "3.12" + + def test_equal_bounds_agree(self, tmp_path): + wheel = _wheel(tmp_path, "vector_vrl-1.0.8-cp314-abi3-linux_x86_64.whl", ">=3.14") + assert _python_version_for(wheel) == "3.14" diff --git a/build/wheel_smoke_check.py b/build/wheel_smoke_check.py index 4d4e1d16..fed221e2 100644 --- a/build/wheel_smoke_check.py +++ b/build/wheel_smoke_check.py @@ -14,27 +14,56 @@ import subprocess import sys import tempfile +import zipfile +from email.parser import BytesParser from pathlib import Path _PYTHON_TAG_RE = re.compile(r"^cp(\d)(\d+)$") +_REQUIRES_FLOOR_RE = re.compile(r">=\s*(\d+)\.(\d+)") -def _python_version_for(wheel: Path) -> str: - """The CPython version (e.g. "3.12") a wheel's own filename tag names. - - A wheel is tied to the exact interpreter it was built against unless - tagged abi3 - installing it into a mismatched venv fails outright, and - the CI runner's ambient default Python is not guaranteed to match - whatever maturin-action picked, so this must be read from the wheel - itself rather than assumed. - """ +def _tag_version(wheel: Path) -> tuple[int, int]: + """The CPython version a wheel's own filename tag names, as (major, minor).""" python_tag = wheel.stem.split("-")[2] match = _PYTHON_TAG_RE.match(python_tag) if not match: raise ValueError( f"can't parse a CPython version from wheel tag {python_tag!r} ({wheel.name})" ) - return f"{match.group(1)}.{match.group(2)}" + return int(match.group(1)), int(match.group(2)) + + +def _requires_python_floor(wheel: Path) -> tuple[int, int] | None: + """The lower bound of the wheel's own ``Requires-Python``, if it declares one.""" + with zipfile.ZipFile(wheel) as archive: + names = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")] + if not names: + return None + with archive.open(names[0]) as handle: + metadata = BytesParser().parse(handle, headersonly=True) + declared = metadata.get("Requires-Python") + if not declared: + return None + match = _REQUIRES_FLOOR_RE.search(declared) + return (int(match.group(1)), int(match.group(2))) if match else None + + +def _python_version_for(wheel: Path) -> str: + """The CPython version (e.g. "3.14") to build the throwaway venv on. + + Two constraints, and the venv must satisfy both. The filename tag names + the interpreter a non-abi3 wheel is tied to, and the CI runner's ambient + default is not guaranteed to match whatever maturin-action picked. An + abi3 wheel is forward compatible, so there its tag is a FLOOR rather than + an exact requirement, and the package's own ``Requires-Python`` can sit + above it -- an abi3-py312 wheel declaring >=3.14 refuses to install on + 3.12, which is the wheel telling the truth and the tag being the lesser + of the two bounds. + """ + tag = _tag_version(wheel) + floor = _requires_python_floor(wheel) + major, minor = max(tag, floor) if floor else tag + return f"{major}.{minor}" # Mirrors README.md's worked example and the Vector class golden path.