diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1d3e1d8e..a44e8d3a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -102,6 +102,48 @@ jobs: with: verbose: true + upload-gpu-test-asset: + name: Upload gpu_test binary to release + needs: [release-please, pypi-publish] + if: ${{ always() && (needs.release-please.outputs.release_created || (github.event_name == 'workflow_dispatch' && inputs.force_publish == 'true')) }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Login to Docker Hub (optional) + if: ${{ vars.DOCKERHUB_USERNAME != '' }} + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Compile gpu_test binary + run: | + cd build_tools + ./compile_gpu_test.sh + cd .. + test -f runpod/serverless/binaries/gpu_test + + - name: Generate sha256 checksum + working-directory: runpod/serverless/binaries + run: | + sha256sum gpu_test > gpu_test.sha256 + cat gpu_test.sha256 + + - name: Upload binary to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.release-please.outputs.tag_name }}" \ + runpod/serverless/binaries/gpu_test \ + runpod/serverless/binaries/gpu_test.sha256 \ + --clobber + # TODO: Re-enable after optimizing (17 parallel jobs each sleeping 5min is wasteful). # Consider a single job that sleeps once then dispatches sequentially. # notify-workers: diff --git a/.gitignore b/.gitignore index eb63accf..38b804c0 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ runpod/_version.py *.lock benchmark_results/ + +# Locally-compiled CUDA test binary — CI compiles per-release +runpod/serverless/binaries/gpu_test diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd8bbdf..ae970865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +### Changed + +- **gpu_test binary no longer bundled in the PyPI wheel.** Fixes installs on + Nix and other non-glibc platforms ([#498](https://github.com/runpod/runpod-python/issues/498)). + Runtime falls back to an `nvidia-smi`-based availability check when the + binary is missing. Runpod GPU workers should add + `RUN runpod install-gpu-test` after `pip install runpod` to restore the + native CUDA memory-allocation test. + +### Added + +- `runpod install-gpu-test` CLI command — downloads the `gpu_test` binary + from the GitHub release matching the installed runpod version, verifies + sha256, and installs it into the package's `serverless/binaries/` directory. + ## [1.9.0](https://github.com/runpod/runpod-python/compare/v1.8.2...v1.9.0) (2026-04-08) diff --git a/MANIFEST.in b/MANIFEST.in index 5dd7e21d..6f5d0428 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include runpod/serverless/binaries/gpu_test include runpod/serverless/binaries/README.md include build_tools/gpu_test.c include build_tools/compile_gpu_test.sh +exclude runpod/serverless/binaries/gpu_test diff --git a/docs/serverless/gpu_binary_compilation.md b/docs/serverless/gpu_binary_compilation.md index 70f1ee5e..b631b110 100644 --- a/docs/serverless/gpu_binary_compilation.md +++ b/docs/serverless/gpu_binary_compilation.md @@ -4,13 +4,30 @@ This document explains how to rebuild the `gpu_test` binary for GPU health check ## When to Rebuild -You typically **do not need to rebuild** the binary. A pre-compiled version is included in the runpod-python package and works across most GPU environments. Rebuild only when: +You typically **do not need to rebuild** the binary. A pre-compiled version is published as a GitHub release asset and can be installed with `runpod install-gpu-test` (see next section). Rebuild only when: - You need to modify the GPU test logic (in `build_tools/gpu_test.c`) - Targeting specific new CUDA versions - Adding support for new GPU architectures - Fixing compilation issues for your specific environment +## Installing from a release + +As of v1.10.0, the `gpu_test` binary is **not bundled** in the PyPI wheel so the package stays platform-agnostic (fixes [#498](https://github.com/runpod/runpod-python/issues/498) — Nix / non-glibc builds). + +Runpod GPU workers that want the native CUDA memory-allocation test back should run: + +```bash +pip install runpod +runpod install-gpu-test +``` + +This downloads `gpu_test` from the GitHub release matching the installed runpod version, verifies its sha256, and places it at `runpod/serverless/binaries/gpu_test` inside the installed package. + +If the binary is missing, the runtime falls back to an `nvidia-smi`-based availability check (no memory-allocation test). + +Advanced users can override the binary path with the `RUNPOD_BINARY_GPU_TEST_PATH` environment variable. + ## Prerequisites You need Docker installed to build the binary: diff --git a/docs/serverless/worker_fitness_checks.md b/docs/serverless/worker_fitness_checks.md index a255045a..e6cce6f3 100644 --- a/docs/serverless/worker_fitness_checks.md +++ b/docs/serverless/worker_fitness_checks.md @@ -169,10 +169,19 @@ GPU workers automatically run a built-in fitness check that validates GPU memory The check: - Tests actual GPU memory allocation (cudaMalloc) to ensure GPUs are accessible - Enumerates all detected GPUs and validates each one -- Uses a native CUDA binary for comprehensive testing -- Falls back to Python-based checks if the binary is unavailable +- Uses a native CUDA binary for comprehensive testing (opt-in; see below) +- Falls back to an `nvidia-smi` availability check if the binary is unavailable - Skips silently on CPU-only workers (allows same code for CPU/GPU) +**Installing the native binary**: as of v1.10.0 the `gpu_test` binary is not +bundled in the PyPI wheel. Runpod GPU worker Dockerfiles should add: + +```dockerfile +RUN pip install runpod && runpod install-gpu-test +``` + +See [GPU Binary Compilation](./gpu_binary_compilation.md) for details. + ```python import runpod diff --git a/pyproject.toml b/pyproject.toml index c88c8ec2..51435f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,6 @@ include-package-data = true [tool.setuptools.package-data] runpod = [ - "serverless/binaries/gpu_test", "serverless/binaries/README.md", ] diff --git a/runpod/cli/entry.py b/runpod/cli/entry.py index 6fefa2dd..77cab6e1 100644 --- a/runpod/cli/entry.py +++ b/runpod/cli/entry.py @@ -8,6 +8,7 @@ from .groups.config.commands import config_wizard from .groups.exec.commands import exec_cli +from .groups.install.commands import install_gpu_test_cli from .groups.pod.commands import pod_cli from .groups.project.commands import project_cli from .groups.ssh.commands import ssh_cli @@ -24,3 +25,4 @@ def runpod_cli(): runpod_cli.add_command(pod_cli) # runpod pod runpod_cli.add_command(exec_cli) # runpod exec runpod_cli.add_command(project_cli) # runpod project +runpod_cli.add_command(install_gpu_test_cli) # runpod install-gpu-test diff --git a/runpod/cli/groups/install/__init__.py b/runpod/cli/groups/install/__init__.py new file mode 100644 index 00000000..d6db52d9 --- /dev/null +++ b/runpod/cli/groups/install/__init__.py @@ -0,0 +1 @@ +"""GPU test binary installer CLI.""" diff --git a/runpod/cli/groups/install/commands.py b/runpod/cli/groups/install/commands.py new file mode 100644 index 00000000..9b472a34 --- /dev/null +++ b/runpod/cli/groups/install/commands.py @@ -0,0 +1,65 @@ +""" +CLI commands for installing optional runpod binaries. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +import runpod +from runpod.version import get_version + +from .functions import ( + BinaryChecksumMismatch, + BinaryDownloadError, + download_gpu_test_binary, +) + + +def _default_install_path() -> Path: + """Package-local binaries dir — the same path _binary_helpers checks.""" + return Path(runpod.__file__).parent / "serverless" / "binaries" / "gpu_test" + + +@click.command( + "install-gpu-test", + help=( + "Download the optional gpu_test CUDA health-check binary from the " + "GitHub release matching the installed runpod version. " + "Runpod GPU workers only — no-op on CPU-only environments." + ), +) +@click.option( + "--version", + "version", + default=None, + help="Release tag to download (defaults to installed runpod version).", +) +@click.option( + "--dest", + "dest", + type=click.Path(dir_okay=False, writable=True, path_type=Path), + default=None, + help="Override destination path. Defaults to the package's binaries dir.", +) +def install_gpu_test_cli(version: str | None, dest: Path | None) -> None: + version = version or get_version() + if version == "unknown": + click.echo( + "Cannot determine installed runpod version; pass --version explicitly.", + err=True, + ) + sys.exit(1) + + target = dest or _default_install_path() + + try: + installed_at = download_gpu_test_binary(version=version, dest=target) + except (BinaryDownloadError, BinaryChecksumMismatch) as exc: + click.echo(f"Failed to install gpu_test: {exc}", err=True) + sys.exit(1) + + click.echo(f"Installed gpu_test at {installed_at}") diff --git a/runpod/cli/groups/install/functions.py b/runpod/cli/groups/install/functions.py new file mode 100644 index 00000000..dc4f4d33 --- /dev/null +++ b/runpod/cli/groups/install/functions.py @@ -0,0 +1,108 @@ +""" +Download and install the optional gpu_test binary from a GitHub release. + +The binary is NOT bundled in PyPI wheels to keep them universal +(py3-none-any). Runpod GPU workers that want the native CUDA memory +allocation test can fetch it from the GitHub release matching their +installed runpod version. + +See docs/serverless/gpu_binary_compilation.md for usage. +""" + +from __future__ import annotations + +import hashlib +import os +import tempfile +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +GITHUB_REPO = "runpod/runpod-python" +DOWNLOAD_TIMEOUT_SECONDS = 60 + + +@dataclass(frozen=True) +class ReleaseAssetUrls: + binary: str + checksum: str + + +class BinaryDownloadError(RuntimeError): + """Raised when the binary or checksum cannot be fetched.""" + + +class BinaryChecksumMismatch(RuntimeError): + """Raised when the downloaded binary's sha256 does not match the expected value.""" + + +def release_asset_urls(version: str) -> ReleaseAssetUrls: + """Build release-asset URLs for a given runpod version. + + Accepts either '1.9.0' or 'v1.9.0' — the leading 'v' is optional. + """ + clean = version.lstrip("v") + base = f"https://github.com/{GITHUB_REPO}/releases/download/v{clean}/gpu_test" + return ReleaseAssetUrls(binary=base, checksum=f"{base}.sha256") + + +def _fetch(url: str) -> bytes: + try: + with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response: + return response.read() + except urllib.error.HTTPError as exc: + raise BinaryDownloadError( + f"HTTP {exc.code} fetching {url}: {exc.reason}" + ) from exc + except urllib.error.URLError as exc: + raise BinaryDownloadError( + f"Network error fetching {url}: {exc.reason!r}" + ) from exc + + +def _parse_sha256(checksum_body: bytes) -> str: + """Extract the hex digest from a 'sha256 filename' line.""" + text = checksum_body.decode("utf-8", errors="replace").strip() + first_token = text.split()[0] if text else "" + if len(first_token) != 64: + raise BinaryDownloadError( + f"checksum file did not contain a sha256 digest: {text!r}" + ) + return first_token.lower() + + +def download_gpu_test_binary(version: str, dest: Path) -> Path: + """Download gpu_test from the matching GitHub release and install it at dest. + + Verifies sha256 before writing to the final destination. On checksum + mismatch or HTTP failure, no partial file is left at dest. + + Returns the destination path on success. + """ + urls = release_asset_urls(version) + + checksum_body = _fetch(urls.checksum) + expected_sha = _parse_sha256(checksum_body) + + binary_body = _fetch(urls.binary) + actual_sha = hashlib.sha256(binary_body).hexdigest() + if actual_sha != expected_sha: + raise BinaryChecksumMismatch( + f"sha256 mismatch for {urls.binary} " + f"({len(binary_body)} bytes): " + f"expected {expected_sha}, got {actual_sha}" + ) + + dest.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=dest.parent, delete=False) as tmp: + tmp.write(binary_body) + tmp_path = Path(tmp.name) + + try: + os.chmod(tmp_path, 0o750) + os.replace(tmp_path, dest) + except OSError: + tmp_path.unlink(missing_ok=True) + raise + return dest diff --git a/runpod/serverless/binaries/README.md b/runpod/serverless/binaries/README.md index ede412ba..67a39687 100644 --- a/runpod/serverless/binaries/README.md +++ b/runpod/serverless/binaries/README.md @@ -4,7 +4,24 @@ Pre-compiled GPU health check binary for Linux x86_64. ## Files -- `gpu_test` - Compiled binary for CUDA GPU memory allocation testing +- `gpu_test` - Compiled binary for CUDA GPU memory allocation testing (not + bundled in the PyPI wheel; see below) + +## Availability + +As of runpod v1.10.0 this binary is **not included** in the PyPI wheel. The +universal `py3-none-any` wheel would otherwise advertise itself as +platform-agnostic while shipping a Linux x86_64 ELF, which breaks Nix and +other strict packagers (see [#498](https://github.com/runpod/runpod-python/issues/498)). + +Runpod GPU workers can download the matching binary with: + +```bash +runpod install-gpu-test +``` + +This fetches the asset from the GitHub release matching the installed runpod +version and verifies its sha256. ## Compatibility @@ -29,7 +46,7 @@ GPU 0 memory allocation test passed. ## Building -See `build_tools/compile_gpu_test.sh` and `docs/serverless/gpu_binary_compilation.md` for compilation instructions. +See `build_tools/compile_gpu_test.sh` and `docs/serverless/gpu_binary_compilation.md`. ## License diff --git a/runpod/serverless/binaries/gpu_test b/runpod/serverless/binaries/gpu_test deleted file mode 100755 index 71647bba..00000000 Binary files a/runpod/serverless/binaries/gpu_test and /dev/null differ diff --git a/setup.py b/setup.py index ebd4c2d1..f8750213 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,6 @@ include_package_data=True, package_data={ "runpod": [ - "serverless/binaries/gpu_test", "serverless/binaries/README.md", ] }, diff --git a/tests/test_cli/test_install/__init__.py b/tests/test_cli/test_install/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/test_cli/test_install/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_cli/test_install/test_install_gpu_test.py b/tests/test_cli/test_install/test_install_gpu_test.py new file mode 100644 index 00000000..4b0ece18 --- /dev/null +++ b/tests/test_cli/test_install/test_install_gpu_test.py @@ -0,0 +1,251 @@ +""" +Tests for the gpu_test binary installer (runpod install-gpu-test). + +Mocks urllib so tests don't touch the network. Verifies: +- Download URL is constructed from the installed runpod version +- SHA256 is verified before the binary is written +- Destination path matches _binary_helpers.get_binary_path() expectations +- Failure modes (HTTP error, checksum mismatch) raise cleanly +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + +from runpod.cli.groups.install.commands import install_gpu_test_cli +from runpod.cli.groups.install.functions import ( + BinaryChecksumMismatch, + BinaryDownloadError, + download_gpu_test_binary, + release_asset_urls, +) + + +def _fake_http_response(body: bytes) -> MagicMock: + response = MagicMock() + response.__enter__.return_value = response + response.__exit__.return_value = False + response.read.return_value = body + response.status = 200 + return response + + +class TestReleaseAssetUrls: + def test_urls_use_installed_version(self): + urls = release_asset_urls(version="1.9.0") + assert urls.binary == ( + "https://github.com/runpod/runpod-python/releases/download/v1.9.0/gpu_test" + ) + assert urls.checksum == ( + "https://github.com/runpod/runpod-python/releases/download/" + "v1.9.0/gpu_test.sha256" + ) + + def test_strips_leading_v_if_present(self): + """Callers sometimes pass 'v1.9.0' by accident — accept either.""" + urls = release_asset_urls(version="v1.9.0") + assert "/v1.9.0/" in urls.binary + + +class TestDownloadGpuTestBinary: + def test_writes_binary_when_checksum_matches(self, tmp_path: Path): + binary_body = b"\x7fELF fake binary payload" + expected_sha = hashlib.sha256(binary_body).hexdigest() + checksum_body = f"{expected_sha} gpu_test\n".encode() + dest = tmp_path / "gpu_test" + + def fake_urlopen(url, timeout): # noqa: ARG001 + if url.endswith(".sha256"): + return _fake_http_response(checksum_body) + return _fake_http_response(binary_body) + + with patch( + "runpod.cli.groups.install.functions.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + result = download_gpu_test_binary(version="1.9.0", dest=dest) + + assert result == dest + assert dest.read_bytes() == binary_body + # 0o750 after chmod (owner rwx, group r-x, others no access) + assert dest.stat().st_mode & 0o777 == 0o750 + + def test_raises_on_checksum_mismatch(self, tmp_path: Path): + binary_body = b"real payload" + wrong_sha = "0" * 64 + checksum_body = f"{wrong_sha} gpu_test\n".encode() + dest = tmp_path / "gpu_test" + + def fake_urlopen(url, timeout): # noqa: ARG001 + if url.endswith(".sha256"): + return _fake_http_response(checksum_body) + return _fake_http_response(binary_body) + + with patch( + "runpod.cli.groups.install.functions.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + with pytest.raises(BinaryChecksumMismatch): + download_gpu_test_binary(version="1.9.0", dest=dest) + + assert not dest.exists(), "partial download must not be left on disk" + + def test_raises_on_http_error(self, tmp_path: Path): + import urllib.error + + dest = tmp_path / "gpu_test" + + def fake_urlopen(url, timeout): # noqa: ARG001 + raise urllib.error.HTTPError( + url=url, code=404, msg="Not Found", hdrs=None, fp=None + ) + + with patch( + "runpod.cli.groups.install.functions.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + with pytest.raises(BinaryDownloadError, match="404"): + download_gpu_test_binary(version="9.9.9", dest=dest) + + +class TestDownloadGpuTestBinaryErrorPaths: + """Coverage for failure modes added in response to code review.""" + + def test_cleans_up_temp_file_on_replace_failure(self, tmp_path: Path): + binary_body = b"payload" + expected_sha = hashlib.sha256(binary_body).hexdigest() + checksum_body = f"{expected_sha} gpu_test\n".encode() + dest = tmp_path / "sub" / "gpu_test" + + def fake_urlopen(url, timeout): # noqa: ARG001 + if url.endswith(".sha256"): + return _fake_http_response(checksum_body) + return _fake_http_response(binary_body) + + with patch( + "runpod.cli.groups.install.functions.urllib.request.urlopen", + side_effect=fake_urlopen, + ), patch( + "runpod.cli.groups.install.functions.os.replace", + side_effect=OSError("simulated replace failure"), + ): + with pytest.raises(OSError, match="simulated replace failure"): + download_gpu_test_binary(version="1.9.0", dest=dest) + + assert not dest.exists(), "final dest must not exist on failure" + leftover = list(dest.parent.glob("tmp*")) + assert leftover == [], f"temp file leaked: {leftover}" + + def test_checksum_error_includes_url_and_byte_count(self, tmp_path: Path): + binary_body = b"payload bytes" + wrong_sha = "0" * 64 + checksum_body = f"{wrong_sha} gpu_test\n".encode() + dest = tmp_path / "gpu_test" + + def fake_urlopen(url, timeout): # noqa: ARG001 + if url.endswith(".sha256"): + return _fake_http_response(checksum_body) + return _fake_http_response(binary_body) + + with patch( + "runpod.cli.groups.install.functions.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + with pytest.raises(BinaryChecksumMismatch) as exc_info: + download_gpu_test_binary(version="1.9.0", dest=dest) + + msg = str(exc_info.value) + assert "/v1.9.0/gpu_test" in msg, "error must cite the release URL" + assert f"{len(binary_body)} bytes" in msg, "error must cite download size" + + +class TestInstallGpuTestCommand: + def test_command_calls_download_with_resolved_dest(self, tmp_path: Path): + """Command resolves destination via _binary_helpers and passes installed version.""" + fake_dest = tmp_path / "runpod" / "serverless" / "binaries" / "gpu_test" + + with patch( + "runpod.cli.groups.install.commands.download_gpu_test_binary" + ) as mock_download, patch( + "runpod.cli.groups.install.commands._default_install_path", + return_value=fake_dest, + ), patch( + "runpod.cli.groups.install.commands.get_version", + return_value="1.9.0", + ): + mock_download.return_value = fake_dest + runner = CliRunner() + result = runner.invoke(install_gpu_test_cli, []) + + assert result.exit_code == 0, result.output + mock_download.assert_called_once_with(version="1.9.0", dest=fake_dest) + + def test_command_honors_version_override(self, tmp_path: Path): + fake_dest = tmp_path / "gpu_test" + with patch( + "runpod.cli.groups.install.commands.download_gpu_test_binary" + ) as mock_download, patch( + "runpod.cli.groups.install.commands._default_install_path", + return_value=fake_dest, + ): + mock_download.return_value = fake_dest + runner = CliRunner() + result = runner.invoke(install_gpu_test_cli, ["--version", "1.8.0"]) + + assert result.exit_code == 0 + mock_download.assert_called_once_with(version="1.8.0", dest=fake_dest) + + def test_command_exits_nonzero_on_download_error(self, tmp_path: Path): + fake_dest = tmp_path / "gpu_test" + with patch( + "runpod.cli.groups.install.commands.download_gpu_test_binary", + side_effect=BinaryDownloadError("HTTP 404"), + ), patch( + "runpod.cli.groups.install.commands._default_install_path", + return_value=fake_dest, + ), patch( + "runpod.cli.groups.install.commands.get_version", + return_value="1.9.0", + ): + runner = CliRunner() + result = runner.invoke(install_gpu_test_cli, []) + + assert result.exit_code == 1 + assert "HTTP 404" in result.output + + def test_command_honors_dest_override(self, tmp_path: Path): + """--dest should bypass _default_install_path and be forwarded verbatim.""" + custom_dest = tmp_path / "custom" / "gpu_test" + + with patch( + "runpod.cli.groups.install.commands.download_gpu_test_binary" + ) as mock_download, patch( + "runpod.cli.groups.install.commands.get_version", + return_value="1.9.0", + ), patch( + "runpod.cli.groups.install.commands._default_install_path" + ) as mock_default: + mock_download.return_value = custom_dest + runner = CliRunner() + result = runner.invoke(install_gpu_test_cli, ["--dest", str(custom_dest)]) + + assert result.exit_code == 0, result.output + mock_default.assert_not_called() + mock_download.assert_called_once_with(version="1.9.0", dest=custom_dest) + + def test_command_errors_when_version_unknown(self): + """If get_version() returns 'unknown', command exits with actionable hint.""" + with patch( + "runpod.cli.groups.install.commands.get_version", + return_value="unknown", + ): + runner = CliRunner() + result = runner.invoke(install_gpu_test_cli, []) + + assert result.exit_code == 1 + assert "--version" in result.output diff --git a/tests/test_package/__init__.py b/tests/test_package/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/test_package/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_package/test_wheel_contents.py b/tests/test_package/test_wheel_contents.py new file mode 100644 index 00000000..5b447811 --- /dev/null +++ b/tests/test_package/test_wheel_contents.py @@ -0,0 +1,50 @@ +""" +Verifies the built wheel excludes platform-specific binaries so it stays +universal (py3-none-any) and installable on Nix / non-glibc platforms. + +Regression guard for https://github.com/runpod/runpod-python/issues/498. +""" + +from __future__ import annotations + +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def built_wheel(tmp_path_factory) -> Path: + """Build the wheel once per test module and return its path.""" + out_dir = tmp_path_factory.mktemp("dist") + subprocess.run( + [sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)], + cwd=REPO_ROOT, + check=True, + capture_output=True, + ) + wheels = list(out_dir.glob("runpod-*.whl")) + assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}" + return wheels[0] + + +def test_wheel_excludes_gpu_test_binary(built_wheel: Path) -> None: + """The gpu_test ELF binary must NOT be bundled in the PyPI wheel.""" + with zipfile.ZipFile(built_wheel) as zf: + names = zf.namelist() + offending = [n for n in names if n.endswith("serverless/binaries/gpu_test")] + assert offending == [], ( + f"wheel still contains the gpu_test binary: {offending}. " + "See docs/serverless/gpu_binary_compilation.md for the opt-in install path." + ) + + +def test_wheel_is_universal(built_wheel: Path) -> None: + """Filename tag must be py3-none-any (no platform pinning).""" + assert built_wheel.name.endswith("-py3-none-any.whl"), ( + f"wheel is not universal: {built_wheel.name}" + )