diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index eb99397..cc66dd7 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -13,12 +13,12 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - wheel_platform: manylinux_2_17_x86_64.manylinux2014_x86_64 + wheel_platform: manylinux2014_x86_64.manylinux_2_17_x86_64 container: quay.io/pypa/manylinux_2_28_x86_64 macos_deployment_target: "" - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - wheel_platform: manylinux_2_17_aarch64.manylinux2014_aarch64 + wheel_platform: manylinux2014_aarch64.manylinux_2_17_aarch64 container: quay.io/pypa/manylinux_2_28_aarch64 macos_deployment_target: "" - os: macos-15-intel diff --git a/changelog.d/fixed/wheel-platform-validation.md b/changelog.d/fixed/wheel-platform-validation.md new file mode 100644 index 0000000..a8a8701 --- /dev/null +++ b/changelog.d/fixed/wheel-platform-validation.md @@ -0,0 +1 @@ +Fix wheel artifact validation for manylinux platform tag normalization. diff --git a/interfaces/python/tests/test_validate_wheel_artifact.py b/interfaces/python/tests/test_validate_wheel_artifact.py index 61cd021..210fa00 100644 --- a/interfaces/python/tests/test_validate_wheel_artifact.py +++ b/interfaces/python/tests/test_validate_wheel_artifact.py @@ -15,13 +15,37 @@ _SPEC.loader.exec_module(validator) -def _write_wheel(dist_dir: Path, filename: str, tag: str) -> Path: - dist_dir.mkdir() +BINARY_DESCRIPTIONS_BY_TARGET = { + "x86_64-unknown-linux-gnu": "ELF 64-bit LSB executable, x86-64", + "aarch64-unknown-linux-gnu": "ELF 64-bit LSB executable, ARM aarch64", + "x86_64-apple-darwin": "Mach-O 64-bit executable x86_64", + "aarch64-apple-darwin": "Mach-O 64-bit executable arm64", +} + + +def _publish_wheel_targets() -> list[tuple[str, str]]: + yaml = pytest.importorskip("yaml") + workflow = yaml.safe_load((_REPO / ".github/workflows/publish-pypi.yml").read_text()) + matrix = workflow["jobs"]["build-wheels"]["strategy"]["matrix"]["include"] + return [(entry["target"], entry["wheel_platform"]) for entry in matrix] + + +def _write_wheel(dist_dir: Path, filename: str, tags: str | list[str]) -> Path: + dist_dir.mkdir(parents=True) wheel_path = dist_dir / filename + if isinstance(tags, str): + tags = [tags] + metadata = "\n".join( + [ + "Wheel-Version: 1.0", + "Root-Is-Purelib: false", + *[f"Tag: {tag}" for tag in tags], + ] + ) with zipfile.ZipFile(wheel_path, "w") as wheel: wheel.writestr( "policyengine_uk_compiled-0.30.0.dist-info/WHEEL", - f"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: {tag}\n", + f"{metadata}\n", ) return wheel_path @@ -33,6 +57,31 @@ def _write_binary(package_dir: Path) -> Path: return binary +def test_validates_all_publish_wheel_targets(tmp_path, monkeypatch): + for target, wheel_platform in _publish_wheel_targets(): + dist_dir = tmp_path / target / "dist" + package_dir = tmp_path / target / "pkg" + _write_wheel( + dist_dir, + "policyengine_uk_compiled-0.30.0-py3-none-" + f"{wheel_platform}.whl", + [f"py3-none-{platform_tag}" for platform_tag in wheel_platform.split(".")], + ) + _write_binary(package_dir) + monkeypatch.setattr( + validator, + "_binary_description", + lambda path, target=target: BINARY_DESCRIPTIONS_BY_TARGET[target], + ) + + validator.validate_wheel_artifact( + dist_dir=dist_dir, + package_dir=package_dir, + target=target, + wheel_platform=wheel_platform, + ) + + def test_validates_matching_macos_x86_64_wheel(tmp_path, monkeypatch): dist_dir = tmp_path / "dist" package_dir = tmp_path / "pkg" @@ -94,7 +143,7 @@ def test_rejects_universal2_tag_for_arm64_only_binary(tmp_path, monkeypatch): lambda path: "Mach-O 64-bit executable arm64", ) - with pytest.raises(ValueError, match="expected tag"): + with pytest.raises(ValueError, match="filename platform tags"): validator.validate_wheel_artifact( dist_dir=dist_dir, package_dir=package_dir, @@ -118,7 +167,7 @@ def test_rejects_wheel_filename_and_metadata_disagreement(tmp_path, monkeypatch) lambda path: "Mach-O 64-bit executable x86_64", ) - with pytest.raises(ValueError, match="metadata tags"): + with pytest.raises(ValueError, match="metadata platform tags"): validator.validate_wheel_artifact( dist_dir=dist_dir, package_dir=package_dir, diff --git a/scripts/validate_wheel_artifact.py b/scripts/validate_wheel_artifact.py index edd7613..8775d67 100644 --- a/scripts/validate_wheel_artifact.py +++ b/scripts/validate_wheel_artifact.py @@ -16,11 +16,44 @@ "aarch64-unknown-linux-gnu": ("aarch64", "arm aarch64"), } +EXPECTED_PYTHON_TAG = "py3" +EXPECTED_ABI_TAG = "none" + def _wheel_files(dist_dir: Path) -> list[Path]: return sorted(dist_dir.glob("*.whl")) +def _split_platform_tags(platform_tag: str) -> set[str]: + return set(platform_tag.split(".")) + + +def _parse_wheel_tag(tag: str) -> set[str]: + parts = tag.split("-", 2) + if len(parts) != 3: + raise ValueError(f"Invalid wheel tag: {tag}") + + python_tag, abi_tag, platform_tag = parts + if python_tag != EXPECTED_PYTHON_TAG or abi_tag != EXPECTED_ABI_TAG: + raise ValueError( + f"Expected wheel tag prefix {EXPECTED_PYTHON_TAG}-{EXPECTED_ABI_TAG}, " + f"got {python_tag}-{abi_tag}" + ) + return _split_platform_tags(platform_tag) + + +def _filename_platform_tags(wheel_path: Path) -> set[str]: + if wheel_path.suffix != ".whl": + raise ValueError(f"Expected a .whl file, got {wheel_path.name}") + + parts = wheel_path.name[:-4].rsplit("-", 3) + if len(parts) != 4: + raise ValueError(f"Invalid wheel filename: {wheel_path.name}") + + _, python_tag, abi_tag, platform_tag = parts + return _parse_wheel_tag(f"{python_tag}-{abi_tag}-{platform_tag}") + + def _wheel_tags(wheel_path: Path) -> list[str]: with zipfile.ZipFile(wheel_path) as wheel: metadata_files = [ @@ -41,6 +74,17 @@ def _wheel_tags(wheel_path: Path) -> list[str]: ] +def _metadata_platform_tags(wheel_path: Path) -> set[str]: + platform_tags: set[str] = set() + tags = _wheel_tags(wheel_path) + if not tags: + raise ValueError(f"{wheel_path.name} metadata has no Tag entries") + + for tag in tags: + platform_tags.update(_parse_wheel_tag(tag)) + return platform_tags + + def _binary_description(binary_path: Path) -> str: result = subprocess.run( ["file", str(binary_path)], @@ -63,13 +107,20 @@ def validate_wheel_artifact( raise ValueError(f"Expected exactly one wheel in {dist_dir}, found {len(wheels)}") wheel = wheels[0] - expected_tag = f"py3-none-{wheel_platform}" - if expected_tag not in wheel.name: - raise ValueError(f"{wheel.name} does not contain expected tag {expected_tag}") + expected_platform_tags = _split_platform_tags(wheel_platform) + filename_platform_tags = _filename_platform_tags(wheel) + if filename_platform_tags != expected_platform_tags: + raise ValueError( + f"{wheel.name} filename platform tags {sorted(filename_platform_tags)} " + f"do not match expected {sorted(expected_platform_tags)}" + ) - tags = _wheel_tags(wheel) - if expected_tag not in tags: - raise ValueError(f"{wheel.name} metadata tags {tags} do not include {expected_tag}") + metadata_platform_tags = _metadata_platform_tags(wheel) + if metadata_platform_tags != expected_platform_tags: + raise ValueError( + f"{wheel.name} metadata platform tags {sorted(metadata_platform_tags)} " + f"do not match expected {sorted(expected_platform_tags)}" + ) binary_path = package_dir / "bin" / "policyengine-uk-rust" if not binary_path.is_file():