Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

1016 lines
37 KiB
Python

"""
Tests for the wheel splitter.
Unit tests verify metadata parsing and generation without requiring a toolchain.
Integration tests use real fat binaries from test_assets to verify the full
split pipeline.
"""
import hashlib
import os
import shutil
import stat
import zipfile
from pathlib import Path
import pytest
from rocm_kpack.wheel_splitter import (
FatBinaryInfo,
InvalidWheelError,
NoFatBinariesError,
SplitResult,
WheelIdentity,
WheelSplitter,
WheelSplitError,
_arch_to_bundle_key,
_bundle_key_to_dist_name,
compute_dist_info_name,
compute_wheel_filename,
generate_device_metadata,
generate_record,
generate_wheel_file,
parse_wheel_identity,
zip_wheel,
)
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_wheel(tmp_path: Path):
"""Create a minimal mock wheel directory structure (no fat binaries)."""
wheel_dir = tmp_path / "wheel"
wheel_dir.mkdir()
# Create torch/ overlay root with some files
torch_dir = wheel_dir / "torch"
torch_dir.mkdir()
(torch_dir / "__init__.py").write_text("# torch\n")
lib_dir = torch_dir / "lib"
lib_dir.mkdir()
(lib_dir / "libtorch_cpu.so").write_bytes(b"\x00" * 100)
# Create dist-info
dist_info = wheel_dir / "torch-2.10.0+rocm7.1.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text(
"Metadata-Version: 2.4\n"
"Name: torch\n"
"Version: 2.10.0+rocm7.1\n"
"Summary: Tensors and Dynamic neural networks\n"
)
(dist_info / "WHEEL").write_text(
"Wheel-Version: 1.0\n"
"Generator: setuptools 80.9.0\n"
"Root-Is-Purelib: false\n"
"Tag: cp313-cp313-manylinux_2_28_x86_64\n"
)
(dist_info / "RECORD").write_text("")
(dist_info / "top_level.txt").write_text("torch\n")
return wheel_dir
@pytest.fixture
def fat_binary_wheel(tmp_path: Path, test_assets_dir: Path):
"""Create a mock wheel directory with a real fat binary from test assets."""
wheel_dir = tmp_path / "fat_wheel"
wheel_dir.mkdir()
# Create torch/ overlay root
torch_dir = wheel_dir / "torch"
torch_dir.mkdir()
(torch_dir / "__init__.py").write_text("# torch\n")
lib_dir = torch_dir / "lib"
lib_dir.mkdir()
# Copy a real fat binary as a simulated torch library
source_binary = (
test_assets_dir / "bundled_binaries/linux/cov5/libtest_kernel_single.so"
)
if not source_binary.exists():
pytest.skip("Test assets not available")
shutil.copy2(source_binary, lib_dir / "libfat_test.so")
# Also add a non-fat binary
(lib_dir / "libplain.so").write_bytes(b"\x00" * 100)
# Create dist-info
dist_info = wheel_dir / "torch-2.10.0+rocm7.1.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text(
"Metadata-Version: 2.4\n"
"Name: torch\n"
"Version: 2.10.0+rocm7.1\n"
"Summary: Tensors and Dynamic neural networks\n"
)
(dist_info / "WHEEL").write_text(
"Wheel-Version: 1.0\n"
"Generator: setuptools 80.9.0\n"
"Root-Is-Purelib: false\n"
"Tag: cp313-cp313-manylinux_2_28_x86_64\n"
)
(dist_info / "RECORD").write_text("")
(dist_info / "top_level.txt").write_text("torch\n")
return wheel_dir
# =============================================================================
# Unit tests — no toolchain needed
# =============================================================================
class TestParseWheelIdentity:
def test_basic(self, mock_wheel: Path):
identity = parse_wheel_identity(mock_wheel)
assert identity.name == "torch"
assert identity.version == "2.10.0+rocm7.1"
assert identity.python_tag == "cp313"
assert identity.abi_tag == "cp313"
assert identity.platform_tag == "manylinux_2_28_x86_64"
assert identity.dist_info_name == "torch-2.10.0+rocm7.1.dist-info"
def test_no_dist_info(self, tmp_path: Path):
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
with pytest.raises(InvalidWheelError, match="Expected exactly one .dist-info"):
parse_wheel_identity(empty_dir)
def test_multiple_dist_info(self, tmp_path: Path):
wheel_dir = tmp_path / "multi"
wheel_dir.mkdir()
(wheel_dir / "a-1.0.dist-info").mkdir()
(wheel_dir / "b-2.0.dist-info").mkdir()
with pytest.raises(InvalidWheelError, match="Expected exactly one .dist-info"):
parse_wheel_identity(wheel_dir)
def test_missing_metadata(self, tmp_path: Path):
wheel_dir = tmp_path / "no_meta"
wheel_dir.mkdir()
dist_info = wheel_dir / "pkg-1.0.dist-info"
dist_info.mkdir()
(dist_info / "WHEEL").write_text("Tag: cp313-cp313-linux_x86_64\n")
with pytest.raises(InvalidWheelError, match="No METADATA"):
parse_wheel_identity(wheel_dir)
def test_missing_wheel_file(self, tmp_path: Path):
wheel_dir = tmp_path / "no_wheel"
wheel_dir.mkdir()
dist_info = wheel_dir / "pkg-1.0.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text("Name: pkg\nVersion: 1.0\n")
with pytest.raises(InvalidWheelError, match="No WHEEL"):
parse_wheel_identity(wheel_dir)
class TestComputeWheelFilename:
def test_basic(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
filename = compute_wheel_filename(
"amd-torch-device-gfx942", "2.10.0+rocm7.1", identity
)
assert filename == (
"amd_torch_device_gfx942-2.10.0+rocm7.1"
"-cp313-cp313-manylinux_2_28_x86_64.whl"
)
def test_underscore_normalization(self):
identity = WheelIdentity(
name="pkg",
version="1.0",
python_tag="py3",
abi_tag="none",
platform_tag="any",
dist_info_name="pkg-1.0.dist-info",
)
filename = compute_wheel_filename("my-package", "1.0", identity)
assert filename == "my_package-1.0-py3-none-any.whl"
class TestComputeDistInfoName:
def test_basic(self):
assert (
compute_dist_info_name("amd-torch-device-gfx942", "2.10.0+rocm7.1")
== "amd_torch_device_gfx942-2.10.0+rocm7.1.dist-info"
)
class TestGenerateDeviceMetadata:
def test_target_level(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
metadata = generate_device_metadata(identity, "gfx942", "amd-torch-device", [])
assert "Name: amd-torch-device-gfx942" in metadata
assert "Version: 2.10.0+rocm7.1" in metadata
assert "Requires-Dist: torch == 2.10.0+rocm7.1" in metadata
def test_family_level(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
metadata = generate_device_metadata(identity, "gfx11", "amd-torch-device", [])
assert "Name: amd-torch-device-gfx11" in metadata
def test_sub_family_level(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
metadata = generate_device_metadata(identity, "gfx12_0", "amd-torch-device", [])
# gfx12_0 becomes gfx12-0 in dist name (underscore to hyphen)
assert "Name: amd-torch-device-gfx12-0" in metadata
def test_gfx11_sub_family_level(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
metadata = generate_device_metadata(identity, "gfx110x", "amd-torch-device", [])
assert "Name: amd-torch-device-gfx110x" in metadata
assert "Summary: AMD device kernels (RDNA3)" in metadata
def test_with_device_requires_dist(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
metadata = generate_device_metadata(
identity,
"gfx942",
"amd-torch-device",
["rocm-sdk-device-@GFXARCH@ == 7.1"],
)
assert "Requires-Dist: rocm-sdk-device-gfx942 == 7.1" in metadata
def test_gfxarch_placeholder_expansion(self):
identity = WheelIdentity(
name="torch",
version="1.0",
python_tag="py3",
abi_tag="none",
platform_tag="any",
dist_info_name="torch-1.0.dist-info",
)
metadata = generate_device_metadata(
identity,
"gfx1100",
"amd-torch-device",
["some-dep-@GFXARCH@ >= 2.0"],
)
assert "Requires-Dist: some-dep-gfx1100 >= 2.0" in metadata
def test_gfxarch_dep_dropped_for_family(self):
# @GFXARCH@-templated deps name per-target packages (e.g.
# rocm-sdk-device-<target>) which only exist for TARGET-level
# bundles. Family/sub-family device wheels are co-installed with a
# target wheel that already carries those deps, so the line must be
# dropped here.
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.13",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.13.dist-info",
)
family_metadata = generate_device_metadata(
identity,
"gfx11",
"amd-torch-device",
["rocm-sdk-device-@GFXARCH@ == 7.13", "static-dep == 1.0"],
)
assert "rocm-sdk-device" not in family_metadata
assert "Requires-Dist: static-dep == 1.0" in family_metadata
sub_family_metadata = generate_device_metadata(
identity,
"gfx12_0",
"amd-torch-device",
["rocm-sdk-device-@GFXARCH@ == 7.13"],
)
assert "rocm-sdk-device" not in sub_family_metadata
class TestArchToBundleKey:
def test_bare_arch(self):
assert _arch_to_bundle_key("gfx942") == "gfx942"
def test_xnack_plus(self):
assert _arch_to_bundle_key("gfx942:xnack+") == "gfx942"
def test_xnack_minus(self):
assert _arch_to_bundle_key("gfx90a:xnack-") == "gfx90a"
def test_no_xnack(self):
assert _arch_to_bundle_key("gfx1100") == "gfx1100"
def test_family_key(self):
assert _arch_to_bundle_key("gfx11") == "gfx11"
def test_sub_family_key(self):
assert _arch_to_bundle_key("gfx12_0") == "gfx12_0"
class TestBundleKeyToDistName:
def test_target_level(self):
assert (
_bundle_key_to_dist_name("amd-torch-device", "gfx942")
== "amd-torch-device-gfx942"
)
def test_family_level(self):
assert (
_bundle_key_to_dist_name("amd-torch-device", "gfx11")
== "amd-torch-device-gfx11"
)
def test_sub_family_level(self):
assert (
_bundle_key_to_dist_name("amd-torch-device", "gfx110x")
== "amd-torch-device-gfx110x"
)
def test_structural_sub_family_level(self):
# Underscore in bundle key becomes hyphen in dist name.
assert (
_bundle_key_to_dist_name("amd-torch-device", "gfx12_0")
== "amd-torch-device-gfx12-0"
)
def test_various_targets(self):
cases = [
("gfx900", "amd-torch-device-gfx900"),
("gfx90a", "amd-torch-device-gfx90a"),
("gfx1100", "amd-torch-device-gfx1100"),
("gfx1151", "amd-torch-device-gfx1151"),
("gfx9", "amd-torch-device-gfx9"),
("gfx9_4", "amd-torch-device-gfx9-4"),
]
for bundle_key, expected in cases:
assert (
_bundle_key_to_dist_name("amd-torch-device", bundle_key) == expected
), f"Failed for {bundle_key}"
class TestRewriteHostMetadata:
"""Test _rewrite_host_metadata via WheelSplitter."""
def _make_host_staging(self, tmp_path: Path) -> tuple[Path, WheelIdentity]:
"""Create a minimal host staging directory with METADATA."""
staging = tmp_path / "host_staging"
staging.mkdir()
dist_info = staging / "torch-2.10.0+rocm7.1.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text(
"Metadata-Version: 2.4\n"
"Name: torch\n"
"Version: 2.10.0+rocm7.1\n"
"Summary: Tensors and Dynamic neural networks\n"
"\n"
"A description body that comes after the header section.\n"
)
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
return staging, identity
def _make_splitter(self) -> WheelSplitter:
from rocm_kpack.binutils import Toolchain
return WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=Toolchain(),
)
def test_rocm_bootstrap_dep(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._rewrite_host_metadata(staging, identity, {"gfx942"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
assert "Requires-Dist: rocm-bootstrap" in metadata
def test_extras_for_single_target(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
# gfx942 packaging chain: gfx942 -> gfx9_4 -> gfx9
# Only gfx942 has a device wheel (is in bundle_keys)
splitter._rewrite_host_metadata(staging, identity, {"gfx942"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
assert "Provides-Extra: device-gfx942" in metadata
assert (
'Requires-Dist: amd-torch-device-gfx942 == 2.10.0+rocm7.1; extra == "device-gfx942"'
in metadata
)
assert "Provides-Extra: device-all" in metadata
def test_classic_has_no_variant_markers(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._rewrite_host_metadata(staging, identity, {"gfx942"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
assert "variant_properties" not in metadata
def test_variant_markers_when_enabled(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._rewrite_host_metadata(
staging, identity, {"gfx942"}, include_variant_markers=True
)
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
assert (
"Requires-Dist: amd-torch-device-gfx942 == 2.10.0+rocm7.1; "
'"amd :: gfx_arch :: gfx942" in variant_properties'
) in metadata
def test_chain_with_family_wheel(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
# gfx1100 chain: gfx1100 -> gfx110x -> gfx11
# If gfx11 and gfx1100 both have device wheels:
splitter._rewrite_host_metadata(staging, identity, {"gfx1100", "gfx11"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
# Both should appear as deps under the gfx1100 extra
assert (
'Requires-Dist: amd-torch-device-gfx1100 == 2.10.0+rocm7.1; extra == "device-gfx1100"'
in metadata
)
assert (
'Requires-Dist: amd-torch-device-gfx11 == 2.10.0+rocm7.1; extra == "device-gfx1100"'
in metadata
)
# "device-all" should include both
assert (
'Requires-Dist: amd-torch-device-gfx11 == 2.10.0+rocm7.1; extra == "device-all"'
in metadata
)
assert (
'Requires-Dist: amd-torch-device-gfx1100 == 2.10.0+rocm7.1; extra == "device-all"'
in metadata
)
def test_chain_with_gfx110x_wheel(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._rewrite_host_metadata(staging, identity, {"gfx1100", "gfx110x"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
assert (
'Requires-Dist: amd-torch-device-gfx1100 == 2.10.0+rocm7.1; extra == "device-gfx1100"'
in metadata
)
assert (
'Requires-Dist: amd-torch-device-gfx110x == 2.10.0+rocm7.1; extra == "device-gfx1100"'
in metadata
)
assert (
'Requires-Dist: amd-torch-device-gfx110x == 2.10.0+rocm7.1; extra == "device-all"'
in metadata
)
def test_family_only_not_target_extra(self, tmp_path: Path):
"""Family-level bundle keys don't get their own Provides-Extra."""
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
# gfx11 is a family, not a target - no Provides-Extra for it directly
splitter._rewrite_host_metadata(staging, identity, {"gfx11"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
# No target-level extras since gfx11 is a family
assert "Provides-Extra: device-gfx11" not in metadata
# But "device-all" should still include it
assert "Provides-Extra: device-all" in metadata
assert (
'Requires-Dist: amd-torch-device-gfx11 == 2.10.0+rocm7.1; extra == "device-all"'
in metadata
)
def test_preserves_existing_metadata(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._rewrite_host_metadata(staging, identity, {"gfx942"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
# Original fields should still be there
assert "Name: torch" in metadata
assert "Version: 2.10.0+rocm7.1" in metadata
assert "Summary: Tensors and Dynamic neural networks" in metadata
def test_headers_before_body(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._rewrite_host_metadata(staging, identity, {"gfx942"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
# Split into header and body at the first blank line
header_body = metadata.split("\n\n", 1)
assert len(header_body) == 2, "METADATA should have header and body sections"
header_section, body_section = header_body
# All injected headers must be in the header section, not the body
assert "Requires-Dist: rocm-bootstrap" in header_section
assert "Provides-Extra: device-gfx942" in header_section
assert "Provides-Extra: device-all" in header_section
# Body should still contain the description
assert "description body" in body_section
class TestVariantWheel:
"""Tests for PEP 817 variant wheel generation."""
def _make_host_staging(self, tmp_path: Path) -> tuple[Path, WheelIdentity]:
staging = tmp_path / "host_staging"
staging.mkdir()
dist_info = staging / "torch-2.10.0+rocm7.1.dist-info"
dist_info.mkdir()
# Write a classic-rewritten METADATA (extras only, no variant markers)
(dist_info / "METADATA").write_text(
"Metadata-Version: 2.4\n"
"Name: torch\n"
"Version: 2.10.0+rocm7.1\n"
"Requires-Dist: rocm-bootstrap\n"
"Provides-Extra: device-gfx942\n"
'Requires-Dist: amd-torch-device-gfx942 == 2.10.0+rocm7.1; extra == "device-gfx942"\n'
"Provides-Extra: device-all\n"
'Requires-Dist: amd-torch-device-gfx942 == 2.10.0+rocm7.1; extra == "device-all"\n'
"\n"
"Description body.\n"
)
(dist_info / "WHEEL").write_text(
"Wheel-Version: 1.0\nTag: cp313-cp313-manylinux_2_28_x86_64\n"
)
(dist_info / "RECORD").write_text("")
# Add a dummy file so the wheel isn't empty
(staging / "torch").mkdir()
(staging / "torch" / "__init__.py").write_text("")
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
return staging, identity
def _make_splitter(self) -> WheelSplitter:
from rocm_kpack.binutils import Toolchain
return WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=Toolchain(),
generate_variant_wheel=True,
)
def test_add_variant_markers(self, tmp_path: Path):
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._add_variant_markers_to_metadata(staging, identity, {"gfx942"})
metadata = (staging / identity.dist_info_name / "METADATA").read_text()
# Should have the extras line AND the variant marker line
assert 'extra == "device-gfx942"' in metadata
assert ('"amd :: gfx_arch :: gfx942" in variant_properties') in metadata
# "device-all" extra should NOT get variant markers
assert '"amd :: gfx_arch :: all"' not in metadata
def test_variant_json(self, tmp_path: Path):
import json
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
splitter._write_variant_json(staging, identity, {"gfx942", "gfx11"})
variant_path = staging / identity.dist_info_name / "variant.json"
assert variant_path.exists()
data = json.loads(variant_path.read_text())
assert "providers" in data
assert "amd" in data["providers"]
assert "variants" in data
# gfx942 is a target, gfx11 is a family — only targets get variants
assert "gfx942" in data["variants"]
assert "gfx11" not in data["variants"]
def test_create_variant_wheel_directory(self, tmp_path: Path):
import json
staging, identity = self._make_host_staging(tmp_path)
splitter = self._make_splitter()
output_dir = tmp_path / "output"
output_dir.mkdir()
variant_path = splitter._create_variant_wheel(
staging, identity, {"gfx942"}, output_dir, "directory"
)
assert variant_path.exists()
assert "variant" in variant_path.name
# Check METADATA has variant markers
metadata = (variant_path / identity.dist_info_name / "METADATA").read_text()
assert "variant_properties" in metadata
assert 'extra == "device-gfx942"' in metadata
# Check variant.json exists
variant_json = variant_path / identity.dist_info_name / "variant.json"
assert variant_json.exists()
data = json.loads(variant_json.read_text())
assert "gfx942" in data["variants"]
class TestGenerateWheelFile:
def test_basic(self):
identity = WheelIdentity(
name="torch",
version="2.10.0+rocm7.1",
python_tag="cp313",
abi_tag="cp313",
platform_tag="manylinux_2_28_x86_64",
dist_info_name="torch-2.10.0+rocm7.1.dist-info",
)
content = generate_wheel_file(identity)
assert "Wheel-Version: 1.0" in content
assert "Tag: cp313-cp313-manylinux_2_28_x86_64" in content
assert "Root-Is-Purelib: false" in content
assert "Generator: rocm-kpack-split-python-wheels" in content
class TestGenerateRecord:
def test_basic(self, tmp_path: Path):
wheel_dir = tmp_path / "wheel"
wheel_dir.mkdir()
dist_info = wheel_dir / "pkg-1.0.dist-info"
dist_info.mkdir()
# Create a file with known content
test_content = b"hello world"
(wheel_dir / "test.py").write_bytes(test_content)
expected_hash = hashlib.sha256(test_content).hexdigest()
# Create empty RECORD
(dist_info / "RECORD").write_text("")
record = generate_record(wheel_dir, "pkg-1.0.dist-info")
lines = record.strip().split("\n")
# Should have 2 entries: test.py and RECORD itself
assert len(lines) == 2
# RECORD entry has no hash
record_line = [l for l in lines if "RECORD" in l][0]
assert record_line == "pkg-1.0.dist-info/RECORD,,"
# test.py has hash
test_line = [l for l in lines if "test.py" in l][0]
assert f"sha256={expected_hash}" in test_line
assert f",{len(test_content)}" in test_line
class TestWheelArchivePermissions:
def test_extract_and_repack_preserves_exact_executable_mode(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from rocm_kpack.binutils import Toolchain
input_wheel = tmp_path / "input.whl"
executable_path = "torch/bin/torch_shm_manager"
plain_path = "torch/__init__.py"
extracted_root = input_wheel.parent / (input_wheel.stem + ".tmp_extract")
chmod_calls: dict[str, int] = {}
path_type = type(tmp_path)
original_chmod = path_type.chmod
def recording_chmod(self: Path, mode: int) -> None:
chmod_calls[self.relative_to(extracted_root).as_posix()] = mode
if os.name != "nt":
original_chmod(self, mode)
with zipfile.ZipFile(input_wheel, "w") as zf:
executable_info = zipfile.ZipInfo(executable_path)
executable_info.external_attr = (stat.S_IFREG | 0o751) << 16
zf.writestr(executable_info, b"#!/bin/sh\n")
plain_info = zipfile.ZipInfo(plain_path)
plain_info.external_attr = (stat.S_IFREG | 0o640) << 16
zf.writestr(plain_info, b"# torch\n")
monkeypatch.setattr(path_type, "chmod", recording_chmod)
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=Toolchain(),
)
extracted_dir, is_temporary = splitter._resolve_input(input_wheel)
assert is_temporary
try:
extracted_executable = extracted_dir / executable_path
if os.name != "nt":
assert stat.S_IMODE(extracted_executable.stat().st_mode) == 0o751
assert (
stat.S_IMODE((extracted_dir / plain_path).stat().st_mode) == 0o640
)
output_wheel = tmp_path / "output.whl"
zip_wheel(extracted_dir, output_wheel)
finally:
shutil.rmtree(extracted_dir)
assert chmod_calls[executable_path] == 0o751
assert chmod_calls[plain_path] == 0o640
if os.name == "nt":
return
with zipfile.ZipFile(output_wheel, "r") as zf:
output_executable_mode = stat.S_IMODE(
zf.getinfo(executable_path).external_attr >> 16
)
output_plain_mode = stat.S_IMODE(zf.getinfo(plain_path).external_attr >> 16)
assert output_executable_mode == 0o751
assert output_plain_mode == 0o640
class TestKpackSearchPattern:
"""Test the search pattern computation logic."""
def _compute(self, overlay_relative_path: str, group_name: str) -> str:
"""Helper to call the private method via a throwaway splitter."""
from rocm_kpack.binutils import Toolchain
splitter = WheelSplitter(
device_package_prefix="test",
overlay_root="torch/",
toolchain=Toolchain(),
)
return splitter._compute_kpack_search_pattern(overlay_relative_path, group_name)
def test_binary_in_subdir(self):
# lib/libtorch_hip.so -> depth=1 -> ../.kpack/torch_@GFXARCH@.kpack
pattern = self._compute("lib/libtorch_hip.so", "torch")
assert pattern == "../.kpack/torch_@GFXARCH@.kpack"
def test_binary_at_root(self):
# libtorch_hip.so -> depth=0 -> .kpack/torch_@GFXARCH@.kpack
pattern = self._compute("libtorch_hip.so", "torch")
assert pattern == ".kpack/torch_@GFXARCH@.kpack"
def test_binary_deeply_nested(self):
# a/b/c/binary.so -> depth=3 -> ../../../.kpack/torch_@GFXARCH@.kpack
pattern = self._compute("a/b/c/binary.so", "torch")
assert pattern == "../../../.kpack/torch_@GFXARCH@.kpack"
# =============================================================================
# Integration tests — require toolchain and test assets
# =============================================================================
class TestWheelSplitterIntegration:
"""Integration tests that use real fat binaries from test_assets."""
def test_split_directory_to_directory(
self, fat_binary_wheel: Path, toolchain, tmp_path: Path
):
"""Test splitting an exploded wheel to exploded output directories."""
output_dir = tmp_path / "output"
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=toolchain,
verbose=True,
)
result = splitter.split(fat_binary_wheel, output_dir, output_format="directory")
# Should have found architectures
assert len(result.architectures_found) > 0
assert result.fat_binaries_processed == 1
# Host wheel directory should exist
assert result.host_wheel_path.exists()
assert result.host_wheel_path.is_dir()
# Host should have the original files (minus device code)
host_init = result.host_wheel_path / "torch" / "__init__.py"
assert host_init.exists()
# Host fat binary should still exist but be transformed
host_fat = result.host_wheel_path / "torch" / "lib" / "libfat_test.so"
assert host_fat.exists()
# Host RECORD should exist and be non-empty
host_record = (
result.host_wheel_path / "torch-2.10.0+rocm7.1.dist-info" / "RECORD"
)
assert host_record.exists()
assert host_record.stat().st_size > 0
# Device wheels should exist for each bundle key found
for bundle_key, device_path in result.device_wheel_paths.items():
assert device_path.exists()
assert device_path.is_dir()
# Device wheel should have at least one kpack file
kpack_dir = device_path / "torch" / ".kpack"
kpack_files = list(kpack_dir.glob("*.kpack"))
assert len(kpack_files) > 0
for kf in kpack_files:
assert kf.stat().st_size > 0
# Device wheel should have dist-info with correct naming
device_name = _bundle_key_to_dist_name("amd-torch-device", bundle_key)
device_dist_info_name = (
f"{device_name.replace('-', '_')}-2.10.0+rocm7.1.dist-info"
)
device_dist_info = device_path / device_dist_info_name
assert device_dist_info.exists()
# Check METADATA
metadata = (device_dist_info / "METADATA").read_text()
assert f"Name: {device_name}" in metadata
assert "Requires-Dist: torch == 2.10.0+rocm7.1" in metadata
# Check WHEEL
wheel_content = (device_dist_info / "WHEEL").read_text()
assert "Tag: cp313-cp313-manylinux_2_28_x86_64" in wheel_content
# Check RECORD
record = (device_dist_info / "RECORD").read_text()
assert ".kpack/" in record
def test_split_no_fat_binaries(self, mock_wheel: Path, toolchain, tmp_path: Path):
"""Test that splitting a wheel with no fat binaries raises an error."""
output_dir = tmp_path / "output"
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=toolchain,
)
with pytest.raises(NoFatBinariesError):
splitter.split(mock_wheel, output_dir)
def test_split_invalid_overlay_root(
self, mock_wheel: Path, toolchain, tmp_path: Path
):
"""Test that an invalid overlay root raises an error."""
output_dir = tmp_path / "output"
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="nonexistent/",
toolchain=toolchain,
)
with pytest.raises(InvalidWheelError, match="Overlay root"):
splitter.split(mock_wheel, output_dir)
def test_host_record_integrity(
self, fat_binary_wheel: Path, toolchain, tmp_path: Path
):
"""Verify that the host wheel's RECORD has valid hashes."""
output_dir = tmp_path / "output"
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=toolchain,
)
result = splitter.split(fat_binary_wheel, output_dir, output_format="directory")
host_dir = result.host_wheel_path
dist_info_name = "torch-2.10.0+rocm7.1.dist-info"
record_path = host_dir / dist_info_name / "RECORD"
record_text = record_path.read_text()
for line in record_text.strip().split("\n"):
if not line:
continue
parts = line.split(",")
rel_path = parts[0]
if parts[1] == "":
# RECORD itself — no hash
assert rel_path.endswith("RECORD")
continue
# Verify hash
hash_part = parts[1]
size_part = parts[2]
assert hash_part.startswith("sha256=")
expected_hash = hash_part.removeprefix("sha256=")
file_path = host_dir / rel_path
assert file_path.exists(), f"RECORD references missing file: {rel_path}"
actual_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
assert actual_hash == expected_hash, f"Hash mismatch for {rel_path}"
assert int(size_part) == file_path.stat().st_size
def test_device_requires_dist(
self, fat_binary_wheel: Path, toolchain, tmp_path: Path
):
"""Test that --device-requires-dist with @GFXARCH@ expands correctly."""
output_dir = tmp_path / "output"
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=toolchain,
device_requires_dist=["rocm-sdk-device-@GFXARCH@ == 7.1"],
)
result = splitter.split(fat_binary_wheel, output_dir, output_format="directory")
for bundle_key, device_path in result.device_wheel_paths.items():
device_name = _bundle_key_to_dist_name("amd-torch-device", bundle_key)
dist_info_name = f"{device_name.replace('-', '_')}-2.10.0+rocm7.1.dist-info"
metadata = (device_path / dist_info_name / "METADATA").read_text()
assert f"Requires-Dist: rocm-sdk-device-{bundle_key} == 7.1" in metadata
def test_split_parallel(self, fat_binary_wheel: Path, toolchain, tmp_path: Path):
"""Test that parallel splitting (jobs=2) produces correct results."""
output_dir = tmp_path / "output"
splitter = WheelSplitter(
device_package_prefix="amd-torch-device",
overlay_root="torch/",
toolchain=toolchain,
verbose=True,
jobs=2,
)
result = splitter.split(fat_binary_wheel, output_dir, output_format="directory")
# Same assertions as test_split_directory_to_directory
assert len(result.architectures_found) > 0
assert result.fat_binaries_processed == 1
assert result.host_wheel_path.exists()
assert result.host_wheel_path.is_dir()
host_fat = result.host_wheel_path / "torch" / "lib" / "libfat_test.so"
assert host_fat.exists()
host_record = (
result.host_wheel_path / "torch-2.10.0+rocm7.1.dist-info" / "RECORD"
)
assert host_record.exists()
assert host_record.stat().st_size > 0
for bundle_key, device_path in result.device_wheel_paths.items():
assert device_path.exists()
kpack_dir = device_path / "torch" / ".kpack"
kpack_files = list(kpack_dir.glob("*.kpack"))
assert len(kpack_files) > 0
for kf in kpack_files:
assert kf.stat().st_size > 0