From bb57a055c52554b72aa5dd88787e430b3a736e1c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 6 Jun 2026 19:34:28 -0400 Subject: [PATCH 1/3] feat: add find_built_wheel for test-only stage lookup Add find_built_wheel, which locates the wheel in a directory that was previously built for a specific identifier. Unlike find_compatible_wheel (cross-compatible abi3/none wheels only), it also matches a wheel built for the identifier's own interpreter, including free-threaded builds, and covers all platforms (incl. pyodide). Factors out a shared _platform_tag_matches helper and adds unit tests. The shared driver's test-only path now uses it. This is groundwork for the --stage=test feature. Assisted-by: ClaudeCode:claude-opus-4.8 --- cibuildwheel/platforms/_run.py | 9 ++++-- cibuildwheel/util/packaging.py | 50 ++++++++++++++++++++++++++++++++++ unit_test/utils_test.py | 45 +++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/cibuildwheel/platforms/_run.py b/cibuildwheel/platforms/_run.py index fb833f46a..64685eb50 100644 --- a/cibuildwheel/platforms/_run.py +++ b/cibuildwheel/platforms/_run.py @@ -25,7 +25,7 @@ from cibuildwheel.audit import run_audit from cibuildwheel.logger import log from cibuildwheel.util.file import move_file -from cibuildwheel.util.packaging import find_compatible_wheel +from cibuildwheel.util.packaging import find_built_wheel, find_compatible_wheel TYPE_CHECKING = False if TYPE_CHECKING: @@ -82,9 +82,12 @@ def find_prebuilt_wheel(output_dir: Path, identifier: str) -> Path: Used by the test-only stage, which consumes wheels produced by an earlier build-only stage rather than building them itself. """ - wheel = find_compatible_wheel(sorted(output_dir.glob("*.whl")), identifier) + wheel = find_built_wheel(sorted(output_dir.glob("*.whl")), identifier) if wheel is None: - msg = f"No pre-built wheel for {identifier!r} found in {output_dir}" + msg = ( + f"No pre-built wheel for {identifier!r} found in {output_dir}. " + "Run the build stage first, or check --output-dir." + ) raise errors.FatalError(msg) return wheel diff --git a/cibuildwheel/util/packaging.py b/cibuildwheel/util/packaging.py index d8bb57c66..d36dec7d8 100644 --- a/cibuildwheel/util/packaging.py +++ b/cibuildwheel/util/packaging.py @@ -185,6 +185,56 @@ def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None: return None +def _platform_tag_matches(tag_platform: str, identifier_platform: str) -> bool: + """Whether a wheel's platform tag corresponds to an identifier's platform. + + e.g. the wheel tag ``macosx_11_0_x86_64`` matches the identifier platform + ``macosx_x86_64``; ``win_amd64`` must match exactly. + """ + if identifier_platform.startswith( + ("manylinux", "musllinux", "macosx", "android", "ios", "pyodide") + ): + # On these platforms the wheel tag includes a platform version number, + # which we should ignore. + os_, arch = identifier_platform.split("_", 1) + return tag_platform.startswith(os_) and tag_platform.endswith(f"_{arch}") + # Windows should exactly match + return tag_platform == identifier_platform + + +def find_built_wheel(wheels: Sequence[T], identifier: str) -> T | None: + """Find the wheel in `wheels` previously built for exactly `identifier`. + + Used by the test-only stage to locate a wheel produced by an earlier + build-only run. Matches a wheel built for the identifier's own interpreter + (e.g. a ``cp311`` wheel for a ``cp311`` identifier, including free-threaded + builds), or a cross-compatible abi3/none wheel (via find_compatible_wheel). + """ + cross_compatible = find_compatible_wheel(wheels, identifier) + if cross_compatible is not None: + return cross_compatible + + interpreter, platform = identifier.split("-", 1) + interpreter = interpreter.split("_")[0] + free_threaded = interpreter.endswith("t") + base_interpreter = interpreter[:-1] if free_threaded else interpreter + + for wheel in wheels: + _, _, _, tags = parse_wheel_filename(wheel.name) + for tag in tags: + if tag.interpreter != base_interpreter: + continue + # the free-threaded ABI tag ends in "t" (e.g. cp313t); make sure a + # free-threaded identifier only matches a free-threaded wheel and + # vice versa. + if free_threaded != tag.abi.endswith("t"): + continue + if _platform_tag_matches(tag.platform, platform): + return wheel + + return None + + def is_abi3_wheel(wheel_name: str) -> bool: """Check if a wheel uses the abi3 stable ABI based on its filename.""" _, _, _, tags = parse_wheel_filename(wheel_name) diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 2322a0579..5cab17e45 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -16,7 +16,7 @@ unwrap, unwrap_preserving_paragraphs, ) -from cibuildwheel.util.packaging import find_compatible_wheel, is_abi3_wheel +from cibuildwheel.util.packaging import find_built_wheel, find_compatible_wheel, is_abi3_wheel def test_format_safe() -> None: @@ -104,6 +104,49 @@ def test_find_compatible_wheel_not_found(wheel: str, identifier: str) -> None: assert find_compatible_wheel([PurePath(wheel)], identifier) is None +@pytest.mark.parametrize( + ("wheel", "identifier"), + [ + # exact interpreter matches + ("foo-0.1-cp310-cp310-win_amd64.whl", "cp310-win_amd64"), + ("foo-0.1-cp310-cp310-win32.whl", "cp310-win32"), + ("foo-0.1-cp310-cp310-macosx_11_0_x86_64.whl", "cp310-macosx_x86_64"), + ("foo-0.1-cp310-cp310-macosx_11_0_universal2.whl", "cp310-macosx_universal2"), + ("foo-0.1-cp310-cp310-manylinux2014_x86_64.whl", "cp310-manylinux_x86_64"), + ("foo-0.1-cp310-cp310-musllinux_1_1_x86_64.whl", "cp310-musllinux_x86_64"), + ("foo-0.1-cp313-cp313t-manylinux2014_x86_64.whl", "cp313t-manylinux_x86_64"), + ("foo-0.1-pp310-pypy310_pp73-win_amd64.whl", "pp310-win_amd64"), + ("foo-0.1-cp313-cp313-android_24_x86_64.whl", "cp313-android_x86_64"), + ("foo-0.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", "cp313-ios_arm64_iphoneos"), + ("foo-0.1-cp312-cp312-pyodide_2024_0_wasm32.whl", "cp312-pyodide_wasm32"), + # cross-compatible (abi3/none) still match + ("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-manylinux_x86_64"), + ("foo-0.1-py3-none-win_amd64.whl", "cp310-win_amd64"), + ], +) +def test_find_built_wheel_found(wheel: str, identifier: str) -> None: + wheel_ = PurePath(wheel) + assert find_built_wheel([wheel_], identifier) is wheel_ + + +@pytest.mark.parametrize( + ("wheel", "identifier"), + [ + ("foo-0.1-cp310-cp310-win_amd64.whl", "cp311-win_amd64"), + ("foo-0.1-cp310-cp310-win_amd64.whl", "cp310-win32"), + ("foo-0.1-cp310-cp310-macosx_11_0_x86_64.whl", "cp310-macosx_universal2"), + ("foo-0.1-cp310-cp310-manylinux2014_x86_64.whl", "cp310-musllinux_x86_64"), + # free-threaded mismatch in both directions + ("foo-0.1-cp313-cp313-manylinux2014_x86_64.whl", "cp313t-manylinux_x86_64"), + ("foo-0.1-cp313-cp313t-manylinux2014_x86_64.whl", "cp313-manylinux_x86_64"), + # different implementation + ("foo-0.1-pp310-pypy310_pp73-win_amd64.whl", "cp310-win_amd64"), + ], +) +def test_find_built_wheel_not_found(wheel: str, identifier: str) -> None: + assert find_built_wheel([PurePath(wheel)], identifier) is None + + def test_fix_ansi_codes_for_github_actions() -> None: input = textwrap.dedent( """ From 8e49f6590cc840d2063e8f7e7d94a166a73f207c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 6 Jun 2026 19:41:07 -0400 Subject: [PATCH 2/3] feat: add --stage=build/test to run a single stage Add a --stage option (also CIBW_STAGE) with choices all/build/test: 'build' produces wheels into the output dir and skips tests; 'test' skips building and runs tests against wheels already in the output dir; 'all' (default) does both, identical to previous behavior. The selected stages (a frozenset[Stage]) are threaded from __main__ through PlatformModule.build into each platform. Host platforms already gate on stages via run_host_build; Linux's build_in_container now gates before_all/build/repair/audit/output on BUILD and the test block on TEST, and in test-only mode copies the matching pre-built wheel from the output dir into the container before testing. Note: the Linux test-only container path can't be exercised by the unit suite and needs integration testing. Assisted-by: ClaudeCode:claude-opus-4.8 --- cibuildwheel/__main__.py | 21 +++++++++++++- cibuildwheel/options.py | 2 ++ cibuildwheel/platforms/__init__.py | 6 +++- cibuildwheel/platforms/android.py | 7 +++-- cibuildwheel/platforms/ios.py | 7 +++-- cibuildwheel/platforms/linux.py | 46 +++++++++++++++++++++++------- cibuildwheel/platforms/macos.py | 7 +++-- cibuildwheel/platforms/pyodide.py | 7 +++-- cibuildwheel/platforms/windows.py | 7 +++-- 9 files changed, 82 insertions(+), 28 deletions(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index c25d8bc56..d34e89740 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -21,6 +21,7 @@ from cibuildwheel.logger import log from cibuildwheel.options import CommandLineArguments, Options, compute_options from cibuildwheel.platforms import ALL_PLATFORM_MODULES, get_build_identifiers, native_platform +from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector, EnableGroup, selector_matches from cibuildwheel.typing import PLATFORMS, PlatformName from cibuildwheel.util.file import CIBW_CACHE_PATH, ensure_cache_sentinel @@ -208,6 +209,18 @@ def main_inner(global_options: GlobalOptions) -> None: help="Print a full traceback for all errors", ) + parser.add_argument( + "--stage", + choices=["all", "build", "test"], + default=os.environ.get("CIBW_STAGE", "all"), + help=""" + Which stage(s) to run. 'build' builds wheels into the output + directory and skips tests; 'test' skips building and runs tests + against wheels already in the output directory; 'all' (the default) + does both. + """, + ) + args = CommandLineArguments(**vars(parser.parse_args())) global_options.print_traceback_on_error = args.debug_traceback @@ -388,10 +401,16 @@ def build_in_directory(args: CommandLineArguments) -> None: output_dir.mkdir(parents=True, exist_ok=True) + stages = { + "all": frozenset({Stage.BUILD, Stage.TEST}), + "build": frozenset({Stage.BUILD}), + "test": frozenset({Stage.TEST}), + }[args.stage] + tmp_path = Path(mkdtemp(prefix="cibw-run-")).resolve(strict=True) try: with log.print_summary(options=options): - platform_module.build(options, tmp_path) + platform_module.build(options, tmp_path, stages) finally: # avoid https://github.com/python/cpython/issues/86962 by performing # cleanup manually diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index ed39bf35e..61b72fca4 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -71,6 +71,7 @@ class CommandLineArguments: debug_traceback: bool enable: list[str] clean_cache: bool + stage: str @classmethod def defaults(cls) -> Self: @@ -86,6 +87,7 @@ def defaults(cls) -> Self: debug_traceback=False, enable=[], clean_cache=False, + stage="all", ) diff --git a/cibuildwheel/platforms/__init__.py b/cibuildwheel/platforms/__init__.py index cfe22cbba..5f6c59edc 100644 --- a/cibuildwheel/platforms/__init__.py +++ b/cibuildwheel/platforms/__init__.py @@ -5,6 +5,7 @@ from cibuildwheel import errors from cibuildwheel.platforms import android, ios, linux, macos, pyodide, windows +from cibuildwheel.platforms._run import ALL_STAGES TYPE_CHECKING = False if TYPE_CHECKING: @@ -14,6 +15,7 @@ from cibuildwheel.architecture import Architecture from cibuildwheel.options import Options + from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector from cibuildwheel.typing import GenericPythonConfiguration, PlatformName @@ -27,7 +29,9 @@ def get_python_configurations( self, build_selector: BuildSelector, architectures: set[Architecture] ) -> Sequence[GenericPythonConfiguration]: ... - def build(self, options: Options, tmp_path: Path) -> None: ... + def build( + self, options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES + ) -> None: ... ALL_PLATFORM_MODULES: Final[dict[PlatformName, PlatformModule]] = { diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py index f4b264ea1..558f63664 100644 --- a/cibuildwheel/platforms/android.py +++ b/cibuildwheel/platforms/android.py @@ -26,7 +26,7 @@ prepare_config_settings, ) from cibuildwheel.logger import log -from cibuildwheel.platforms._run import run_host_build +from cibuildwheel.platforms._run import ALL_STAGES, run_host_build from cibuildwheel.util import resources from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import ( @@ -44,6 +44,7 @@ from collections.abc import Sequence from cibuildwheel.options import BuildOptions, Options + from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector from cibuildwheel.typing import PathOrStr @@ -121,7 +122,7 @@ class BuildState: android_env: dict[str, str] -def build(options: Options, tmp_path: Path) -> None: +def build(options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES) -> None: if "ANDROID_HOME" not in os.environ: msg = ( "ANDROID_HOME environment variable is not set. For instructions, see " @@ -129,7 +130,7 @@ def build(options: Options, tmp_path: Path) -> None: ) raise errors.FatalError(msg) - run_host_build(platforms.android, options, tmp_path) + run_host_build(platforms.android, options, tmp_path, stages=stages) def setup(config: PythonConfiguration, options: Options, tmp_path: Path) -> BuildState: diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py index 0cebb44a4..1730ba174 100644 --- a/cibuildwheel/platforms/ios.py +++ b/cibuildwheel/platforms/ios.py @@ -21,7 +21,7 @@ prepare_config_settings, ) from cibuildwheel.logger import log -from cibuildwheel.platforms._run import run_host_build +from cibuildwheel.platforms._run import ALL_STAGES, run_host_build from cibuildwheel.platforms.macos import install_cpython as install_build_cpython from cibuildwheel.util import resources from cibuildwheel.util.cmd import call, shell, split_command @@ -41,6 +41,7 @@ from cibuildwheel.architecture import Architecture from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.options import BuildOptions, Options + from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector @@ -445,12 +446,12 @@ class BuildState: env: dict[str, str] -def build(options: Options, tmp_path: Path) -> None: +def build(options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES) -> None: if sys.platform != "darwin": msg = "iOS binaries can only be built on macOS" raise errors.FatalError(msg) - run_host_build(platforms.ios, options, tmp_path) + run_host_build(platforms.ios, options, tmp_path, stages=stages) def before_all(options: Options, python_configurations: Sequence[PythonConfiguration]) -> None: diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py index 310355d3a..ab52cb2cc 100644 --- a/cibuildwheel/platforms/linux.py +++ b/cibuildwheel/platforms/linux.py @@ -16,10 +16,11 @@ from cibuildwheel.frontend import get_build_frontend_extra_flags, prepare_config_settings from cibuildwheel.logger import log from cibuildwheel.oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform +from cibuildwheel.platforms._run import ALL_STAGES, Stage from cibuildwheel.util import resources from cibuildwheel.util.file import copy_test_sources from cibuildwheel.util.helpers import prepare_command, unwrap -from cibuildwheel.util.packaging import find_compatible_wheel +from cibuildwheel.util.packaging import find_built_wheel, find_compatible_wheel TYPE_CHECKING = False if TYPE_CHECKING: @@ -177,6 +178,7 @@ def build_in_container( container_project_path: PurePath, container_package_dir: PurePath, local_tmp_dir: Path, + stages: frozenset[Stage] = ALL_STAGES, ) -> None: container_output_dir = PurePosixPath("/output") @@ -188,7 +190,7 @@ def build_in_container( before_all_options_identifier = platform_configs[0].identifier before_all_options = options.build_options(before_all_options_identifier) - if before_all_options.before_all: + if Stage.BUILD in stages and before_all_options.before_all: log.step("Running before_all...") env = container.get_environment() @@ -257,8 +259,24 @@ def build_in_container( msg = "pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it." raise errors.FatalError(msg) - compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) - if compatible_wheel: + compatible_wheel = None + if Stage.BUILD not in stages: + # test-only: bring the already-built wheel into the container + host_wheel = find_built_wheel( + sorted(options.globals.output_dir.glob("*.whl")), config.identifier + ) + if host_wheel is None: + msg = ( + f"No pre-built wheel for {config.identifier!r} found in " + f"{options.globals.output_dir}. Run the build stage first." + ) + raise errors.FatalError(msg) + prebuilt_dir = PurePosixPath("/tmp/cibuildwheel/prebuilt_wheel") + container.call(["rm", "-rf", prebuilt_dir]) + container.call(["mkdir", "-p", prebuilt_dir]) + container.copy_into(host_wheel, prebuilt_dir / host_wheel.name) + repaired_wheel = prebuilt_dir / host_wheel.name + elif compatible_wheel := find_compatible_wheel(built_wheels, config.identifier): log.step_end() print( f"\nFound previously built wheel {compatible_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." @@ -391,7 +409,11 @@ def build_in_container( finally: shutil.rmtree(local_abi3audit_dir, ignore_errors=True) - if build_options.test_command and build_options.test_selector(config.identifier): + if ( + Stage.TEST in stages + and build_options.test_command + and build_options.test_selector(config.identifier) + ): log.step("Testing wheel...") # set up a virtual environment to install and test from, to make sure @@ -470,7 +492,7 @@ def build_in_container( # move repaired wheel to output output_wheel: Path | None = None - if compatible_wheel is None: + if Stage.BUILD in stages and compatible_wheel is None: container.call(["mkdir", "-p", container_output_dir]) container.call(["mv", repaired_wheel, container_output_dir]) built_wheels.append(container_output_dir / repaired_wheel.name) @@ -478,13 +500,14 @@ def build_in_container( log.build_end(output_wheel) - log.step("Copying wheels back to host...") - # copy the output back into the host - container.copy_out(container_output_dir, options.globals.output_dir) - log.step_end() + if Stage.BUILD in stages: + log.step("Copying wheels back to host...") + # copy the output back into the host + container.copy_out(container_output_dir, options.globals.output_dir) + log.step_end() -def build(options: Options, tmp_path: Path) -> None: +def build(options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES) -> None: python_configurations = get_python_configurations( options.globals.build_selector, options.globals.architectures ) @@ -538,6 +561,7 @@ def build(options: Options, tmp_path: Path) -> None: container_project_path=container_project_path, container_package_dir=container_package_dir, local_tmp_dir=tmp_path, + stages=stages, ) except subprocess.CalledProcessError as error: diff --git a/cibuildwheel/platforms/macos.py b/cibuildwheel/platforms/macos.py index 9fa881936..211dca50a 100644 --- a/cibuildwheel/platforms/macos.py +++ b/cibuildwheel/platforms/macos.py @@ -23,7 +23,7 @@ prepare_config_settings, ) from cibuildwheel.logger import log -from cibuildwheel.platforms._run import run_host_build +from cibuildwheel.platforms._run import ALL_STAGES, run_host_build from cibuildwheel.util import resources from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import ( @@ -44,6 +44,7 @@ from cibuildwheel.architecture import Architecture from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.options import BuildOptions, Options + from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector @@ -441,8 +442,8 @@ class BuildState: pip_version: str | None -def build(options: Options, tmp_path: Path) -> None: - run_host_build(platforms.macos, options, tmp_path) +def build(options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES) -> None: + run_host_build(platforms.macos, options, tmp_path, stages=stages) def before_all(options: Options, python_configurations: Sequence[PythonConfiguration]) -> None: diff --git a/cibuildwheel/platforms/pyodide.py b/cibuildwheel/platforms/pyodide.py index fde715710..c8ad37c40 100644 --- a/cibuildwheel/platforms/pyodide.py +++ b/cibuildwheel/platforms/pyodide.py @@ -18,7 +18,7 @@ from cibuildwheel.architecture import Architecture from cibuildwheel.frontend import get_build_frontend_extra_flags, prepare_config_settings from cibuildwheel.logger import log -from cibuildwheel.platforms._run import run_host_build +from cibuildwheel.platforms._run import ALL_STAGES, run_host_build from cibuildwheel.util import resources from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import ( @@ -43,6 +43,7 @@ from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.options import BuildOptions, Options + from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector IS_WIN: Final[bool] = sys.platform.startswith("win") @@ -352,8 +353,8 @@ class BuildState: pip_version: str -def build(options: Options, tmp_path: Path) -> None: - run_host_build(platforms.pyodide, options, tmp_path) +def build(options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES) -> None: + run_host_build(platforms.pyodide, options, tmp_path, stages=stages) def before_all(options: Options, python_configurations: Sequence[PythonConfiguration]) -> None: diff --git a/cibuildwheel/platforms/windows.py b/cibuildwheel/platforms/windows.py index c3a03f131..32e0aab3a 100644 --- a/cibuildwheel/platforms/windows.py +++ b/cibuildwheel/platforms/windows.py @@ -20,7 +20,7 @@ prepare_config_settings, ) from cibuildwheel.logger import log -from cibuildwheel.platforms._run import run_host_build +from cibuildwheel.platforms._run import ALL_STAGES, run_host_build from cibuildwheel.util import resources from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import ( @@ -40,6 +40,7 @@ from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.options import BuildOptions, Options + from cibuildwheel.platforms._run import Stage from cibuildwheel.selector import BuildSelector @@ -437,8 +438,8 @@ class BuildState: pip_version: str | None -def build(options: Options, tmp_path: Path) -> None: - run_host_build(platforms.windows, options, tmp_path) +def build(options: Options, tmp_path: Path, stages: frozenset[Stage] = ALL_STAGES) -> None: + run_host_build(platforms.windows, options, tmp_path, stages=stages) def before_all(options: Options, python_configurations: Sequence[PythonConfiguration]) -> None: From 805bff31892a13872293122df96c0ba4bd5791e4 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 6 Jun 2026 19:46:36 -0400 Subject: [PATCH 3/3] test: cover host build driver stage gating Add unit tests for run_host_build using a fake backend, verifying that --stage=all runs build+test and moves the wheel, --stage=build skips tests, and --stage=test only runs tests against a pre-built wheel from the output dir (erroring clearly when none is present). Assisted-by: ClaudeCode:claude-opus-4.8 --- unit_test/run_host_build_test.py | 153 +++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 unit_test/run_host_build_test.py diff --git a/unit_test/run_host_build_test.py b/unit_test/run_host_build_test.py new file mode 100644 index 000000000..6eb4c5bb2 --- /dev/null +++ b/unit_test/run_host_build_test.py @@ -0,0 +1,153 @@ +"""Tests for the shared host build driver's stage gating.""" + +# The fake backend below deliberately ignores most of its arguments. +# ruff: noqa: ARG002 + +from __future__ import annotations + +import dataclasses +from typing import cast + +import pytest + +from cibuildwheel import errors +from cibuildwheel.platforms._run import Stage, run_host_build + +TYPE_CHECKING = False +if TYPE_CHECKING: + from pathlib import Path + + from cibuildwheel.options import Options + + +@dataclasses.dataclass +class FakeBuildOptions: + output_dir: Path + audit_command: list[str] = dataclasses.field(default_factory=list) + + +@dataclasses.dataclass +class FakeGlobals: + build_selector: object = None + architectures: set[object] = dataclasses.field(default_factory=set) + + +class FakeOptions: + def __init__(self, output_dir: Path) -> None: + self.globals = FakeGlobals() + self._build_options = FakeBuildOptions(output_dir=output_dir) + + def build_options(self, identifier: str | None) -> FakeBuildOptions: + return self._build_options + + +@dataclasses.dataclass +class FakeConfig: + identifier: str + + +class FakeBackend: + """A HostBackend recording which phases the driver invokes.""" + + def __init__(self, configs: list[FakeConfig], built_wheel: Path) -> None: + self.configs = configs + self.built_wheel = built_wheel + self.calls: list[object] = [] + + def get_python_configurations( + self, build_selector: object, architectures: object + ) -> list[FakeConfig]: + return self.configs + + def before_all(self, options: object, configs: object) -> None: + self.calls.append("before_all") + + def setup(self, config: FakeConfig, options: object, tmp_path: Path) -> FakeConfig: + self.calls.append("setup") + return config + + def before_build(self, state: object) -> None: + self.calls.append("before_build") + + def build_wheel(self, state: object) -> Path: + self.calls.append("build_wheel") + self.built_wheel.write_bytes(b"") + return self.built_wheel + + def repair_wheel(self, state: object, built_wheel: Path) -> Path: + self.calls.append("repair_wheel") + return built_wheel + + def test_wheel(self, state: object, wheel: Path) -> None: + self.calls.append(("test_wheel", wheel.name)) + + def teardown(self, state: object) -> None: + self.calls.append("teardown") + + +WHEEL_NAME = "foo-1.0-cp311-cp311-manylinux2014_x86_64.whl" +IDENTIFIER = "cp311-manylinux_x86_64" + + +def _options(output_dir: Path) -> Options: + return cast("Options", FakeOptions(output_dir)) + + +def test_run_host_build_all_stages(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + output_dir.mkdir() + backend = FakeBackend([FakeConfig(IDENTIFIER)], tmp_path / WHEEL_NAME) + + run_host_build(backend, _options(output_dir), tmp_path) + + assert backend.calls == [ + "before_all", + "setup", + "before_build", + "build_wheel", + "repair_wheel", + ("test_wheel", WHEEL_NAME), + "teardown", + ] + # the wheel was moved into the output dir after a successful test + assert (output_dir / WHEEL_NAME).exists() + + +def test_run_host_build_build_only(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + output_dir.mkdir() + backend = FakeBackend([FakeConfig(IDENTIFIER)], tmp_path / WHEEL_NAME) + + run_host_build(backend, _options(output_dir), tmp_path, stages=frozenset({Stage.BUILD})) + + # the wheel is built and moved, but tests are skipped + assert "build_wheel" in backend.calls + assert not any(isinstance(c, tuple) for c in backend.calls) + assert (output_dir / WHEEL_NAME).exists() + + +def test_run_host_build_test_only(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + output_dir.mkdir() + # a wheel built by an earlier run + (output_dir / WHEEL_NAME).write_bytes(b"") + backend = FakeBackend([FakeConfig(IDENTIFIER)], tmp_path / WHEEL_NAME) + + run_host_build(backend, _options(output_dir), tmp_path, stages=frozenset({Stage.TEST})) + + assert backend.calls == [ + "setup", + ("test_wheel", WHEEL_NAME), + "teardown", + ] + # nothing built, so the pre-existing wheel is left in place + assert (output_dir / WHEEL_NAME).exists() + + +def test_run_host_build_test_only_missing_wheel(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + output_dir.mkdir() + backend = FakeBackend([FakeConfig(IDENTIFIER)], tmp_path / WHEEL_NAME) + + with pytest.raises(errors.FatalError, match="No pre-built wheel"): + run_host_build(backend, _options(output_dir), tmp_path, stages=frozenset({Stage.TEST}))