diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index bd22d7aa4..5d81fec3e 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -124,7 +124,7 @@ jobs: - name: Build the bundle shell: bash - run: python scripts/bundle.py --release + run: python scripts/bundle.py --release --gpu 0 - name: Check the bundle runs and carries its notices shell: bash diff --git a/Makefile b/Makefile index 10a16227d..d5b3d77ec 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,8 @@ help: @echo $(Q) make setup - Set up development environment (uv); GPU auto-detected, GPU=0 forces CPU$(Q) @echo $(Q) make pre-commit - Install pre-commit hooks$(Q) @echo $(Q) make system-deps - Install system packages required to build and run (apt on Debian-based Linux, Homebrew on macOS)$(Q) - @echo $(Q) make build - Compile standalone executable (development deployment config: DEBUG, strict history)$(Q) - @echo $(Q) make release - Compile standalone executable with the release deployment config (INFO, self-healing history)$(Q) + @echo $(Q) make build - Compile standalone executable (development deployment config: DEBUG, strict history); GPU auto-detected, GPU=0 forces CPU$(Q) + @echo $(Q) make release - Compile standalone executable with the release deployment config (INFO, self-healing history); CPU only$(Q) @echo $(Q) make test - Run the test suite with coverage$(Q) @echo $(Q) make test-docs - Run the doctests$(Q) @echo $(Q) make benchmarks - Run the measured-duration suite$(Q) @@ -45,10 +45,10 @@ install: $(MAKE) build build: - $(PYTHON) scripts/bundle.py + $(PYTHON) scripts/bundle.py --gpu $(GPU) release: - $(PYTHON) scripts/bundle.py --release + $(PYTHON) scripts/bundle.py --release --gpu 0 system-deps: $(PYTHON) scripts/system_dependencies.py diff --git a/README.md b/README.md index 6fbec4e20..79391f4fa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Overview -_SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). +_SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/) or [_Bitphase_](https://bitphase.app/).
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 0d147c3d5..4b139647a 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -105,10 +105,15 @@ three years from the date of that release.
### GPU acceleration
-The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA libraries
-are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes
-from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the
-CUDA components from their publishers straight to your machine.
+The published bundles are **CPU-only** and carry no CuPy or CUDA libraries.
+
+CuPy is MIT-licensed. The CUDA libraries (cuBLAS, cuFFT, cuRAND, cuSOLVER, cuSPARSE, NVRTC and the CUDA
+runtime) are NVIDIA software under the
+[CUDA EULA](https://docs.nvidia.com/cuda/eula/index.html), which lists them as distributable inside an
+application with material functionality of its own, without modification and for use by that application
+alone. GPU support comes from installing _SampleToNES_ from PyPI with the `gpu` extra, or from building
+the standalone app on your own machine with an NVIDIA driver, which bundles the libraries fetched from
+NVIDIA's wheels for your own use.
## Build-time tooling
diff --git a/docs/development/release/dependencies.md b/docs/development/release/dependencies.md
index 6027810e1..70ea55b2e 100644
--- a/docs/development/release/dependencies.md
+++ b/docs/development/release/dependencies.md
@@ -10,7 +10,7 @@ The interface is built with [DearPyGui](https://github.com/hoffstadt/DearPyGui),
## Core
-The reconstruction engine stands on the usual numerical stack, with `cupy` as an optional GPU backend. The GPU backend is installed as the extra that matches the machine's NVIDIA driver. See [GPU acceleration](../../guide/installation.md#gpu-acceleration) for enabling it.
+The reconstruction engine stands on the usual numerical stack, with `cupy` as an optional GPU backend. The GPU backend is installed as the extra that matches the machine's NVIDIA driver. See [GPU acceleration](../../guide/installation.md#gpu-acceleration) for enabling it. [GPU bundles](#gpu-bundles) says how a standalone executable carries it.
## Serialization
@@ -60,6 +60,23 @@ Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) with the `libgme` de
CI runs the driver on py65, which arrives with the package's own dependencies, and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`.
+## GPU bundles
+
+A standalone executable built on a machine with an NVIDIA driver carries CuPy and the CUDA libraries. The build asks the same driver detector as `make setup`, and `--gpu 0` opts out. The published bundles are built with it off, and `scripts/verify_bundle.py` holds a release to carrying none of them.
+
+A GPU bundle is a directory. The CUDA libraries would otherwise unpack from a single file on every start.
+
+PyInstaller needs four things to carry a CuPy that computes, and the repository supplies each:
+
+- **CuPy whole.** CuPy imports its submodules by name at run time, so the build collects all of `cupy`, `cupy_backends` and `cupyx`.
+- **The hook beside the build** (`scripts/pyinstaller_hooks/hook-cupy.py`). It names the CUDA wheels installed in the build environment as hidden imports, which brings in the hooks that collect their shared libraries. It adds their header directories as data, because CuPy compiles kernels with NVRTC and looks the headers up, and it adds `graphlib`, which a compiled CuPy module imports out of sight of the analysis.
+- **The runtime hook** (`scripts/runtime_hooks/cuda_bundle.py`). The CUDA library search walks site-packages and then re-runs `sys.executable` to probe the system. Frozen, that re-runs the application and hangs. The hook makes the bundle the only site-packages, so every library is found inside it.
+- **A build environment that matches.** A CPU build excludes the GPU packages by name, so a `.venv-build` left over from a GPU build ships none of them.
+
+The self-check is what proves a bundle. `sampletones self-check --gpu` computes a kernel and a matrix product on the card and holds the answer to NumPy's, and a GPU build runs it before it finishes. Importing CuPy alone passes in a bundle that lost its CUDA libraries.
+
+The CUDA license lets an application distribute these libraries inside itself, on its terms ([third-party notices](../../../THIRD-PARTY-NOTICES.md#gpu-acceleration)). A GPU bundle is for its builder's machine.
+
## Linux (standalone executable)
Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. `make system-deps` installs them (or run `python3 scripts/system_dependencies.py`), and the script has the full list.
diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index f0206baf6..c6f93b362 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -57,19 +57,28 @@ After you pull new changes, run `make setup` again.
On Windows and Linux, you can also build a standalone app from the source code:
-- **Windows**: double-click `install.bat`. It builds `bin\sampletones.exe`.
-- **Linux**: run `make system-deps`, then `./install.sh`. It builds `bin/sampletones`.
+- **Windows**: double-click `install.bat`. It builds `bin\sampletones\sampletones.exe`.
+- **Linux**: run `make system-deps`, then `./install.sh`. It builds `bin/sampletones/sampletones`.
+
+The build adds GPU support when it finds an NVIDIA graphics card. See [GPU acceleration](#gpu-acceleration).
## GPU acceleration
_SampleToNES_ can use an NVIDIA graphics card to build libraries and convert recordings faster. It
needs an NVIDIA card with a current driver, on Windows or Linux. On macOS, the app runs without GPU acceleration.
+- **Standalone app you build**: `install.bat` and `./install.sh` check your NVIDIA driver and bundle the
+ matching GPU support. The app folder then holds the NVIDIA libraries and grows to a few gigabytes.
+ Add `--gpu 0` to build the smaller app for the CPU alone: `./install.sh --gpu 0`, or `install.bat --gpu 0`.
+ The build finishes only once the app has computed on your card.
- **From source**: `make setup` checks your NVIDIA driver and installs the matching GPU support.
`make setup GPU=0` installs the app for the CPU alone.
- **From PyPI**: add the `gpu` extra, `uv tool install "sampletones[gpu]"`. If your driver supports
CUDA 11 only, use the `gpu-cuda11` extra instead.
+The apps on the releases page run on the CPU. To see which one your app uses, run `sampletones self-check`
+and read the "array backend" line.
+
---
Once the app runs, [Getting started](getting-started.md) walks you through your first
diff --git a/scripts/archive_bundle.py b/scripts/archive_bundle.py
index 19a586392..cdc9bee81 100644
--- a/scripts/archive_bundle.py
+++ b/scripts/archive_bundle.py
@@ -67,7 +67,7 @@ def archive_release(root: Path, platform: Platform, *, label: str) -> int:
SystemExit: If the system builds no bundle.
"""
project = read_project(root)
- source = platform.bundling().launcher(root / DISTRIBUTION, name=project.name, release=True).parent
+ source = platform.bundling().launcher(root / DISTRIBUTION, name=project.name, directory=True).parent
if not source.is_dir():
print(f"::error::Bundle directory {source} is missing")
return 1
diff --git a/scripts/bootstrap/cuda.py b/scripts/bootstrap/cuda.py
index ec43e3868..72c172dd2 100644
--- a/scripts/bootstrap/cuda.py
+++ b/scripts/bootstrap/cuda.py
@@ -1,6 +1,7 @@
import re
import shutil
import subprocess
+import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Mapping, Optional, Sequence, Tuple
@@ -13,6 +14,15 @@
CUDA11_MAJOR: Final[int] = 11
CUDA_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"CUDA Version\s*:?\s*(\d+)\.(\d+)")
QUERY_ARGUMENTS: Final[Tuple[Tuple[str, ...], ...]] = ((), ("-q",))
+GPU_AUTO: Final[str] = "auto"
+GPU_OFF: Final[str] = "0"
+GPU_CHOICES: Final[Tuple[str, ...]] = (
+ GPU_AUTO,
+ GPU_OFF,
+ GPU_EXTRA,
+ GPU_CUDA11_EXTRA,
+)
+DEFAULT_GPU: Final[str] = GPU_AUTO
@dataclass(frozen=True, kw_only=True)
@@ -140,3 +150,29 @@ def detect(platform: Platform, environment: Mapping[str, str]) -> CudaDetection:
cuda_version = query_driver_cuda_version(nvidia_smi)
extra = select_extra(cuda_version)
return CudaDetection(extra=extra, reason=_describe(cuda_version=cuda_version, extra=extra))
+
+
+def gpu_extra(
+ choice: str,
+ platform: Platform,
+ environment: Mapping[str, str],
+) -> Optional[str]:
+ """The optional-dependency extra a GPU choice selects, shared by the setup and the build.
+
+ Args:
+ choice: ``auto`` to read the NVIDIA driver, ``0`` for the CPU backend, or an extra's name.
+ platform: The system the choice is made on.
+ environment: The variables the driver's locations are read from.
+
+ Returns:
+ Optional[str]: The extra, or ``None`` for the CPU backend.
+ """
+ if choice == GPU_OFF:
+ return None
+
+ if choice == GPU_AUTO:
+ detection = detect(platform, environment)
+ print(detection.reason, file=sys.stderr)
+ return detection.extra
+
+ return choice
diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py
index 4be482b5e..9542db5b3 100644
--- a/scripts/bootstrap/layout.py
+++ b/scripts/bootstrap/layout.py
@@ -10,8 +10,13 @@
BUNDLES: Final[str] = "bundles"
BUILD_ENVIRONMENT: Final[str] = ".venv-build"
RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py"
+GPU_HOOK: Final[str] = "scripts/runtime_hooks/cuda_bundle.py"
+PYINSTALLER_HOOKS: Final[str] = "scripts/pyinstaller_hooks"
NOTICES: Final[Tuple[str, ...]] = ("LICENSE", "THIRD-PARTY-NOTICES.md", "THIRD-PARTY-LICENSES.txt")
BUILD_TOOLS: Final[Tuple[str, ...]] = ("PIL",)
+CUPY_PACKAGES: Final[Tuple[str, ...]] = ("cupy", "cupy_backends", "cupyx")
+CUDA_PACKAGE: Final[str] = "nvidia"
+GPU_PACKAGES: Final[Tuple[str, ...]] = (*CUPY_PACKAGES, CUDA_PACKAGE)
CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (DISTRIBUTION, BUNDLES, "build", "dist", "htmlcov", ".coverage")
CLEAN_PATTERNS: Final[Tuple[str, ...]] = ("*.spec",)
CACHE_DIRECTORIES: Final[Tuple[str, ...]] = ("__pycache__",)
diff --git a/scripts/bootstrap/platforms/bundling.py b/scripts/bootstrap/platforms/bundling.py
index 02eaad2ac..bd49144e5 100644
--- a/scripts/bootstrap/platforms/bundling.py
+++ b/scripts/bootstrap/platforms/bundling.py
@@ -20,19 +20,20 @@ class Bundling:
tkinter_advice: str
tkinter_warning: str
- def launcher(self, distribution: Path, *, name: str, release: bool) -> Path:
+ def launcher(self, distribution: Path, *, name: str, directory: bool) -> Path:
"""The executable PyInstaller writes under ``distribution``.
Args:
distribution: The directory the bundle is written into.
name: The bundle's name.
- release: Whether the bundle is a release, which is a directory beside its launcher.
+ directory: Whether the bundle is a directory beside its launcher, as a release and a
+ GPU bundle are, or one file.
Returns:
Path: The launcher.
"""
executable = f"{name}{self.executable_suffix}"
- if release:
+ if directory:
return distribution / name / executable
return distribution / executable
diff --git a/scripts/bootstrap/preflight.py b/scripts/bootstrap/preflight.py
index 1757f71f7..414d98c8f 100644
--- a/scripts/bootstrap/preflight.py
+++ b/scripts/bootstrap/preflight.py
@@ -6,6 +6,8 @@
PYAUDIO: Final[str] = "pyaudio"
TKINTER: Final[str] = "tkinter"
+CUPY: Final[str] = "cupy"
+CUPY_ADVICE: Final[str] = "Check the NVIDIA driver and build again, or pass '--gpu 0' to build for the CPU alone."
def can_import(
@@ -42,26 +44,29 @@ def check_build_interpreter(
bundling: Bundling,
*,
release: bool,
+ gpu: bool,
runner: Runner,
cwd: Path,
environment: Mapping[str, str],
) -> None:
"""Holds the build interpreter to what a bundle has to carry.
- Audio playback is required of every bundle. Tk is required of a release bundle, so the
- shipped executable opens file dialogs on its own; a development bundle built without it is
- warned about what it leans on instead.
+ Audio playback is required of every bundle, and CuPy of a GPU bundle. Tk is required of a
+ release bundle, so the shipped executable opens file dialogs on its own; a development bundle
+ built without it is warned about what it leans on instead.
Args:
python: The build environment's interpreter.
bundling: What building a bundle takes on the system, whose advice a refusal gives.
release: Whether the bundle is a release.
+ gpu: Whether the bundle carries GPU support.
runner: What runs the probes.
cwd: The directory the probes run in.
environment: The variables the probes see.
Raises:
- SystemExit: If the interpreter cannot play audio, or a release lacks Tk.
+ SystemExit: If the interpreter cannot play audio, a GPU bundle's cannot import CuPy, or a
+ release lacks Tk.
"""
print("Checking the build environment...")
if not can_import(python, PYAUDIO, runner=runner, cwd=cwd, environment=environment):
@@ -71,6 +76,15 @@ def check_build_interpreter(
)
print(f"{PYAUDIO}: available")
+ if gpu:
+ if not can_import(python, CUPY, runner=runner, cwd=cwd, environment=environment):
+ raise SystemExit(
+ "ERROR: the build interpreter cannot import cupy, so the bundle would carry no GPU support.\n"
+ f"{CUPY_ADVICE}"
+ )
+
+ print(f"{CUPY}: available")
+
if can_import(python, TKINTER, runner=runner, cwd=cwd, environment=environment):
print(f"{TKINTER}: available")
return
diff --git a/scripts/bundle.py b/scripts/bundle.py
index 88b2a94a0..caf0cd86b 100644
--- a/scripts/bundle.py
+++ b/scripts/bundle.py
@@ -4,20 +4,32 @@
import sys
from dataclasses import dataclass
from pathlib import Path
-from typing import Final, List, Mapping, Sequence, Tuple
+from typing import Final, List, Mapping, Optional, Sequence, Tuple
+from bootstrap.cuda import DEFAULT_GPU, GPU_CHOICES, gpu_extra
from bootstrap.files import remove_path
from bootstrap.interpreter import running_version
-from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES, RELEASE_HOOK, repository_root
+from bootstrap.layout import (
+ BUILD_TOOLS,
+ CUPY_PACKAGES,
+ DISTRIBUTION,
+ GPU_HOOK,
+ GPU_PACKAGES,
+ NOTICES,
+ PYINSTALLER_HOOKS,
+ RELEASE_HOOK,
+ repository_root,
+)
from bootstrap.platforms.bundling import Bundling
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.preflight import check_build_interpreter
from bootstrap.processes import Runner, expect_success, run
-from bootstrap.project import BUILD_EXTRA, GPU_EXTRA, Project, read_project
+from bootstrap.project import BUILD_EXTRA, Project, read_project
from bootstrap.venv_build import ensure_build_venv, install
SELF_CHECK: Final[str] = "self-check"
+GPU_FLAG: Final[str] = "--gpu"
@dataclass(frozen=True)
@@ -25,23 +37,59 @@ class BundleOptions:
"""How a bundle is built.
Attributes:
- release: Whether the bundle is a release: a directory beside its launcher, carrying the
- release deployment configuration and the notices.
- gpu: Whether the bundle carries GPU support.
+ release: Whether the bundle is a release: carrying the release deployment configuration
+ and the notices.
+ gpu_extra: The optional-dependency extra bringing GPU support, or ``None`` for a bundle
+ computing on the CPU.
"""
release: bool
- gpu: bool
+ gpu_extra: Optional[str]
+
+ @property
+ def gpu(self) -> bool:
+ """Whether the bundle carries GPU support."""
+ return self.gpu_extra is not None
+
+ @property
+ def directory(self) -> bool:
+ """Whether the bundle is a directory beside its launcher.
+
+ A release is one so its notices sit beside the launcher. A GPU bundle is one because the
+ CUDA libraries it carries weigh gigabytes, which a single file would unpack on every start.
+ """
+ return self.release or self.gpu
def extras(options: BundleOptions) -> Tuple[str, ...]:
"""The optional-dependency extras a bundle is built with."""
- if options.gpu:
- return (BUILD_EXTRA, GPU_EXTRA)
+ if options.gpu_extra is not None:
+ return (BUILD_EXTRA, options.gpu_extra)
return (BUILD_EXTRA,)
+def backend_arguments(options: BundleOptions) -> List[str]:
+ """The PyInstaller arguments shaping what the bundle computes on.
+
+ A CPU bundle leaves the GPU packages out, so a build environment that carried them for an
+ earlier GPU build ships nothing of them. A GPU bundle collects CuPy whole, since it imports its
+ modules dynamically, and brings the CUDA wheels in through the repository's own hooks.
+ """
+ if options.gpu_extra is None:
+ arguments: List[str] = []
+ for package in GPU_PACKAGES:
+ arguments.extend(("--exclude-module", package))
+
+ return arguments
+
+ arguments = ["--additional-hooks-dir", PYINSTALLER_HOOKS, "--runtime-hook", GPU_HOOK]
+ for package in CUPY_PACKAGES:
+ arguments.extend(("--collect-all", package))
+
+ return arguments
+
+
def pyinstaller_command(
python: Path,
bundling: Bundling,
@@ -68,7 +116,7 @@ def pyinstaller_command(
"PyInstaller",
"--name",
project.name,
- "--onedir" if options.release else "--onefile",
+ "--onedir" if options.directory else "--onefile",
"--noconfirm",
"--distpath",
DISTRIBUTION,
@@ -82,6 +130,7 @@ def pyinstaller_command(
for module in BUILD_TOOLS:
command.extend(("--exclude-module", module))
+ command.extend(backend_arguments(options))
if options.release:
command.extend(("--runtime-hook", RELEASE_HOOK))
@@ -89,11 +138,19 @@ def pyinstaller_command(
return command
+def self_check_command(launcher: Path, options: BundleOptions) -> Tuple[str, ...]:
+ """The launcher's self-check, which a GPU bundle runs with the GPU check that proves it computes on the card."""
+ if options.gpu:
+ return (str(launcher), SELF_CHECK, GPU_FLAG)
+
+ return (str(launcher), SELF_CHECK)
+
+
def remove_previous(distribution: Path, bundling: Bundling, name: str) -> None:
- """Removes what an earlier build left under ``distribution``, a release's directory or a single file."""
+ """Removes what an earlier build left under ``distribution``, a bundle's directory or a single file."""
for previous in (
- bundling.launcher(distribution, name=name, release=True).parent,
- bundling.launcher(distribution, name=name, release=False),
+ bundling.launcher(distribution, name=name, directory=True).parent,
+ bundling.launcher(distribution, name=name, directory=False),
):
if remove_path(previous):
print(f"Removed the previous artifact: {previous}")
@@ -135,6 +192,9 @@ def build_bundle(
if options.release:
print("Release build: onedir bundle, injecting release deployment configuration")
+ if options.gpu_extra is not None:
+ print(f"GPU build: onedir bundle carrying the '{options.gpu_extra}' extra")
+
python = ensure_build_venv(
root,
platform,
@@ -152,6 +212,7 @@ def build_bundle(
python,
bundling,
release=options.release,
+ gpu=options.gpu,
runner=runner,
cwd=root,
environment=environment,
@@ -165,12 +226,12 @@ def build_bundle(
cwd=root,
environment=environment,
)
- launcher = bundling.launcher(distribution, name=project.name, release=options.release)
+ launcher = bundling.launcher(distribution, name=project.name, directory=options.directory)
if not launcher.is_file():
raise SystemExit(f"Build failed: PyInstaller produced no executable at {launcher}.")
print("Verifying the bundle...")
- status = runner((str(launcher), SELF_CHECK), cwd=root, environment=environment, quiet=False)
+ status = runner(self_check_command(launcher, options), cwd=root, environment=environment, quiet=False)
if status != 0:
raise SystemExit(f"Build failed: {launcher} did not pass its self-check.")
@@ -182,7 +243,7 @@ def build_bundle(
def main(argv: Sequence[str]) -> int:
- """Builds the standalone bundle, as a development build or a release."""
+ """Builds the standalone bundle, as a development build or a release, with GPU support where a driver is found."""
parser = argparse.ArgumentParser(description="Build the standalone SampleToNES bundle.")
parser.add_argument(
"--release",
@@ -191,16 +252,21 @@ def main(argv: Sequence[str]) -> int:
)
parser.add_argument(
"--gpu",
- action="store_true",
- help="build with GPU support",
+ default=DEFAULT_GPU,
+ choices=GPU_CHOICES,
+ help="auto to match the NVIDIA driver, 0 for the CPU backend, or the GPU extra to bundle",
)
arguments = parser.parse_args(list(argv))
- options = BundleOptions(release=arguments.release, gpu=arguments.gpu)
+ platform = current_platform()
+ options = BundleOptions(
+ release=arguments.release,
+ gpu_extra=gpu_extra(arguments.gpu, platform, os.environ),
+ )
print(f"Detected Python version: {running_version()}")
build_bundle(
repository_root(),
- current_platform(),
+ platform,
options,
runner=run,
environment=os.environ,
diff --git a/scripts/pyinstaller_hooks/hook-cupy.py b/scripts/pyinstaller_hooks/hook-cupy.py
new file mode 100644
index 000000000..4acbac231
--- /dev/null
+++ b/scripts/pyinstaller_hooks/hook-cupy.py
@@ -0,0 +1,50 @@
+# pylint: disable=invalid-name
+import sysconfig
+from pathlib import Path
+from typing import Final, List, Tuple
+
+CUDA_PACKAGE: Final[str] = "nvidia"
+LIBRARY_DIRECTORIES: Final[Tuple[str, ...]] = ("lib", "bin")
+INCLUDE_DIRECTORY: Final[str] = "include"
+STANDARD_LIBRARY_IMPORTS: Final[Tuple[str, ...]] = ("graphlib",)
+PURE_LIBRARY: Final[str] = "purelib"
+
+
+def cuda_components(site_packages: Path) -> List[Path]:
+ """The CUDA components the NVIDIA wheels installed, each a directory holding shared libraries.
+
+ Args:
+ site_packages: The site-packages directory of the environment the bundle is built in.
+
+ Returns:
+ List[Path]: The component directories under the ``nvidia`` package, in name order.
+ """
+ root = site_packages / CUDA_PACKAGE
+ if not root.is_dir():
+ return []
+
+ return sorted(
+ component
+ for component in root.iterdir()
+ if any((component / libraries).is_dir() for libraries in LIBRARY_DIRECTORIES)
+ )
+
+
+def hidden_imports(components: List[Path]) -> List[str]:
+ """The modules PyInstaller's analysis misses: a standard-library import made inside compiled code, and
+ each CUDA component, whose hook collects its shared libraries."""
+ return [*STANDARD_LIBRARY_IMPORTS, *(f"{CUDA_PACKAGE}.{component.name}" for component in components)]
+
+
+def header_directories(components: List[Path]) -> List[Tuple[str, str]]:
+ """The CUDA header directories, placed where CuPy's kernel compiler finds them in the bundle."""
+ return [
+ (str(component / INCLUDE_DIRECTORY), f"{CUDA_PACKAGE}/{component.name}/{INCLUDE_DIRECTORY}")
+ for component in components
+ if (component / INCLUDE_DIRECTORY).is_dir()
+ ]
+
+
+INSTALLED: Final[List[Path]] = cuda_components(Path(sysconfig.get_paths()[PURE_LIBRARY]))
+hiddenimports = hidden_imports(INSTALLED)
+datas = header_directories(INSTALLED)
diff --git a/scripts/runtime_hooks/cuda_bundle.py b/scripts/runtime_hooks/cuda_bundle.py
new file mode 100644
index 000000000..9aa97afe6
--- /dev/null
+++ b/scripts/runtime_hooks/cuda_bundle.py
@@ -0,0 +1,15 @@
+import site
+import sys
+from typing import Final, Iterable, List, Optional
+
+BUNDLE: Final[str] = sys._MEIPASS # type: ignore[attr-defined] # pylint: disable=protected-access,no-member
+
+
+def bundle_site_packages(prefixes: Optional[Iterable[str]] = None) -> List[str]:
+ """The directories the CUDA wheels are searched in: the bundle, where PyInstaller placed them."""
+ del prefixes
+ return [BUNDLE]
+
+
+site.getsitepackages = bundle_site_packages
+site.ENABLE_USER_SITE = False
diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py
index e1358e9ba..f0daba10b 100644
--- a/scripts/setup_environment.py
+++ b/scripts/setup_environment.py
@@ -3,50 +3,14 @@
import platform as running
import sys
from pathlib import Path
-from typing import Final, List, Mapping, Optional, Sequence, Tuple
+from typing import List, Mapping, Optional, Sequence
-from bootstrap.cuda import detect
+from bootstrap.cuda import DEFAULT_GPU, GPU_CHOICES, gpu_extra
from bootstrap.layout import repository_root
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.processes import Runner, expect_success, run
-from bootstrap.project import DEVELOPMENT_GROUP, GPU_CUDA11_EXTRA, GPU_EXTRA, read_project
-
-GPU_AUTO: Final[str] = "auto"
-GPU_OFF: Final[str] = "0"
-GPU_CHOICES: Final[Tuple[str, ...]] = (
- GPU_AUTO,
- GPU_OFF,
- GPU_EXTRA,
- GPU_CUDA11_EXTRA,
-)
-DEFAULT_GPU: Final[str] = GPU_AUTO
-
-
-def gpu_extra(
- choice: str,
- platform: Platform,
- environment: Mapping[str, str],
-) -> Optional[str]:
- """The optional-dependency extra a GPU choice selects.
-
- Args:
- choice: ``auto`` to read the NVIDIA driver, ``0`` for the CPU backend, or an extra's name.
- platform: The system the setup runs on.
- environment: The variables the driver's locations are read from.
-
- Returns:
- Optional[str]: The extra, or ``None`` for the CPU backend.
- """
- if choice == GPU_OFF:
- return None
-
- if choice == GPU_AUTO:
- detection = detect(platform, environment)
- print(detection.reason, file=sys.stderr)
- return detection.extra
-
- return choice
+from bootstrap.project import DEVELOPMENT_GROUP, read_project
def setup_commands(extra: Optional[str]) -> List[List[str]]:
diff --git a/scripts/verify_bundle.py b/scripts/verify_bundle.py
index daeb751d9..c647f43ff 100644
--- a/scripts/verify_bundle.py
+++ b/scripts/verify_bundle.py
@@ -4,7 +4,7 @@
from pathlib import Path
from typing import Final, List, Mapping, Sequence
-from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES, repository_root
+from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, GPU_PACKAGES, NOTICES, repository_root
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.processes import Runner, run
@@ -19,6 +19,12 @@ def missing_notices(bundle: Path) -> List[str]:
return [name for name in NOTICES if not (bundle / name).is_file()]
+def carried_packages(bundle: Path, names: Sequence[str]) -> List[str]:
+ """The packages among ``names`` found in a bundle, beside its launcher or among its internals."""
+ directories = (bundle, bundle / INTERNAL_DIRECTORY)
+ return [name for name in names if any((directory / name).is_dir() for directory in directories)]
+
+
def carried_build_tools(bundle: Path) -> List[str]:
"""The build-time packages found in a bundle, which the notices place on the build machine.
@@ -26,8 +32,16 @@ def carried_build_tools(bundle: Path) -> List[str]:
belongs to the machine that builds it, so finding it here means the notices describe a
different set of components than the bundle ships.
"""
- directories = (bundle, bundle / INTERNAL_DIRECTORY)
- return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)]
+ return carried_packages(bundle, BUILD_TOOLS)
+
+
+def carried_gpu_packages(bundle: Path) -> List[str]:
+ """The GPU packages found in a bundle, which the published bundles leave out.
+
+ A release computes on the CPU, and GPU support is built locally. Finding CuPy or the CUDA
+ wheels here means the release was built with a GPU extra.
+ """
+ return carried_packages(bundle, GPU_PACKAGES)
def bundle_failures(
@@ -39,8 +53,8 @@ def bundle_failures(
) -> List[str]:
"""What keeps the release bundle under ``root`` from shipping, each as a line of its own.
- The bundle ships its notices, carries the application and its runtime dependencies alone, and
- offers a launcher that starts.
+ The bundle ships its notices, carries the application and its CPU runtime dependencies alone,
+ and offers a launcher that starts.
Args:
root: The repository the bundle was built in.
@@ -55,7 +69,7 @@ def bundle_failures(
SystemExit: If the system builds no bundle.
"""
name = read_project(root).name
- launcher = platform.bundling().launcher(root / DISTRIBUTION, name=name, release=True)
+ launcher = platform.bundling().launcher(root / DISTRIBUTION, name=name, directory=True)
bundle = launcher.parent
failures: List[str] = []
absent = missing_notices(bundle)
@@ -66,6 +80,10 @@ def bundle_failures(
if carried:
failures.append(f"Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}")
+ gpu = carried_gpu_packages(bundle)
+ if gpu:
+ failures.append(f"Bundle {bundle} carries GPU packages the published bundles leave out: {', '.join(gpu)}")
+
if not launcher.is_file():
failures.append(f"Bundle {bundle} offers no launcher at {launcher}")
return failures
diff --git a/src/sampletones/commands/self_check.py b/src/sampletones/commands/self_check.py
index a9957caa0..220b9fa02 100644
--- a/src/sampletones/commands/self_check.py
+++ b/src/sampletones/commands/self_check.py
@@ -5,19 +5,22 @@
NAME: Final[str] = "self-check"
HELP: Final[str] = "verify that this build's imports, bundled resources and configuration files are usable"
+GPU_FLAG: Final[str] = "--gpu"
def configure(parser: ArgumentParser) -> None:
- del parser
+ parser.add_argument(
+ GPU_FLAG,
+ action="store_true",
+ help="also verify that this build computes on an NVIDIA graphics card",
+ )
def run(arguments: Namespace) -> int:
"""Runs every startup check and answers with the status a packaged build is held to."""
- del arguments
-
from sampletones.self_check import run_self_check
- return run_self_check()
+ return run_self_check(gpu=arguments.gpu)
SELF_CHECK: Final[Command] = Command(
diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py
index 613c6ef53..2d612a207 100644
--- a/src/sampletones/self_check.py
+++ b/src/sampletones/self_check.py
@@ -14,6 +14,7 @@
KeyError,
TypeError,
ValueError,
+ RuntimeError,
SystemError,
SampleToNESError,
)
@@ -148,6 +149,20 @@ def _check_file_dialog_backend() -> str:
return type(select_file_dialog_backend()).__name__
+def _check_array_backend() -> str:
+ """Names the backend the build computes on, so a GPU build that lost CuPy says so in its inventory."""
+ from sampletones_shared.array import describe_array_backend
+
+ return describe_array_backend()
+
+
+def _check_gpu_backend() -> str:
+ """Computes on the graphics card, which is what a GPU build is held to."""
+ from sampletones_shared.array import exercise_gpu
+
+ return exercise_gpu()
+
+
CHECKS: Final[Tuple[SelfCheck, ...]] = (
SelfCheck(name="application import", run=_check_application_import),
SelfCheck(name="deployment config", run=_check_deployment_config),
@@ -159,17 +174,32 @@ def _check_file_dialog_backend() -> str:
SelfCheck(name="resources", run=_check_resources),
SelfCheck(name="export backends", run=_check_export_backends),
SelfCheck(name="file dialog backend", run=_check_file_dialog_backend),
+ SelfCheck(name="array backend", run=_check_array_backend),
)
+GPU_CHECK: Final[SelfCheck] = SelfCheck(name="gpu backend", run=_check_gpu_backend)
+
+
+def checks_for(*, gpu: bool) -> Tuple[SelfCheck, ...]:
+ """The checks a build runs: every startup check, and the GPU check where the build carries GPU support."""
+ if gpu:
+ return (*CHECKS, GPU_CHECK)
+ return CHECKS
-def run_self_check() -> int:
+
+def run_self_check(*, gpu: bool) -> int:
"""Runs every startup check in order and returns the process exit status.
Prints one line per check so a passing run doubles as an inventory of what the build
carries, and stops at the first failure with the offending check named on the standard
- error stream. A packaged build runs this to prove it starts before it is shipped.
+ error stream. A packaged build runs this to prove it starts before it is shipped, and a GPU
+ bundle runs it with ``gpu`` set to prove it computes on the graphics card.
+
+ Args:
+ gpu: Whether the build is held to the GPU backend.
"""
- for check in CHECKS:
+ checks = checks_for(gpu=gpu)
+ for check in checks:
try:
detail = check.run()
except CHECK_FAILURES as exception:
@@ -181,5 +211,5 @@ def run_self_check() -> int:
print(f"{SUCCESS_PREFIX} {check.name}: {detail}")
- print(f"{SUCCESS_PREFIX} {len(CHECKS)} checks passed")
+ print(f"{SUCCESS_PREFIX} {len(checks)} checks passed")
return SUCCESS_STATUS
diff --git a/src/sampletones_shared/array.py b/src/sampletones_shared/array.py
index 7b7dd1234..091610e36 100644
--- a/src/sampletones_shared/array.py
+++ b/src/sampletones_shared/array.py
@@ -1,12 +1,16 @@
import warnings
-from typing import Final, Optional, Type, Union
+from typing import Any, Final, Optional, Tuple, Type, Union
import numpy as np
-from sampletones_shared.exceptions import CuPyNotInstalledWarning
+from sampletones_shared.exceptions import CuPyNotInstalledWarning, GPUBackendError
from sampletones_shared.logger import logger
CUPY_MISSING_MESSAGE: Final[str] = "CuPy is not available, falling back to NumPy."
+CUPY_BACKEND: Final[str] = "CuPy"
+NUMPY_BACKEND: Final[str] = "NumPy"
+GPU_PROBE_SIZE: Final[int] = 64
+GPU_PROBE_MISMATCH: Final[str] = "The graphics card answered {device}, while the CPU answered {host}."
def _preload_cuda_libraries() -> None:
@@ -59,6 +63,47 @@ def _format_warning_no_location(
xp = np
+def describe_array_backend() -> str:
+ """The array backend the process computes on, named with its version."""
+ name = CUPY_BACKEND if CUPY_AVAILABLE else NUMPY_BACKEND
+ return f"{name} {xp.__version__}"
+
+
+def _probe_sums(module: Any) -> Tuple[float, float]:
+ """The sum of squares and the summed outer product of the first probe-size integers, computed by ``module``.
+
+ Both sums stay below 2**24 at the probe size, so single precision carries them exactly in any
+ order of summation and the card's answer is held to the CPU's as an equality.
+ """
+ values = module.arange(GPU_PROBE_SIZE, dtype=module.float32)
+ return float((values * values).sum()), float((values[:, None] @ values[None, :]).sum())
+
+
+def exercise_gpu() -> str:
+ """Computes on the graphics card, holds its answer to the CPU's, and names the device.
+
+ A kernel is compiled for the elementwise product, and the matrix product goes through cuBLAS,
+ so a build missing the CUDA libraries or the headers the compiler reads fails here rather than
+ in a conversion.
+
+ Returns:
+ str: The backend and the device's name.
+
+ Raises:
+ GPUBackendError: If the process computes on the CPU, or the card's answer differs from the CPU's.
+ """
+ if not CUPY_AVAILABLE:
+ raise GPUBackendError(CUPY_MISSING_MESSAGE)
+
+ device = _probe_sums(xp)
+ host = _probe_sums(np)
+ if device != host:
+ raise GPUBackendError(GPU_PROBE_MISMATCH.format(device=device, host=host))
+
+ properties = xp.cuda.runtime.getDeviceProperties(xp.cuda.runtime.getDevice())
+ return f"{describe_array_backend()} on {properties['name'].decode()}"
+
+
def report_array_backend() -> None:
"""States which array backend the process computes on.
@@ -68,7 +113,7 @@ def report_array_backend() -> None:
imports the array vocabulary for its types alone to its own output.
"""
if CUPY_AVAILABLE:
- logger.info(f"CuPy {xp.__version__} is active")
+ logger.info(f"{describe_array_backend()} is active")
return
warnings.formatwarning = _format_warning_no_location
@@ -88,6 +133,8 @@ def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray:
__all__ = [
"CUPY_AVAILABLE",
+ "describe_array_backend",
+ "exercise_gpu",
"report_array_backend",
"to_numpy",
"xp",
diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py
index 08c783f8c..6864d640c 100644
--- a/src/sampletones_shared/exceptions/__init__.py
+++ b/src/sampletones_shared/exceptions/__init__.py
@@ -6,7 +6,7 @@
)
from .base import SampleToNESError
from .callback import CallbackQueueStop
-from .cuda import CuPyNotInstalledWarning
+from .cuda import CuPyNotInstalledWarning, GPUBackendError
from .dialog import FileDialogUnavailableError
from .instrument import (
IncompatibleInstrumentVersionError,
@@ -71,6 +71,7 @@
"DeserializationError",
"DriverBuildError",
"FileDialogUnavailableError",
+ "GPUBackendError",
"IncompatibleInstrumentVersionError",
"IncompatibleLibraryDataVersionError",
"IncompatibleProjectVersionError",
diff --git a/src/sampletones_shared/exceptions/cuda.py b/src/sampletones_shared/exceptions/cuda.py
index 932b50f7c..f9c87b139 100644
--- a/src/sampletones_shared/exceptions/cuda.py
+++ b/src/sampletones_shared/exceptions/cuda.py
@@ -1,5 +1,9 @@
-from .base import SampleToNESWarning
+from .base import SampleToNESError, SampleToNESWarning
class CuPyNotInstalledWarning(SampleToNESWarning):
"""Warning raised when CuPy is not installed."""
+
+
+class GPUBackendError(SampleToNESError):
+ """Raised when a build held to the GPU backend computes on the CPU, or the card answers wrong."""
diff --git a/tests/unit/sampletones/commands/test_self_check.py b/tests/unit/sampletones/commands/test_self_check.py
index 2748135e4..0bae99808 100644
--- a/tests/unit/sampletones/commands/test_self_check.py
+++ b/tests/unit/sampletones/commands/test_self_check.py
@@ -1,6 +1,9 @@
+from typing import List
+
import pytest
from sampletones.commands.registry import COMMANDS
+from sampletones.commands.self_check import GPU_FLAG
from sampletones.dispatcher import dispatch
CHECK = "sampletones.self_check.run_self_check"
@@ -8,6 +11,19 @@
class TestSelfCheck:
def test_the_status_is_the_checks_own(self, monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(CHECK, lambda: 7)
+ monkeypatch.setattr(CHECK, lambda *, gpu: 7)
assert dispatch(COMMANDS, ["self-check"]) == 7
+
+ def test_the_gpu_flag_holds_the_build_to_the_gpu(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ held: List[bool] = []
+
+ def run_self_check(*, gpu: bool) -> int:
+ held.append(gpu)
+ return 0
+
+ monkeypatch.setattr(CHECK, run_self_check)
+
+ assert dispatch(COMMANDS, ["self-check"]) == 0
+ assert dispatch(COMMANDS, ["self-check", GPU_FLAG]) == 0
+ assert held == [False, True]
diff --git a/tests/unit/sampletones/test_self_check.py b/tests/unit/sampletones/test_self_check.py
index ac2476f7d..dd3a57f0e 100644
--- a/tests/unit/sampletones/test_self_check.py
+++ b/tests/unit/sampletones/test_self_check.py
@@ -2,16 +2,19 @@
import pytest
-from sampletones.self_check import CHECKS, FAILURE_STATUS, SUCCESS_STATUS, run_self_check
+from sampletones.self_check import CHECKS, FAILURE_STATUS, GPU_CHECK, SUCCESS_STATUS, checks_for, run_self_check
+from sampletones_shared import array
+from sampletones_shared.array import CUPY_AVAILABLE
from sampletones_shared.exceptions import FileDialogUnavailableError
RESOURCES_MODULE = "sampletones_application.ui.resources.resources"
SELECTION_MODULE = "sampletones_application.utils.file_dialogs.selection"
+CUPY_REQUIRED_REASON = "the GPU check needs CuPy and an NVIDIA graphics card"
class TestRunSelfCheck:
def test_source_checkout_passes(self, capsys: pytest.CaptureFixture[str]) -> None:
- status = run_self_check()
+ status = run_self_check(gpu=False)
output = capsys.readouterr().out
assert status == SUCCESS_STATUS
@@ -20,7 +23,7 @@ def test_source_checkout_passes(self, capsys: pytest.CaptureFixture[str]) -> Non
def test_missing_resource_fails(self, capsys: pytest.CaptureFixture[str]) -> None:
with patch(f"{RESOURCES_MODULE}.get_font_path", side_effect=FileNotFoundError("Resource not found")):
- status = run_self_check()
+ status = run_self_check(gpu=False)
captured = capsys.readouterr()
@@ -34,7 +37,45 @@ def test_unavailable_dialog_backend_fails(self, capsys: pytest.CaptureFixture[st
f"{SELECTION_MODULE}.select_file_dialog_backend",
side_effect=FileDialogUnavailableError("No file dialog backend is available."),
):
- status = run_self_check()
+ status = run_self_check(gpu=False)
assert status == FAILURE_STATUS
assert "file dialog backend" in capsys.readouterr().err
+
+
+class TestChecksFor:
+ def test_a_cpu_build_runs_the_startup_checks_alone(self) -> None:
+ assert checks_for(gpu=False) == CHECKS
+
+ def test_a_gpu_build_adds_the_gpu_check_last(self) -> None:
+ assert checks_for(gpu=True) == (*CHECKS, GPU_CHECK)
+
+
+class TestGpuCheck:
+ def test_the_inventory_names_the_array_backend(self, capsys: pytest.CaptureFixture[str]) -> None:
+ run_self_check(gpu=False)
+
+ assert "array backend" in capsys.readouterr().out
+
+ def test_a_gpu_build_computing_on_the_cpu_fails(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ monkeypatch.setattr(array, "CUPY_AVAILABLE", False)
+
+ status = run_self_check(gpu=True)
+
+ captured = capsys.readouterr()
+ assert status == FAILURE_STATUS
+ assert GPU_CHECK.name in captured.err
+ assert "GPUBackendError" in captured.err
+
+ @pytest.mark.skipif(not CUPY_AVAILABLE, reason=CUPY_REQUIRED_REASON)
+ def test_a_gpu_build_computing_on_the_card_passes(self, capsys: pytest.CaptureFixture[str]) -> None:
+ status = run_self_check(gpu=True)
+
+ output = capsys.readouterr().out
+ assert status == SUCCESS_STATUS
+ assert GPU_CHECK.name in output
+ assert f"{len(CHECKS) + 1} checks passed" in output
diff --git a/tests/unit/sampletones_shared/test_array.py b/tests/unit/sampletones_shared/test_array.py
new file mode 100644
index 000000000..413e6267c
--- /dev/null
+++ b/tests/unit/sampletones_shared/test_array.py
@@ -0,0 +1,43 @@
+import numpy as np
+import pytest
+
+from sampletones_shared import array
+from sampletones_shared.array import CUPY_AVAILABLE, describe_array_backend, exercise_gpu, xp
+from sampletones_shared.exceptions import GPUBackendError
+
+CUPY_REQUIRED_REASON = "the GPU backend needs CuPy and an NVIDIA graphics card"
+
+
+class TestDescribeArrayBackend:
+ def test_the_backend_is_named_with_its_version(self) -> None:
+ description = describe_array_backend()
+
+ assert description.endswith(xp.__version__)
+ assert description.startswith(array.CUPY_BACKEND if CUPY_AVAILABLE else array.NUMPY_BACKEND)
+
+ def test_the_cpu_backend_is_numpy(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(array, "CUPY_AVAILABLE", False)
+
+ assert describe_array_backend().startswith(array.NUMPY_BACKEND)
+
+
+class TestExerciseGpu:
+ def test_a_cpu_build_is_refused(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(array, "CUPY_AVAILABLE", False)
+
+ with pytest.raises(GPUBackendError, match="falling back to NumPy"):
+ exercise_gpu()
+
+ def test_the_probe_sums_are_exact_in_single_precision(self) -> None:
+ squares, products = array._probe_sums(np) # pylint: disable=protected-access
+
+ assert squares == sum(value * value for value in range(array.GPU_PROBE_SIZE))
+ assert products == sum(range(array.GPU_PROBE_SIZE)) ** 2
+ assert max(squares, products) < 2**24
+
+ @pytest.mark.skipif(not CUPY_AVAILABLE, reason=CUPY_REQUIRED_REASON)
+ def test_the_card_answers_and_is_named(self) -> None:
+ description = exercise_gpu()
+
+ assert description.startswith(describe_array_backend())
+ assert " on " in description
diff --git a/tests/unit/scripts/bootstrap/platforms/test_bundling.py b/tests/unit/scripts/bootstrap/platforms/test_bundling.py
index 172169618..300b5ea95 100644
--- a/tests/unit/scripts/bootstrap/platforms/test_bundling.py
+++ b/tests/unit/scripts/bootstrap/platforms/test_bundling.py
@@ -24,27 +24,27 @@ class TestLauncher(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
suffix: str
- release: bool
+ directory: bool
expected: Path
test_cases = (
- TestCase(label="a development bundle is one file", suffix="", release=False, expected=Path("bin", NAME)),
+ TestCase(label="a cpu development bundle is one file", suffix="", directory=False, expected=Path("bin", NAME)),
TestCase(
- label="a release is a directory beside its launcher",
+ label="a directory bundle sits beside its launcher",
suffix="",
- release=True,
+ directory=True,
expected=Path("bin", NAME, NAME),
),
TestCase(
label="the suffix ends the launcher's name",
suffix=".exe",
- release=True,
+ directory=True,
expected=Path("bin", NAME, f"{NAME}.exe"),
),
)
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_the_launcher_lies_where_pyinstaller_writes_it(self, test_case: TestCase) -> None:
- launcher = _bundling(test_case.suffix).launcher(Path("bin"), name=NAME, release=test_case.release)
+ launcher = _bundling(test_case.suffix).launcher(Path("bin"), name=NAME, directory=test_case.directory)
assert launcher == test_case.expected
diff --git a/tests/unit/scripts/bootstrap/platforms/test_linux.py b/tests/unit/scripts/bootstrap/platforms/test_linux.py
index c7af9df0d..7a9cc4843 100644
--- a/tests/unit/scripts/bootstrap/platforms/test_linux.py
+++ b/tests/unit/scripts/bootstrap/platforms/test_linux.py
@@ -8,7 +8,9 @@ def test_a_virtual_environment_runs_bin_python(self) -> None:
assert Linux().interpreter(Path(".venv-build")) == Path(".venv-build", "bin", "python")
def test_a_bundle_launcher_carries_no_extension(self) -> None:
- assert Linux().bundling().launcher(Path("bin"), name="sampletones", release=False) == Path("bin", "sampletones")
+ assert Linux().bundling().launcher(Path("bin"), name="sampletones", directory=False) == Path(
+ "bin", "sampletones"
+ )
def test_apt_is_updated_before_the_packages_are_installed(self) -> None:
commands = Linux().system_packages()
diff --git a/tests/unit/scripts/bootstrap/platforms/test_windows.py b/tests/unit/scripts/bootstrap/platforms/test_windows.py
index 77fc8fb3a..228a62b96 100644
--- a/tests/unit/scripts/bootstrap/platforms/test_windows.py
+++ b/tests/unit/scripts/bootstrap/platforms/test_windows.py
@@ -8,7 +8,7 @@ def test_a_virtual_environment_runs_scripts_python(self) -> None:
assert Windows().interpreter(Path(".venv-build")) == Path(".venv-build", "Scripts", "python.exe")
def test_a_bundle_launcher_carries_the_executable_extension(self) -> None:
- launcher = Windows().bundling().launcher(Path("bin"), name="sampletones", release=True)
+ launcher = Windows().bundling().launcher(Path("bin"), name="sampletones", directory=True)
assert launcher == Path("bin", "sampletones", "sampletones.exe")
diff --git a/tests/unit/scripts/bootstrap/test_cuda.py b/tests/unit/scripts/bootstrap/test_cuda.py
index cb565bfa9..d0bf816e7 100644
--- a/tests/unit/scripts/bootstrap/test_cuda.py
+++ b/tests/unit/scripts/bootstrap/test_cuda.py
@@ -229,3 +229,24 @@ def test_selects_gpu_for_cuda12_driver(self, monkeypatch: pytest.MonkeyPatch) ->
assert detection.extra == GPU_EXTRA
assert "12.4" in detection.reason
+
+
+class TestGpuExtra:
+ def test_zero_keeps_the_cpu_backend(self) -> None:
+ assert cuda.gpu_extra(cuda.GPU_OFF, Linux(), {}) is None
+
+ def test_a_named_extra_is_taken_as_given(self) -> None:
+ assert cuda.gpu_extra(GPU_CUDA11_EXTRA, Linux(), {}) == GPU_CUDA11_EXTRA
+
+ def test_auto_on_macos_keeps_the_cpu_backend(self) -> None:
+ assert cuda.gpu_extra(cuda.GPU_AUTO, MacOS(), {}) is None
+
+ def test_auto_follows_the_driver(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(cuda.shutil, "which", lambda name: "/usr/bin/nvidia-smi")
+ monkeypatch.setattr(cuda.subprocess, "run", lambda command, **_: _completed(TABLE_OUTPUT_CUDA12))
+
+ assert cuda.gpu_extra(cuda.GPU_AUTO, Linux(), {}) == GPU_EXTRA
+
+ def test_every_choice_is_auto_off_or_an_extra(self) -> None:
+ assert set(cuda.GPU_CHOICES) == {cuda.GPU_AUTO, cuda.GPU_OFF, GPU_EXTRA, GPU_CUDA11_EXTRA}
+ assert cuda.DEFAULT_GPU == cuda.GPU_AUTO
diff --git a/tests/unit/scripts/bootstrap/test_preflight.py b/tests/unit/scripts/bootstrap/test_preflight.py
index 08f061205..014d8d15b 100644
--- a/tests/unit/scripts/bootstrap/test_preflight.py
+++ b/tests/unit/scripts/bootstrap/test_preflight.py
@@ -1,9 +1,10 @@
+import re
from pathlib import Path
import pytest
from bootstrap.platforms.linux import Linux
-from bootstrap.preflight import can_import, check_build_interpreter
+from bootstrap.preflight import CUPY_ADVICE, can_import, check_build_interpreter
from tests.suite.bootstrap import RecordingRunner
@@ -32,6 +33,7 @@ def test_an_interpreter_without_audio_playback_is_refused_with_the_platform_s_ad
python,
Linux().bundling(),
release=False,
+ gpu=False,
runner=RecordingRunner({"import pyaudio": 1}, None),
cwd=tmp_path,
environment={},
@@ -43,6 +45,7 @@ def test_a_release_without_tk_is_refused(self, python: Path, tmp_path: Path) ->
python,
Linux().bundling(),
release=True,
+ gpu=False,
runner=RecordingRunner({"import tkinter": 1}, None),
cwd=tmp_path,
environment={},
@@ -58,6 +61,7 @@ def test_a_development_bundle_without_tk_is_warned(
python,
Linux().bundling(),
release=False,
+ gpu=False,
runner=RecordingRunner({"import tkinter": 1}, None),
cwd=tmp_path,
environment={},
@@ -65,6 +69,51 @@ def test_a_development_bundle_without_tk_is_warned(
assert "WARNING" in capsys.readouterr().out
+ def test_a_gpu_bundle_without_cupy_is_refused_with_the_opt_out(self, python: Path, tmp_path: Path) -> None:
+ with pytest.raises(SystemExit, match=re.escape(CUPY_ADVICE)):
+ check_build_interpreter(
+ python,
+ Linux().bundling(),
+ release=False,
+ gpu=True,
+ runner=RecordingRunner({"import cupy": 1}, None),
+ cwd=tmp_path,
+ environment={},
+ )
+
+ def test_a_cpu_bundle_never_probes_cupy(self, python: Path, tmp_path: Path) -> None:
+ runner = RecordingRunner({"import cupy": 1}, None)
+
+ check_build_interpreter(
+ python,
+ Linux().bundling(),
+ release=False,
+ gpu=False,
+ runner=runner,
+ cwd=tmp_path,
+ environment={},
+ )
+
+ assert all("cupy" not in line for line in runner.lines)
+
+ def test_a_gpu_interpreter_carrying_cupy_passes(
+ self,
+ python: Path,
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ check_build_interpreter(
+ python,
+ Linux().bundling(),
+ release=False,
+ gpu=True,
+ runner=RecordingRunner({}, None),
+ cwd=tmp_path,
+ environment={},
+ )
+
+ assert "cupy: available" in capsys.readouterr().out
+
def test_an_interpreter_carrying_both_passes(
self,
python: Path,
@@ -75,6 +124,7 @@ def test_an_interpreter_carrying_both_passes(
python,
Linux().bundling(),
release=True,
+ gpu=False,
runner=RecordingRunner({}, None),
cwd=tmp_path,
environment={},
diff --git a/tests/unit/scripts/pyinstaller_hooks/test_hook_cupy.py b/tests/unit/scripts/pyinstaller_hooks/test_hook_cupy.py
new file mode 100644
index 000000000..b2aea5ef8
--- /dev/null
+++ b/tests/unit/scripts/pyinstaller_hooks/test_hook_cupy.py
@@ -0,0 +1,65 @@
+from pathlib import Path
+
+import pytest
+
+from tests.suite.scripts import load_script
+
+hook = load_script("pyinstaller_hooks/hook-cupy.py")
+
+
+@pytest.fixture(name="site_packages")
+def site_packages_fixture(tmp_path: Path) -> Path:
+ """A site-packages holding two CUDA components, a cache directory and a component without headers."""
+ root = tmp_path / hook.CUDA_PACKAGE
+ (root / "cublas" / "lib").mkdir(parents=True)
+ (root / "cublas" / "include").mkdir()
+ (root / "cuda_runtime" / "lib").mkdir(parents=True)
+ (root / "cuda_runtime" / "include").mkdir()
+ (root / "nvjitlink" / "bin").mkdir(parents=True)
+ (root / "__pycache__").mkdir()
+ (root / "__init__.py").write_text("")
+ return tmp_path
+
+
+class TestCudaComponents:
+ def test_each_directory_holding_libraries_is_a_component(self, site_packages: Path) -> None:
+ components = hook.cuda_components(site_packages)
+
+ assert [component.name for component in components] == ["cublas", "cuda_runtime", "nvjitlink"]
+
+ def test_an_environment_without_the_cuda_wheels_has_none(self, tmp_path: Path) -> None:
+ assert hook.cuda_components(tmp_path) == []
+
+
+class TestHiddenImports:
+ def test_the_standard_library_import_leads_and_every_component_follows(self, site_packages: Path) -> None:
+ imports = hook.hidden_imports(hook.cuda_components(site_packages))
+
+ assert imports[: len(hook.STANDARD_LIBRARY_IMPORTS)] == list(hook.STANDARD_LIBRARY_IMPORTS)
+ assert imports[len(hook.STANDARD_LIBRARY_IMPORTS) :] == [
+ f"{hook.CUDA_PACKAGE}.cublas",
+ f"{hook.CUDA_PACKAGE}.cuda_runtime",
+ f"{hook.CUDA_PACKAGE}.nvjitlink",
+ ]
+
+
+class TestHeaderDirectories:
+ def test_each_include_directory_lands_at_its_own_package_path(self, site_packages: Path) -> None:
+ components = hook.cuda_components(site_packages)
+
+ assert hook.header_directories(components) == [
+ (
+ str(site_packages / hook.CUDA_PACKAGE / "cublas" / "include"),
+ f"{hook.CUDA_PACKAGE}/cublas/include",
+ ),
+ (
+ str(site_packages / hook.CUDA_PACKAGE / "cuda_runtime" / "include"),
+ f"{hook.CUDA_PACKAGE}/cuda_runtime/include",
+ ),
+ ]
+
+
+class TestModuleGlobals:
+ def test_the_hook_states_what_pyinstaller_reads(self) -> None:
+ assert hook.hiddenimports[: len(hook.STANDARD_LIBRARY_IMPORTS)] == list(hook.STANDARD_LIBRARY_IMPORTS)
+ assert all(isinstance(source, str) and isinstance(target, str) for source, target in hook.datas)
diff --git a/tests/unit/scripts/runtime_hooks/test_cuda_bundle.py b/tests/unit/scripts/runtime_hooks/test_cuda_bundle.py
new file mode 100644
index 000000000..bfcf6f871
--- /dev/null
+++ b/tests/unit/scripts/runtime_hooks/test_cuda_bundle.py
@@ -0,0 +1,20 @@
+import site
+import sys
+from pathlib import Path
+
+import pytest
+
+from tests.suite.scripts import load_script
+
+
+class TestCudaBundleHook:
+ def test_the_bundle_is_the_only_site_packages(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False)
+ monkeypatch.setattr(site, "getsitepackages", site.getsitepackages)
+ monkeypatch.setattr(site, "ENABLE_USER_SITE", site.ENABLE_USER_SITE)
+
+ load_script("runtime_hooks/cuda_bundle.py")
+
+ assert site.getsitepackages() == [str(tmp_path)]
+ assert site.getsitepackages(["/elsewhere"]) == [str(tmp_path)]
+ assert site.ENABLE_USER_SITE is False
diff --git a/tests/unit/scripts/test_archive_bundle.py b/tests/unit/scripts/test_archive_bundle.py
index 47bfd7a46..4ee35e505 100644
--- a/tests/unit/scripts/test_archive_bundle.py
+++ b/tests/unit/scripts/test_archive_bundle.py
@@ -110,7 +110,7 @@ def test_the_root_names_the_project_its_version_and_the_platform(self, tmp_path:
class TestArchiveRelease:
def test_the_release_bundle_is_archived_into_the_bundles_directory(self, tmp_path: Path) -> None:
root = write_project(tmp_path / "repository")
- launcher = Windows().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, release=True)
+ launcher = Windows().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, directory=True)
launcher.parent.mkdir(parents=True)
launcher.write_bytes(b"MZ")
diff --git a/tests/unit/scripts/test_bundle.py b/tests/unit/scripts/test_bundle.py
index cb4422d80..f6eaa6e21 100644
--- a/tests/unit/scripts/test_bundle.py
+++ b/tests/unit/scripts/test_bundle.py
@@ -1,18 +1,33 @@
from pathlib import Path
-from typing import Callable, Sequence
+from typing import Callable, List, Sequence
import pytest
-from bootstrap.layout import BUILD_ENVIRONMENT, BUILD_TOOLS, DISTRIBUTION, NOTICES, RELEASE_HOOK
+from bootstrap.cuda import GPU_CHOICES
+from bootstrap.layout import (
+ BUILD_ENVIRONMENT,
+ BUILD_TOOLS,
+ CUPY_PACKAGES,
+ DISTRIBUTION,
+ GPU_HOOK,
+ GPU_PACKAGES,
+ NOTICES,
+ PYINSTALLER_HOOKS,
+ RELEASE_HOOK,
+)
from bootstrap.platforms.linux import Linux
from bootstrap.platforms.macos import MacOS
from bootstrap.platforms.windows import Windows
-from bootstrap.project import BUILD_EXTRA, GPU_EXTRA, read_project
+from bootstrap.project import BUILD_EXTRA, GPU_CUDA11_EXTRA, GPU_EXTRA, read_project
from tests.suite.bootstrap import PROJECT_NAME, RecordingRunner, write_project
from tests.suite.scripts import load_script
bundle = load_script("bundle.py")
+CPU_RELEASE = bundle.BundleOptions(release=True, gpu_extra=None)
+CPU_DEVELOPMENT = bundle.BundleOptions(release=False, gpu_extra=None)
+GPU_DEVELOPMENT = bundle.BundleOptions(release=False, gpu_extra=GPU_EXTRA)
+
def _repository(tmp_path: Path) -> Path:
for notice in NOTICES:
@@ -21,53 +36,90 @@ def _repository(tmp_path: Path) -> Path:
return write_project(tmp_path)
+def _values(command: Sequence[str], flag: str) -> List[str]:
+ return [command[index + 1] for index, argument in enumerate(command) if argument == flag]
+
+
+class TestBundleOptions:
+ def test_a_release_and_a_gpu_bundle_are_directories(self) -> None:
+ assert CPU_RELEASE.directory
+ assert GPU_DEVELOPMENT.directory
+ assert not CPU_DEVELOPMENT.directory
+
+ def test_gpu_support_follows_the_extra(self) -> None:
+ assert GPU_DEVELOPMENT.gpu
+ assert not CPU_RELEASE.gpu
+
+
class TestPyInstallerCommand:
def test_a_release_is_a_directory_with_the_runtime_hook(self, tmp_path: Path) -> None:
project = read_project(_repository(tmp_path))
- options = bundle.BundleOptions(release=True, gpu=False)
- command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, options)
+ command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, CPU_RELEASE)
assert command[:3] == ["python", "-m", "PyInstaller"]
assert "--onedir" in command
- assert command[command.index("--runtime-hook") + 1] == RELEASE_HOOK
+ assert _values(command, "--runtime-hook") == [RELEASE_HOOK]
assert command[-1] == project.entry_script
- def test_a_development_bundle_is_one_file_without_the_hook(self, tmp_path: Path) -> None:
+ def test_a_cpu_development_bundle_is_one_file_without_the_hook(self, tmp_path: Path) -> None:
project = read_project(_repository(tmp_path))
- command = bundle.pyinstaller_command(
- Path("python"),
- Linux().bundling(),
- project,
- bundle.BundleOptions(release=False, gpu=False),
- )
+ command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, CPU_DEVELOPMENT)
assert "--onefile" in command
assert "--runtime-hook" not in command
- def test_every_package_brings_its_data_and_the_build_tools_stay_out(self, tmp_path: Path) -> None:
+ def test_every_package_brings_its_data_and_the_build_tools_and_gpu_packages_stay_out(self, tmp_path: Path) -> None:
project = read_project(_repository(tmp_path))
- command = bundle.pyinstaller_command(
- Path("python"),
- Windows().bundling(),
- project,
- bundle.BundleOptions(release=False, gpu=False),
- )
+ command = bundle.pyinstaller_command(Path("python"), Windows().bundling(), project, CPU_DEVELOPMENT)
+
+ assert _values(command, "--collect-data") == list(project.packages)
+ assert _values(command, "--exclude-module") == [*BUILD_TOOLS, *GPU_PACKAGES]
+ assert _values(command, "--icon") == [Windows().bundling().icon]
+ assert _values(command, "--name") == [project.name]
+
+ def test_a_gpu_bundle_is_a_directory_collecting_cupy_through_the_hooks(self, tmp_path: Path) -> None:
+ project = read_project(_repository(tmp_path))
+
+ command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, GPU_DEVELOPMENT)
+
+ assert "--onedir" in command
+ assert _values(command, "--collect-all") == list(CUPY_PACKAGES)
+ assert _values(command, "--additional-hooks-dir") == [PYINSTALLER_HOOKS]
+ assert _values(command, "--runtime-hook") == [GPU_HOOK]
+ assert _values(command, "--exclude-module") == list(BUILD_TOOLS)
+
+ def test_a_gpu_release_carries_both_runtime_hooks(self, tmp_path: Path) -> None:
+ project = read_project(_repository(tmp_path))
+ options = bundle.BundleOptions(release=True, gpu_extra=GPU_EXTRA)
+
+ command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, options)
- collected = [command[index + 1] for index, flag in enumerate(command) if flag == "--collect-data"]
- excluded = [command[index + 1] for index, flag in enumerate(command) if flag == "--exclude-module"]
- assert collected == list(project.packages)
- assert excluded == list(BUILD_TOOLS)
- assert command[command.index("--icon") + 1] == Windows().bundling().icon
- assert command[command.index("--name") + 1] == project.name
+ assert _values(command, "--runtime-hook") == [GPU_HOOK, RELEASE_HOOK]
class TestExtras:
- def test_the_build_extra_is_always_installed_and_gpu_on_request(self) -> None:
- assert bundle.extras(bundle.BundleOptions(release=False, gpu=False)) == (BUILD_EXTRA,)
- assert bundle.extras(bundle.BundleOptions(release=False, gpu=True)) == (BUILD_EXTRA, GPU_EXTRA)
+ def test_the_build_extra_is_always_installed_and_the_gpu_extra_beside_it(self) -> None:
+ assert bundle.extras(CPU_DEVELOPMENT) == (BUILD_EXTRA,)
+ assert bundle.extras(GPU_DEVELOPMENT) == (BUILD_EXTRA, GPU_EXTRA)
+ assert bundle.extras(bundle.BundleOptions(release=False, gpu_extra=GPU_CUDA11_EXTRA)) == (
+ BUILD_EXTRA,
+ GPU_CUDA11_EXTRA,
+ )
+
+
+class TestSelfCheckCommand:
+ def test_a_gpu_bundle_is_held_to_the_gpu_check(self) -> None:
+ launcher = Path("bin", PROJECT_NAME, PROJECT_NAME)
+
+ assert bundle.self_check_command(launcher, CPU_DEVELOPMENT) == (str(launcher), bundle.SELF_CHECK)
+ assert bundle.self_check_command(launcher, GPU_DEVELOPMENT) == (
+ str(launcher),
+ bundle.SELF_CHECK,
+ bundle.GPU_FLAG,
+ )
class TestRemovePrevious:
@@ -99,12 +151,10 @@ def on_run(command: Sequence[str]) -> None:
class TestBuildBundle:
def test_a_release_runs_every_step_in_order_and_places_the_notices(self, tmp_path: Path) -> None:
root = _repository(tmp_path)
- launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, release=True)
+ launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, directory=True)
runner = RecordingRunner({}, _leave_behind(root, launcher))
- built = bundle.build_bundle(
- root, Linux(), bundle.BundleOptions(release=True, gpu=False), runner=runner, environment={}
- )
+ built = bundle.build_bundle(root, Linux(), CPU_RELEASE, runner=runner, environment={})
assert built == launcher
assert runner.lines[0].endswith(BUILD_ENVIRONMENT)
@@ -116,6 +166,21 @@ def test_a_release_runs_every_step_in_order_and_places_the_notices(self, tmp_pat
assert runner.lines[6] == f"{launcher} {bundle.SELF_CHECK}"
assert all((launcher.parent / notice).read_text() == notice for notice in NOTICES)
+ def test_a_gpu_bundle_installs_the_extra_probes_cupy_and_lands_in_a_directory(self, tmp_path: Path) -> None:
+ root = _repository(tmp_path)
+ launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, directory=True)
+ runner = RecordingRunner({}, _leave_behind(root, launcher))
+
+ built = bundle.build_bundle(root, Linux(), GPU_DEVELOPMENT, runner=runner, environment={})
+
+ assert built == launcher
+ assert f".[{BUILD_EXTRA},{GPU_EXTRA}]" in runner.lines[2]
+ assert "import cupy" in runner.lines[4]
+ assert "import tkinter" in runner.lines[5]
+ assert "PyInstaller" in runner.lines[6]
+ assert runner.lines[7] == f"{launcher} {bundle.SELF_CHECK} {bundle.GPU_FLAG}"
+ assert not any((launcher.parent / notice).exists() for notice in NOTICES)
+
def test_a_bundle_pyinstaller_never_wrote_is_reported(self, tmp_path: Path) -> None:
root = _repository(tmp_path)
elsewhere = tmp_path / "elsewhere" / PROJECT_NAME
@@ -124,20 +189,20 @@ def test_a_bundle_pyinstaller_never_wrote_is_reported(self, tmp_path: Path) -> N
bundle.build_bundle(
root,
Linux(),
- bundle.BundleOptions(release=False, gpu=False),
+ CPU_DEVELOPMENT,
runner=RecordingRunner({}, _leave_behind(root, elsewhere)),
environment={},
)
def test_a_launcher_failing_its_self_check_fails_the_build(self, tmp_path: Path) -> None:
root = _repository(tmp_path)
- launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, release=False)
+ launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, directory=False)
with pytest.raises(SystemExit, match=bundle.SELF_CHECK):
bundle.build_bundle(
root,
Linux(),
- bundle.BundleOptions(release=False, gpu=False),
+ CPU_DEVELOPMENT,
runner=RecordingRunner({bundle.SELF_CHECK: 1}, _leave_behind(root, launcher)),
environment={},
)
@@ -146,12 +211,19 @@ def test_a_system_without_bundles_is_told_to_run_from_source_before_anything_run
runner = RecordingRunner({}, None)
with pytest.raises(SystemExit, match="make setup"):
- bundle.build_bundle(
- _repository(tmp_path),
- MacOS(),
- bundle.BundleOptions(release=False, gpu=False),
- runner=runner,
- environment={},
- )
+ bundle.build_bundle(_repository(tmp_path), MacOS(), CPU_DEVELOPMENT, runner=runner, environment={})
assert runner.lines == []
+
+
+class TestMain:
+ def test_a_gpu_choice_outside_the_extras_is_refused_before_anything_runs(
+ self,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ with pytest.raises(SystemExit) as exit_info:
+ bundle.main(["--gpu", "1"])
+
+ refusal = capsys.readouterr().err
+ assert exit_info.value.code == 2
+ assert all(choice in refusal for choice in GPU_CHOICES)
diff --git a/tests/unit/scripts/test_setup_environment.py b/tests/unit/scripts/test_setup_environment.py
index 7de7fdf3a..1be608b9f 100644
--- a/tests/unit/scripts/test_setup_environment.py
+++ b/tests/unit/scripts/test_setup_environment.py
@@ -2,26 +2,16 @@
import pytest
+from bootstrap.cuda import GPU_CHOICES
from bootstrap.platforms.linux import Linux
from bootstrap.platforms.macos import ARCHFLAGS, MacOS
-from bootstrap.project import DEVELOPMENT_GROUP, GPU_CUDA11_EXTRA, GPU_EXTRA
+from bootstrap.project import DEVELOPMENT_GROUP, GPU_EXTRA
from tests.suite.bootstrap import RecordingRunner
from tests.suite.scripts import load_script
setup_environment = load_script("setup_environment.py")
-class TestGpuExtra:
- def test_zero_keeps_the_cpu_backend(self) -> None:
- assert setup_environment.gpu_extra(setup_environment.GPU_OFF, Linux(), {}) is None
-
- def test_a_named_extra_is_taken_as_given(self) -> None:
- assert setup_environment.gpu_extra(GPU_CUDA11_EXTRA, Linux(), {}) == GPU_CUDA11_EXTRA
-
- def test_auto_on_macos_keeps_the_cpu_backend(self) -> None:
- assert setup_environment.gpu_extra(setup_environment.GPU_AUTO, MacOS(), {}) is None
-
-
class TestMain:
def test_a_gpu_choice_outside_the_extras_is_refused_before_anything_runs(
self,
@@ -32,7 +22,7 @@ def test_a_gpu_choice_outside_the_extras_is_refused_before_anything_runs(
refusal = capsys.readouterr().err
assert exit_info.value.code == 2
- assert all(choice in refusal for choice in setup_environment.GPU_CHOICES)
+ assert all(choice in refusal for choice in GPU_CHOICES)
class TestSetupCommands:
diff --git a/tests/unit/scripts/test_verify_bundle.py b/tests/unit/scripts/test_verify_bundle.py
index 5bc6d90fe..75d29bd1d 100644
--- a/tests/unit/scripts/test_verify_bundle.py
+++ b/tests/unit/scripts/test_verify_bundle.py
@@ -2,7 +2,7 @@
import pytest
-from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES
+from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, GPU_PACKAGES, NOTICES
from bootstrap.platforms.linux import Linux
from bootstrap.platforms.macos import MacOS
from tests.suite.bootstrap import PROJECT_NAME, RecordingRunner, write_project
@@ -14,7 +14,7 @@
@pytest.fixture(name="root")
def root_fixture(tmp_path: Path) -> Path:
"""A repository holding a release bundle with its notices and its launcher."""
- launcher = Linux().bundling().launcher(tmp_path / DISTRIBUTION, name=PROJECT_NAME, release=True)
+ launcher = Linux().bundling().launcher(tmp_path / DISTRIBUTION, name=PROJECT_NAME, directory=True)
launcher.parent.mkdir(parents=True)
launcher.write_bytes(b"launcher")
for name in NOTICES:
@@ -59,6 +59,17 @@ def test_a_build_tool_beside_the_launcher_is_reported(self, root: Path) -> None:
assert verify_bundle.carried_build_tools(_bundle(root)) == [BUILD_TOOLS[0]]
+class TestCarriedGpuPackages:
+ def test_a_cpu_bundle_carries_none(self, root: Path) -> None:
+ assert verify_bundle.carried_gpu_packages(_bundle(root)) == []
+
+ def test_every_gpu_package_among_the_internals_is_reported(self, root: Path) -> None:
+ for name in GPU_PACKAGES:
+ (_bundle(root) / verify_bundle.INTERNAL_DIRECTORY / name).mkdir(parents=True)
+
+ assert verify_bundle.carried_gpu_packages(_bundle(root)) == list(GPU_PACKAGES)
+
+
class TestBundleFailures:
def test_a_complete_bundle_passes_once_its_launcher_starts(self, root: Path) -> None:
runner = RecordingRunner({}, None)
@@ -81,6 +92,13 @@ def test_bundled_build_tooling_is_named(self, root: Path) -> None:
assert [BUILD_TOOLS[0] in failure for failure in failures] == [True]
+ def test_a_release_built_with_gpu_support_is_named(self, root: Path) -> None:
+ (_bundle(root) / verify_bundle.INTERNAL_DIRECTORY / GPU_PACKAGES[0]).mkdir(parents=True)
+
+ failures = verify_bundle.bundle_failures(root, Linux(), runner=RecordingRunner({}, None), environment={})
+
+ assert [GPU_PACKAGES[0] in failure for failure in failures] == [True]
+
def test_a_missing_launcher_is_named_and_nothing_runs(self, root: Path) -> None:
(_bundle(root) / PROJECT_NAME).unlink()
runner = RecordingRunner({}, None)