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
4 changes: 2 additions & 2 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixed/wheel-platform-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix wheel artifact validation for manylinux platform tag normalization.
59 changes: 54 additions & 5 deletions interfaces/python/tests/test_validate_wheel_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
63 changes: 57 additions & 6 deletions scripts/validate_wheel_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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)],
Expand All @@ -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():
Expand Down