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
369 lines
14 KiB
Python
369 lines
14 KiB
Python
"""
|
|
Unit tests for artifact_utils module.
|
|
|
|
Tests the common utilities for artifact manipulation including manifest
|
|
handling, directory traversal, and file classification.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from rocm_kpack.artifact_utils import (
|
|
read_artifact_manifest,
|
|
write_artifact_manifest,
|
|
scan_directory,
|
|
is_fat_binary,
|
|
extract_architecture_from_target,
|
|
)
|
|
from rocm_kpack.binutils import Toolchain
|
|
|
|
|
|
class TestArtifactManifest:
|
|
"""Tests for artifact manifest reading and writing."""
|
|
|
|
def test_read_artifact_manifest_simple(self, tmp_path):
|
|
"""Test reading a simple artifact manifest."""
|
|
manifest_path = tmp_path / "artifact_manifest.txt"
|
|
manifest_path.write_text("prefix1/stage\nprefix2/stage\n")
|
|
|
|
prefixes = read_artifact_manifest(tmp_path)
|
|
assert prefixes == ["prefix1/stage", "prefix2/stage"]
|
|
|
|
def test_read_artifact_manifest_with_empty_lines(self, tmp_path):
|
|
"""Test reading manifest with empty lines (should ignore them)."""
|
|
manifest_path = tmp_path / "artifact_manifest.txt"
|
|
manifest_path.write_text("prefix1/stage\n\nprefix2/stage\n\n")
|
|
|
|
prefixes = read_artifact_manifest(tmp_path)
|
|
assert prefixes == ["prefix1/stage", "prefix2/stage"]
|
|
|
|
def test_read_artifact_manifest_missing_file(self, tmp_path):
|
|
"""Test reading from directory without manifest file."""
|
|
with pytest.raises(FileNotFoundError, match="artifact_manifest.txt not found"):
|
|
read_artifact_manifest(tmp_path)
|
|
|
|
def test_write_artifact_manifest(self, tmp_path):
|
|
"""Test writing an artifact manifest."""
|
|
prefixes = ["math-libs/BLAS/rocBLAS/stage", "kpack/stage"]
|
|
write_artifact_manifest(tmp_path, prefixes)
|
|
|
|
manifest_path = tmp_path / "artifact_manifest.txt"
|
|
assert manifest_path.exists()
|
|
|
|
content = manifest_path.read_text()
|
|
assert content == "math-libs/BLAS/rocBLAS/stage\nkpack/stage\n"
|
|
|
|
def test_write_empty_manifest(self, tmp_path):
|
|
"""Test writing an empty manifest."""
|
|
write_artifact_manifest(tmp_path, [])
|
|
|
|
manifest_path = tmp_path / "artifact_manifest.txt"
|
|
assert manifest_path.exists()
|
|
assert manifest_path.read_text() == ""
|
|
|
|
def test_roundtrip_manifest(self, tmp_path):
|
|
"""Test reading and writing manifest preserves data."""
|
|
original_prefixes = [
|
|
"math-libs/BLAS/rocBLAS/stage",
|
|
"math-libs/BLAS/hipBLASLt/stage",
|
|
"kpack/stage",
|
|
]
|
|
|
|
write_artifact_manifest(tmp_path, original_prefixes)
|
|
read_prefixes = read_artifact_manifest(tmp_path)
|
|
|
|
assert read_prefixes == original_prefixes
|
|
|
|
|
|
class TestScanDirectory:
|
|
"""Tests for robust directory scanning."""
|
|
|
|
def test_scan_simple_directory(self, tmp_path):
|
|
"""Test scanning a simple directory structure."""
|
|
# Create test structure
|
|
(tmp_path / "dir1").mkdir()
|
|
(tmp_path / "dir1" / "file1.txt").write_text("content1")
|
|
(tmp_path / "dir2").mkdir()
|
|
(tmp_path / "dir2" / "file2.txt").write_text("content2")
|
|
(tmp_path / "file3.txt").write_text("content3")
|
|
|
|
# Scan and collect results
|
|
results = list(scan_directory(tmp_path))
|
|
|
|
# Check we found all files and directories
|
|
# Use as_posix() for consistent forward-slash paths on all platforms
|
|
paths = [p.relative_to(tmp_path).as_posix() for p, _ in results]
|
|
assert "dir1" in paths
|
|
assert "dir2" in paths
|
|
assert "file3.txt" in paths
|
|
assert "dir1/file1.txt" in paths
|
|
assert "dir2/file2.txt" in paths
|
|
|
|
def test_scan_with_symlinks(self, tmp_path):
|
|
"""Test that scanning doesn't follow symlinks."""
|
|
# Create a directory with a file
|
|
real_dir = tmp_path / "real_dir"
|
|
real_dir.mkdir()
|
|
(real_dir / "file.txt").write_text("content")
|
|
|
|
# Create a symlink to the directory
|
|
symlink = tmp_path / "symlink_dir"
|
|
symlink.symlink_to(real_dir)
|
|
|
|
# Create another directory that contains a symlink
|
|
container = tmp_path / "container"
|
|
container.mkdir()
|
|
(container / "link_to_real").symlink_to(real_dir)
|
|
|
|
# Scan the container directory
|
|
results = list(scan_directory(container))
|
|
|
|
# Should find the symlink itself but not traverse into it
|
|
paths = [str(p.relative_to(container)) for p, _ in results]
|
|
assert "link_to_real" in paths
|
|
assert "link_to_real/file.txt" not in paths
|
|
|
|
def test_scan_with_predicate(self, tmp_path):
|
|
"""Test scanning with a filter predicate."""
|
|
# Create mixed files
|
|
(tmp_path / "file1.txt").write_text("text")
|
|
(tmp_path / "file2.py").write_text("python")
|
|
(tmp_path / "file3.txt").write_text("more text")
|
|
(tmp_path / "file4.cpp").write_text("c++")
|
|
|
|
# Define predicate for .txt files only
|
|
def txt_only(path, entry):
|
|
return path.suffix == ".txt" or entry.is_dir()
|
|
|
|
# Scan with predicate
|
|
results = list(scan_directory(tmp_path, predicate=txt_only))
|
|
|
|
# Check we only got .txt files
|
|
file_paths = [p for p, e in results if e.is_file()]
|
|
assert len(file_paths) == 2
|
|
assert all(p.suffix == ".txt" for p in file_paths)
|
|
|
|
def test_scan_handles_permission_errors(self, tmp_path):
|
|
"""Test that scanning fails fast on permission errors."""
|
|
# Create accessible directory
|
|
accessible = tmp_path / "accessible"
|
|
accessible.mkdir()
|
|
(accessible / "file.txt").write_text("content")
|
|
|
|
# Mock os.scandir to raise PermissionError for specific path
|
|
original_scandir = os.scandir
|
|
|
|
def mock_scandir(path):
|
|
if "forbidden" in str(path):
|
|
raise PermissionError("Access denied")
|
|
return original_scandir(path)
|
|
|
|
with patch("os.scandir", side_effect=mock_scandir):
|
|
# Create a "forbidden" directory (won't actually restrict, mock will)
|
|
forbidden = tmp_path / "forbidden"
|
|
forbidden.mkdir()
|
|
|
|
# Scan should fail fast on permission error
|
|
with pytest.raises(PermissionError, match="Access denied"):
|
|
list(scan_directory(tmp_path))
|
|
|
|
def test_scan_empty_directory(self, tmp_path):
|
|
"""Test scanning an empty directory."""
|
|
empty_dir = tmp_path / "empty"
|
|
empty_dir.mkdir()
|
|
|
|
results = list(scan_directory(empty_dir))
|
|
|
|
# Should return empty list for empty directory
|
|
assert results == []
|
|
|
|
|
|
class TestIsFatBinary:
|
|
"""Tests for fat binary detection.
|
|
|
|
These tests verify that is_fat_binary() correctly identifies:
|
|
- Fat binaries (ELF with .hip_fatbin / PE with .hip_fat) -> True
|
|
- Host-only binaries (no device code section) -> False
|
|
- Non-binary files -> False
|
|
- Empty files -> False
|
|
- Missing files -> raises FileNotFoundError
|
|
"""
|
|
|
|
def test_is_fat_binary_empty_file(self, tmp_path, toolchain):
|
|
"""Test that empty files are correctly identified as not fat binaries.
|
|
|
|
Empty files can occur in CI/build artifacts due to:
|
|
- Incomplete downloads
|
|
- Build failures that leave empty placeholder files
|
|
- Test artifacts that weren't properly generated
|
|
"""
|
|
empty_file = tmp_path / "empty.so"
|
|
empty_file.write_bytes(b"") # Create empty file
|
|
|
|
# Should return False immediately without calling readelf
|
|
result = is_fat_binary(empty_file, toolchain)
|
|
assert result is False
|
|
|
|
def test_is_fat_binary_missing_file(self, tmp_path, toolchain):
|
|
"""Test that missing files raise FileNotFoundError."""
|
|
missing_file = tmp_path / "nonexistent.so"
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
is_fat_binary(missing_file, toolchain)
|
|
|
|
def test_is_fat_binary_host_only_exe(self, test_assets_dir, toolchain):
|
|
"""Test that host-only ELF executables (no .hip_fatbin) return False.
|
|
|
|
Uses the pre-generated host_only.exe from test_assets which is a
|
|
valid ELF executable without any GPU device code.
|
|
"""
|
|
host_only = test_assets_dir / "bundled_binaries/linux/cov5/host_only.exe"
|
|
assert host_only.exists(), f"Test asset not found: {host_only}"
|
|
|
|
result = is_fat_binary(host_only, toolchain)
|
|
assert result is False
|
|
|
|
def test_is_fat_binary_host_only_so(self, test_assets_dir, toolchain):
|
|
"""Test that host-only shared libraries (no .hip_fatbin) return False.
|
|
|
|
Uses the pre-generated libhost_only.so from test_assets which is a
|
|
valid ELF shared library without any GPU device code.
|
|
"""
|
|
host_only = test_assets_dir / "bundled_binaries/linux/cov5/libhost_only.so"
|
|
assert host_only.exists(), f"Test asset not found: {host_only}"
|
|
|
|
result = is_fat_binary(host_only, toolchain)
|
|
assert result is False
|
|
|
|
def test_is_fat_binary_with_hip_fatbin_real(self, test_assets_dir, toolchain):
|
|
"""Test that fat binaries (with .hip_fatbin) return True.
|
|
|
|
Uses the pre-generated test_kernel_single.exe from test_assets which
|
|
is a valid ELF executable with GPU device code.
|
|
"""
|
|
fat_binary = (
|
|
test_assets_dir / "bundled_binaries/linux/cov5/test_kernel_single.exe"
|
|
)
|
|
assert fat_binary.exists(), f"Test asset not found: {fat_binary}"
|
|
|
|
result = is_fat_binary(fat_binary, toolchain)
|
|
assert result is True
|
|
|
|
def test_is_fat_binary_with_hip_fatbin(self, test_assets_dir, toolchain):
|
|
"""Test detecting a fat binary with .hip_fatbin section (ELF)."""
|
|
fat_binary = (
|
|
test_assets_dir / "bundled_binaries/linux/cov5/test_kernel_single.exe"
|
|
)
|
|
assert fat_binary.exists(), f"Test asset not found: {fat_binary}"
|
|
|
|
assert (
|
|
is_fat_binary(fat_binary, toolchain) is True
|
|
), f"Expected fat binary: {fat_binary}"
|
|
|
|
def test_is_fat_binary_without_hip_fatbin(self, test_assets_dir, toolchain):
|
|
"""Test detecting a host-only binary without device code (ELF)."""
|
|
host_only = test_assets_dir / "bundled_binaries/linux/cov5/host_only.exe"
|
|
assert host_only.exists(), f"Test asset not found: {host_only}"
|
|
|
|
assert (
|
|
is_fat_binary(host_only, toolchain) is False
|
|
), f"Expected host-only binary: {host_only}"
|
|
|
|
def test_is_fat_binary_not_elf(self, tmp_path):
|
|
"""Test handling non-ELF/non-PE files."""
|
|
text_file = tmp_path / "text.txt"
|
|
text_file.write_text("This is not a binary file")
|
|
|
|
mock_toolchain = Mock(spec=Toolchain)
|
|
result = is_fat_binary(text_file, mock_toolchain)
|
|
assert result is False
|
|
|
|
def test_is_fat_binary_truncated_elf(self, tmp_path):
|
|
"""Test that truncated ELF files are handled gracefully."""
|
|
elf_file = tmp_path / "binary.so"
|
|
elf_file.write_bytes(b"\x7fELF" + b"\x00" * 10) # Too short for real ELF
|
|
|
|
mock_toolchain = Mock(spec=Toolchain)
|
|
# Should not crash — either returns False or raises a sensible error
|
|
try:
|
|
assert (
|
|
is_fat_binary(elf_file, mock_toolchain) is False
|
|
), f"Truncated ELF should not be detected as fat: {elf_file}"
|
|
except (RuntimeError, ValueError):
|
|
pass # Also acceptable for corrupt files
|
|
|
|
def test_is_fat_binary_coff_fat(self, test_assets_dir, toolchain):
|
|
"""Test detecting a fat binary with .hip_fat section (PE/COFF)."""
|
|
fat_binary = (
|
|
test_assets_dir / "bundled_binaries/windows/cov5/test_kernel_single.exe"
|
|
)
|
|
assert fat_binary.exists(), f"Test asset not found: {fat_binary}"
|
|
|
|
assert (
|
|
is_fat_binary(fat_binary, toolchain) is True
|
|
), f"Expected fat binary: {fat_binary}"
|
|
|
|
def test_is_fat_binary_coff_host_only(self, test_assets_dir, toolchain):
|
|
"""Test detecting a host-only PE/COFF binary."""
|
|
host_only = test_assets_dir / "bundled_binaries/windows/cov5/host_only.exe"
|
|
assert host_only.exists(), f"Test asset not found: {host_only}"
|
|
|
|
assert (
|
|
is_fat_binary(host_only, toolchain) is False
|
|
), f"Expected host-only binary: {host_only}"
|
|
|
|
|
|
class TestExtractArchitectureFromTarget:
|
|
"""Tests for extracting architecture from target strings."""
|
|
|
|
def test_extract_simple_architecture(self):
|
|
"""Test extracting simple architecture."""
|
|
target = "hipv4-amdgcn-amd-amdhsa--gfx906"
|
|
arch = extract_architecture_from_target(target)
|
|
assert arch == "gfx906"
|
|
|
|
def test_extract_cooked_architecture(self):
|
|
"""Test extracting 'cooked' architecture with features."""
|
|
target = "hipv4-amdgcn-amd-amdhsa--gfx942:xnack+"
|
|
arch = extract_architecture_from_target(target)
|
|
assert arch == "gfx942:xnack+"
|
|
|
|
def test_extract_with_single_dash(self):
|
|
"""Test target with single dash separator."""
|
|
target = "hip-amdgcn-amd-amdhsa-gfx1100"
|
|
arch = extract_architecture_from_target(target)
|
|
# Should not find architecture with single dash
|
|
assert arch is None
|
|
|
|
def test_extract_architecture_various_formats(self):
|
|
"""Test various target format patterns."""
|
|
test_cases = [
|
|
("hipv4-amdgcn-amd-amdhsa--gfx1100", "gfx1100"),
|
|
("openmp-amdgcn-amd-amdhsa--gfx90a", "gfx90a"),
|
|
("hipv4-amdgcn-amd-amdhsa--gfx1030", "gfx1030"),
|
|
("hip-amdgcn-amd-amdhsa--gfx908:xnack-", "gfx908:xnack-"),
|
|
("cuda-nvptx64-nvidia-cuda--sm_70", "sm_70"), # Non-AMD format
|
|
]
|
|
|
|
for target, expected in test_cases:
|
|
arch = extract_architecture_from_target(target)
|
|
assert arch == expected, f"Failed for target: {target}"
|
|
|
|
def test_extract_architecture_empty_string(self):
|
|
"""Test handling empty target string."""
|
|
assert extract_architecture_from_target("") is None
|
|
|
|
def test_extract_architecture_none(self):
|
|
"""Test handling None input."""
|
|
assert extract_architecture_from_target(None) is None
|
|
|
|
def test_extract_architecture_no_double_dash(self):
|
|
"""Test target without double dash separator."""
|
|
target = "some-random-string-without-proper-format"
|
|
arch = extract_architecture_from_target(target)
|
|
assert arch is None
|