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.