From 1a281bbb9c019e1db90f6a624f0b720e8b4051e3 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 31 Mar 2026 17:37:47 -0400 Subject: [PATCH 1/5] WIP: workspace Signed-off-by: Henry Schreiner --- test/test_projects/c.py | 2 + test/test_projects/uv_scikit_build.py | 81 +++++++++++++++++++++++++++ test/test_uv_workspace.py | 35 ++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 test/test_projects/uv_scikit_build.py create mode 100644 test/test_uv_workspace.py diff --git a/test/test_projects/c.py b/test/test_projects/c.py index 6771ccf78..8e66ec24a 100644 --- a/test/test_projects/c.py +++ b/test/test_projects/c.py @@ -1,5 +1,7 @@ SPAM_C_TEMPLATE = r""" +#define PY_SSIZE_T_CLEAN #include +#include {{ spam_c_top_level_add }} diff --git a/test/test_projects/uv_scikit_build.py b/test/test_projects/uv_scikit_build.py new file mode 100644 index 000000000..bfb609762 --- /dev/null +++ b/test/test_projects/uv_scikit_build.py @@ -0,0 +1,81 @@ +import jinja2 + +from .base import TestProject +from .c import SPAM_C_TEMPLATE + + +PKG_A_PYPROJECT_TEMPLATE = r""" +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[project] +name = "pkg_a" +version = "0.1.0" +""" + +PKG_A_CMAKELISTS = r""" +cmake_minimum_required(VERSION 3.15...4.0) +project(pkg_a C) + +add_library(spam MODULE spam.c) +set_target_properties(spam PROPERTIES PREFIX "" OUTPUT_NAME "spam") +if(WIN32) + set_target_properties(spam PROPERTIES SUFFIX ".pyd") +endif() + +install(TARGETS spam + RUNTIME DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_NAME} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_NAME}) +""" + +PKG_B_PYPROJECT_TEMPLATE = r""" +[build-system] +requires = ["uv_backend"] +build-backend = "uv_backend.build" + +[project] +name = "eggs" +version = "0.1.0" +dependencies = ["pkg_a @ file://../pkg_a"] +""" + +TOPLEVEL_PYPROJECT = r""" +[build-system] +requires = ["uv_backend"] +build-backend = "uv_backend.build" + +[tool.uv.workspace] +members = ["pkg_a", "pkg_b"] +""" + + +def new_uv_workspace_project() -> TestProject: + """Create a TestProject representing a uv workspace with two members. + + - pkg_a: a minimal package built with `scikit-build-core` (a "core" library). + - pkg_b: a package using `uv_backend` as its build backend and depending on pkg_a. + The top-level project also declares `uv_backend` in its build-system. + """ + project = TestProject() + + # pkg_a: scikit-build-core project (simple Python package) + project.files["pkg_a/pyproject.toml"] = jinja2.Template(PKG_A_PYPROJECT_TEMPLATE) + project.files["pkg_a/pkg_a/__init__.py"] = "__version__ = '0.1.0'\n" + + # Add a tiny C extension built via scikit-build-core (CMake) + project.files["pkg_a/CMakeLists.txt"] = jinja2.Template(PKG_A_CMAKELISTS) + project.files["pkg_a/spam.c"] = jinja2.Template(SPAM_C_TEMPLATE).render( + spam_c_top_level_add="", + spam_c_function_add="" + ) + + # pkg_b: uses uv_backend and depends on pkg_a (path dependency) + project.files["pkg_b/pyproject.toml"] = jinja2.Template(PKG_B_PYPROJECT_TEMPLATE) + project.files["pkg_b/eggs/__init__.py"] = "__version__ = '0.1.0'\n" + + # top-level workspace pyproject declares uv workspace and uv_backend + project.files["pyproject.toml"] = jinja2.Template(TOPLEVEL_PYPROJECT) + + return project diff --git a/test/test_uv_workspace.py b/test/test_uv_workspace.py new file mode 100644 index 000000000..4ddef10fa --- /dev/null +++ b/test/test_uv_workspace.py @@ -0,0 +1,35 @@ +import shutil + +import packaging.utils + +import pytest + +from . import test_projects, utils + + +def test_uv_workspace_with_scikit_build_core(tmp_path, build_frontend_env): + """Create a uv workspace with two subpackages; one uses scikit-build-core.""" + if shutil.which("uv") is None: + pytest.skip("uv not available") + if shutil.which("cmake") is None: + pytest.skip("cmake not available (required for scikit-build-core)") + + project_dir = tmp_path / "project" + + # build a uv workspace project with the helper: pkg_a uses scikit-build-core + project = test_projects.new_uv_workspace_project() + project.generate(project_dir) + + # build the wheels from the workspace + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env=build_frontend_env, + single_python=True, + ) + + # expected wheels: one from pkg_a (spam) and one from pkg_b (eggs) + expected_wheels = utils.expected_wheels( + "spam", "0.1.0", single_python=True + ) + utils.expected_wheels("eggs", "0.1.0", single_python=True) + + assert set(actual_wheels) == set(expected_wheels) From 1666e5e4bc97948e275af88d506e50df3b4179f7 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 16 Apr 2026 23:50:27 -0400 Subject: [PATCH 2/5] fix: some more touchup Signed-off-by: Henry Schreiner --- test/test_projects/__init__.py | 3 +- test/test_projects/uv_scikit_build.py | 54 +++++++++++---------------- test/test_uv_workspace.py | 42 +++++++++++++++------ 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/test/test_projects/__init__.py b/test/test_projects/__init__.py index b0107b72c..8738b82b1 100644 --- a/test/test_projects/__init__.py +++ b/test/test_projects/__init__.py @@ -1,5 +1,6 @@ from .base import TestProject from .meson import new_meson_project from .setuptools import new_c_project +from .uv_scikit_build import new_uv_workspace_project -__all__ = ("TestProject", "new_c_project", "new_meson_project") +__all__ = ("TestProject", "new_c_project", "new_meson_project", "new_uv_workspace_project") diff --git a/test/test_projects/uv_scikit_build.py b/test/test_projects/uv_scikit_build.py index bfb609762..fc3df8b4b 100644 --- a/test/test_projects/uv_scikit_build.py +++ b/test/test_projects/uv_scikit_build.py @@ -3,79 +3,67 @@ from .base import TestProject from .c import SPAM_C_TEMPLATE - PKG_A_PYPROJECT_TEMPLATE = r""" [build-system] requires = ["scikit-build-core"] build-backend = "scikit_build_core.build" [project] -name = "pkg_a" +name = "spam" version = "0.1.0" """ PKG_A_CMAKELISTS = r""" cmake_minimum_required(VERSION 3.15...4.0) -project(pkg_a C) - -add_library(spam MODULE spam.c) -set_target_properties(spam PROPERTIES PREFIX "" OUTPUT_NAME "spam") -if(WIN32) - set_target_properties(spam PROPERTIES SUFFIX ".pyd") -endif() - -install(TARGETS spam - RUNTIME DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_NAME} - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_NAME}) +project(spam C) + +find_package(Python REQUIRED COMPONENTS Development.Module) + +Python_add_library(spam MODULE WITH_SOABI spam.c) + +install(TARGETS spam DESTINATION .) """ PKG_B_PYPROJECT_TEMPLATE = r""" [build-system] -requires = ["uv_backend"] -build-backend = "uv_backend.build" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "eggs" version = "0.1.0" -dependencies = ["pkg_a @ file://../pkg_a"] """ TOPLEVEL_PYPROJECT = r""" -[build-system] -requires = ["uv_backend"] -build-backend = "uv_backend.build" - [tool.uv.workspace] members = ["pkg_a", "pkg_b"] + +[tool.cibuildwheel] +build-frontend = { name = "pip", args = ["--all-packages"] } """ def new_uv_workspace_project() -> TestProject: """Create a TestProject representing a uv workspace with two members. - - pkg_a: a minimal package built with `scikit-build-core` (a "core" library). - - pkg_b: a package using `uv_backend` as its build backend and depending on pkg_a. - The top-level project also declares `uv_backend` in its build-system. + - pkg_a: a minimal package built with `scikit-build-core` (produces "spam" wheel). + - pkg_b: a package using `hatchling` as its build backend (produces "eggs" wheel). + The top-level project declares the uv workspace. """ project = TestProject() - # pkg_a: scikit-build-core project (simple Python package) + # pkg_a: scikit-build-core project with a C extension project.files["pkg_a/pyproject.toml"] = jinja2.Template(PKG_A_PYPROJECT_TEMPLATE) - project.files["pkg_a/pkg_a/__init__.py"] = "__version__ = '0.1.0'\n" - - # Add a tiny C extension built via scikit-build-core (CMake) - project.files["pkg_a/CMakeLists.txt"] = jinja2.Template(PKG_A_CMAKELISTS) project.files["pkg_a/spam.c"] = jinja2.Template(SPAM_C_TEMPLATE).render( - spam_c_top_level_add="", - spam_c_function_add="" + spam_c_top_level_add="", spam_c_function_add="" ) + project.files["pkg_a/CMakeLists.txt"] = jinja2.Template(PKG_A_CMAKELISTS) - # pkg_b: uses uv_backend and depends on pkg_a (path dependency) + # pkg_b: uses hatchling project.files["pkg_b/pyproject.toml"] = jinja2.Template(PKG_B_PYPROJECT_TEMPLATE) project.files["pkg_b/eggs/__init__.py"] = "__version__ = '0.1.0'\n" - # top-level workspace pyproject declares uv workspace and uv_backend + # top-level workspace pyproject declares uv workspace project.files["pyproject.toml"] = jinja2.Template(TOPLEVEL_PYPROJECT) return project diff --git a/test/test_uv_workspace.py b/test/test_uv_workspace.py index 4ddef10fa..8e2bc8ee6 100644 --- a/test/test_uv_workspace.py +++ b/test/test_uv_workspace.py @@ -1,16 +1,15 @@ import shutil - -import packaging.utils +from pathlib import Path import pytest from . import test_projects, utils -def test_uv_workspace_with_scikit_build_core(tmp_path, build_frontend_env): - """Create a uv workspace with two subpackages; one uses scikit-build-core.""" - if shutil.which("uv") is None: - pytest.skip("uv not available") +def test_uv_workspace_pkg_a_scikit_build( + tmp_path: Path, build_frontend_env: dict[str, str] +) -> None: + """Test building pkg_a from a uv workspace - uses scikit-build-core.""" if shutil.which("cmake") is None: pytest.skip("cmake not available (required for scikit-build-core)") @@ -20,16 +19,37 @@ def test_uv_workspace_with_scikit_build_core(tmp_path, build_frontend_env): project = test_projects.new_uv_workspace_project() project.generate(project_dir) - # build the wheels from the workspace + # build pkg_a (spam) - it uses scikit-build-core with a C extension + actual_wheels = utils.cibuildwheel_run( + project_dir, + package_dir="pkg_a", + add_env=build_frontend_env, + single_python=True, + ) + + # expected wheels: one from pkg_a (spam) + expected_wheels = utils.expected_wheels("spam", "0.1.0", single_python=True) + + assert set(actual_wheels) == set(expected_wheels) + + +def test_uv_workspace_pkg_b_hatchling(tmp_path: Path, build_frontend_env: dict[str, str]) -> None: + """Test building pkg_b from a uv workspace - uses hatchling.""" + project_dir = tmp_path / "project" + + # build a uv workspace project with the helper + project = test_projects.new_uv_workspace_project() + project.generate(project_dir) + + # build pkg_b (eggs) - it uses hatchling (pure Python) actual_wheels = utils.cibuildwheel_run( project_dir, + package_dir="pkg_b", add_env=build_frontend_env, single_python=True, ) - # expected wheels: one from pkg_a (spam) and one from pkg_b (eggs) - expected_wheels = utils.expected_wheels( - "spam", "0.1.0", single_python=True - ) + utils.expected_wheels("eggs", "0.1.0", single_python=True) + # expected wheels: one from pkg_b (eggs) + expected_wheels = utils.expected_wheels("eggs", "0.1.0", single_python=True) assert set(actual_wheels) == set(expected_wheels) From 2425409b9ecec701127b0b5926be72cb98295acf Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 21 Apr 2026 01:10:40 -0400 Subject: [PATCH 3/5] tests: use meson for the other wheel Assisted-by: OpenCode:Kimi-K2.6 Signed-off-by: Henry Schreiner --- test/test_projects/uv_scikit_build.py | 31 ++++++++++++++++++++++----- test/test_uv_workspace.py | 26 +++++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/test/test_projects/uv_scikit_build.py b/test/test_projects/uv_scikit_build.py index fc3df8b4b..0e9c24e61 100644 --- a/test/test_projects/uv_scikit_build.py +++ b/test/test_projects/uv_scikit_build.py @@ -26,12 +26,30 @@ PKG_B_PYPROJECT_TEMPLATE = r""" [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["meson-python"] +build-backend = "mesonpy" [project] name = "eggs" version = "0.1.0" + +[tool.cibuildwheel.windows] +config-settings = { "setup-args" = "--vsenv" } +archs = ["auto64"] +""" + +PKG_B_MESON_BUILD = r""" +project('eggs', 'c', + version: '0.1.0', + default_options: ['warning_level=2'], +) + +py = import('python').find_installation(pure: false) + +py.extension_module('spam', + 'spam.c', + install: true, +) """ TOPLEVEL_PYPROJECT = r""" @@ -47,7 +65,7 @@ def new_uv_workspace_project() -> TestProject: """Create a TestProject representing a uv workspace with two members. - pkg_a: a minimal package built with `scikit-build-core` (produces "spam" wheel). - - pkg_b: a package using `hatchling` as its build backend (produces "eggs" wheel). + - pkg_b: a package using `meson-python` as its build backend (produces "eggs" wheel). The top-level project declares the uv workspace. """ project = TestProject() @@ -59,9 +77,12 @@ def new_uv_workspace_project() -> TestProject: ) project.files["pkg_a/CMakeLists.txt"] = jinja2.Template(PKG_A_CMAKELISTS) - # pkg_b: uses hatchling + # pkg_b: uses meson-python project.files["pkg_b/pyproject.toml"] = jinja2.Template(PKG_B_PYPROJECT_TEMPLATE) - project.files["pkg_b/eggs/__init__.py"] = "__version__ = '0.1.0'\n" + project.files["pkg_b/spam.c"] = jinja2.Template(SPAM_C_TEMPLATE).render( + spam_c_top_level_add="", spam_c_function_add="" + ) + project.files["pkg_b/meson.build"] = jinja2.Template(PKG_B_MESON_BUILD) # top-level workspace pyproject declares uv workspace project.files["pyproject.toml"] = jinja2.Template(TOPLEVEL_PYPROJECT) diff --git a/test/test_uv_workspace.py b/test/test_uv_workspace.py index 8e2bc8ee6..b80ba831a 100644 --- a/test/test_uv_workspace.py +++ b/test/test_uv_workspace.py @@ -1,6 +1,7 @@ import shutil from pathlib import Path +import packaging.utils import pytest from . import test_projects, utils @@ -33,15 +34,20 @@ def test_uv_workspace_pkg_a_scikit_build( assert set(actual_wheels) == set(expected_wheels) -def test_uv_workspace_pkg_b_hatchling(tmp_path: Path, build_frontend_env: dict[str, str]) -> None: - """Test building pkg_b from a uv workspace - uses hatchling.""" +def test_uv_workspace_pkg_b_meson_python( + tmp_path: Path, build_frontend_env: dict[str, str] +) -> None: + """Test building pkg_b from a uv workspace - uses meson-python.""" + if shutil.which("meson") is None: + pytest.skip("meson not available (required for meson-python)") + project_dir = tmp_path / "project" # build a uv workspace project with the helper project = test_projects.new_uv_workspace_project() project.generate(project_dir) - # build pkg_b (eggs) - it uses hatchling (pure Python) + # build pkg_b (eggs) - it uses meson-python with a C extension actual_wheels = utils.cibuildwheel_run( project_dir, package_dir="pkg_b", @@ -50,6 +56,16 @@ def test_uv_workspace_pkg_b_hatchling(tmp_path: Path, build_frontend_env: dict[s ) # expected wheels: one from pkg_b (eggs) - expected_wheels = utils.expected_wheels("eggs", "0.1.0", single_python=True) + # meson-python doesn't support win32 on a 64-bit CI machine + is_windows = utils.get_platform() == "windows" + expected_wheels = utils.expected_wheels( + "eggs", "0.1.0", single_python=True, single_arch=is_windows + ) - assert set(actual_wheels) == set(expected_wheels) + actual_wheels_normalized = { + packaging.utils.parse_wheel_filename(w) for w in actual_wheels + } + expected_wheels_normalized = { + packaging.utils.parse_wheel_filename(w) for w in expected_wheels + } + assert actual_wheels_normalized == expected_wheels_normalized From ce63bd6b2ab34d2aa7f2df35e22d928837a9a3fc Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 21 Apr 2026 16:09:48 -0400 Subject: [PATCH 4/5] feat: multiple wheels per build support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All tasks are complete. Here's a summary of the changes made: - **`_wheel_is_compatible()`**: Extracted the core compatibility check from `find_compatible_wheel` into a standalone function - **`find_all_compatible_wheels()`**: Returns *all* compatible wheels for an identifier (not just the first) - **`should_skip_build()`**: New function that returns `True` only if *all* previously built wheels are compatible with the current identifier. This fixes the workspace bug where an `abi3` subpackage would incorrectly skip a rebuild of a normal subpackage. - Added unit tests for both new functions - **`build_end()`** now accepts `Path | Sequence[Path] | None` - When a list of wheels is passed, one `BuildInfo` row per wheel is appended to the summary - Added `Sequence` import - **Linux** (`linux.py`): Discovers all wheels via `container.glob()`, repairs each one individually using per-wheel temp dirs, tests install all wheels, copies all back to host - **macOS** (`macos.py`): Same pattern with `list(built_wheel_dir.glob("*.whl"))` and per-wheel repair dirs - **Windows** (`windows.py`): Same pattern - **iOS** (`ios.py`): Consistency update (no uv support yet) - **Pyodide** (`pyodide.py`): Consistency update (no uv support yet) - **Android** (`android.py`): Renamed `build_wheel()` → `build_wheels()` returning `list[Path]`, renamed `test_wheel()` → `test_wheels()` accepting `list[Path]` 1. Use `should_skip_build()` instead of `find_compatible_wheel()` to decide whether to skip 2. If skipping: collect all compatible wheels and use them for testing 3. If building: discover all `*.whl` files in the output dir 4. Repair each wheel individually (using unique temp dirs like `repaired_wheel_0`, `repaired_wheel_1`) 5. Install **all** wheels into the test virtualenv before running tests 6. Move all repaired wheels to the output directory 7. Call `log.build_end()` with the list of output wheels - All existing `unit_test` tests pass ✅ - `nox -s lint` passes (ruff check + format + mypy 3.11 + mypy 3.14) ✅ Signed-off-by: Henry Schreiner Assisted-by: OpenCode:Kimi-K2.6 --- cibuildwheel/logger.py | 34 ++++++-- cibuildwheel/platforms/android.py | 91 +++++++++++--------- cibuildwheel/platforms/ios.py | 133 ++++++++++++++++-------------- cibuildwheel/platforms/linux.py | 109 +++++++++++++----------- cibuildwheel/platforms/macos.py | 121 +++++++++++++++------------ cibuildwheel/platforms/pyodide.py | 117 +++++++++++++++----------- cibuildwheel/platforms/windows.py | 117 +++++++++++++++----------- cibuildwheel/util/packaging.py | 103 ++++++++++++++--------- test/test_uv_workspace.py | 8 +- unit_test/utils_test.py | 55 +++++++++++- 10 files changed, 546 insertions(+), 342 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index e2b5776ca..1290a5b0c 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -9,7 +9,7 @@ import sys import textwrap import time -from collections.abc import Generator +from collections.abc import Generator, Sequence from pathlib import Path from typing import IO, TYPE_CHECKING, AnyStr, Final, Literal @@ -166,7 +166,7 @@ def build_start(self, identifier: str) -> None: self.build_start_time = time.time() self.active_build_identifier = identifier - def build_end(self, filename: Path | None) -> None: + def build_end(self, filename: Path | Sequence[Path] | None) -> None: assert self.build_start_time is not None assert self.active_build_identifier is not None self.step_end() @@ -178,9 +178,33 @@ def build_end(self, filename: Path | None) -> None: print() print(f"{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration_str}") - self.summary.append( - BuildInfo(identifier=self.active_build_identifier, filename=filename, duration=duration) - ) + + if isinstance(filename, Sequence): + if not filename: + self.summary.append( + BuildInfo( + identifier=self.active_build_identifier, + filename=None, + duration=duration, + ) + ) + else: + for f in filename: + self.summary.append( + BuildInfo( + identifier=self.active_build_identifier, + filename=f, + duration=duration, + ) + ) + else: + self.summary.append( + BuildInfo( + identifier=self.active_build_identifier, + filename=filename, + duration=duration, + ) + ) self.build_start_time = None self.active_build_identifier = None diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py index 50b07884c..011ae5d37 100644 --- a/cibuildwheel/platforms/android.py +++ b/cibuildwheel/platforms/android.py @@ -32,7 +32,10 @@ from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file from cibuildwheel.util.helpers import prepare_command -from cibuildwheel.util.packaging import find_compatible_wheel +from cibuildwheel.util.packaging import ( + find_all_compatible_wheels, + should_skip_build, +) from cibuildwheel.util.python_build_standalone import create_python_build_standalone_environment from cibuildwheel.venv import constraint_flags, find_uv, virtualenv @@ -139,29 +142,32 @@ def build(options: Options, tmp_path: Path) -> None: config, build_options, build_path, python_dir, build_env, android_env ) - compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) - if compatible_wheel: + skip_build = should_skip_build(built_wheels, config.identifier) + if skip_build: + compatible_wheels = find_all_compatible_wheels(built_wheels, config.identifier) print( - f"\nFound previously built wheel {compatible_wheel.name} that is " - f"compatible with {config.identifier}. Skipping build step..." + f"\nFound previously built wheels {[p.name for p in compatible_wheels]} " + f"that are compatible with {config.identifier}. Skipping build step..." ) - repaired_wheel = compatible_wheel + repaired_wheels = compatible_wheels else: before_build(state) - built_wheel = build_wheel(state) - repaired_wheel = repair_wheel(state, built_wheel) + built_wheels_list = build_wheels(state) + repaired_wheels = [repair_wheel(state, bw) for bw in built_wheels_list] - test_wheel(state, repaired_wheel, build_frontend=build_options.build_frontend.name) + test_wheels(state, repaired_wheels, build_frontend=build_options.build_frontend.name) - output_wheel: Path | None = None - if compatible_wheel is None: - output_wheel = move_file( - repaired_wheel, build_options.output_dir / repaired_wheel.name - ) - built_wheels.append(output_wheel) + output_wheels: list[Path] = [] + if not skip_build: + for repaired_wheel in repaired_wheels: + output_wheel = move_file( + repaired_wheel, build_options.output_dir / repaired_wheel.name + ) + built_wheels.append(output_wheel) + output_wheels.append(output_wheel) shutil.rmtree(build_path) - log.build_end(output_wheel) + log.build_end(output_wheels or None) except subprocess.CalledProcessError as error: msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" @@ -446,7 +452,7 @@ def before_build(state: BuildState) -> None: ) -def build_wheel(state: BuildState) -> Path: +def build_wheels(state: BuildState) -> list[Path]: log.step("Building wheel...") built_wheel_dir = state.build_path / "built_wheel" match state.options.build_frontend.name: @@ -491,20 +497,20 @@ def build_wheel(state: BuildState) -> Path: raise AssertionError(msg) built_wheels = list(built_wheel_dir.glob("*.whl")) - if len(built_wheels) != 1: - msg = f"{built_wheel_dir} contains {len(built_wheels)} wheels; expected 1" + if not built_wheels: + msg = f"{built_wheel_dir} contains no wheels" raise errors.FatalError(msg) - built_wheel = built_wheels[0] - if built_wheel.name.endswith("none-any.whl"): - raise errors.NonPlatformWheelError() - return built_wheel + for built_wheel in built_wheels: + if built_wheel.name.endswith("none-any.whl"): + raise errors.NonPlatformWheelError() + return built_wheels def repair_wheel(state: BuildState, built_wheel: Path) -> Path: log.step("Repairing wheel...") repaired_wheel_dir = state.build_path / "repaired_wheel" - repaired_wheel_dir.mkdir() + repaired_wheel_dir.mkdir(parents=True, exist_ok=True) if state.options.repair_command: shell( @@ -633,7 +639,7 @@ def soname_with_hash(src_path: Path) -> str: return src_name -def test_wheel(state: BuildState, wheel: Path, *, build_frontend: str) -> None: +def test_wheels(state: BuildState, wheels: list[Path], *, build_frontend: str) -> None: test_command = state.options.test_command if not (test_command and state.options.test_selector(state.config.identifier)): return @@ -670,20 +676,31 @@ def test_wheel(state: BuildState, wheel: Path, *, build_frontend: str) -> None: ] ) - # Install the wheel and test-requires. + # Install the wheels and test-requires. site_packages_dir = state.build_path / "site-packages" site_packages_dir.mkdir() - call( - *pip, - "install", - "--only-binary=:all:", - *platform_args, - "--target", - site_packages_dir, - f"{wheel}{state.options.test_extras}", - *state.options.test_requires, - env=state.android_env, - ) + for wheel in wheels: + call( + *pip, + "install", + "--only-binary=:all:", + *platform_args, + "--target", + site_packages_dir, + f"{wheel}{state.options.test_extras}", + env=state.android_env, + ) + if state.options.test_requires: + call( + *pip, + "install", + "--only-binary=:all:", + *platform_args, + "--target", + site_packages_dir, + *state.options.test_requires, + env=state.android_env, + ) # Copy test-sources. cwd_dir = state.build_path / "cwd" diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py index 9e2474483..018f343c1 100644 --- a/cibuildwheel/platforms/ios.py +++ b/cibuildwheel/platforms/ios.py @@ -24,7 +24,10 @@ from cibuildwheel.util.cmd import call, shell, split_command from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file from cibuildwheel.util.helpers import prepare_command, unwrap_preserving_paragraphs -from cibuildwheel.util.packaging import find_compatible_wheel +from cibuildwheel.util.packaging import ( + find_all_compatible_wheels, + should_skip_build, +) from cibuildwheel.venv import constraint_flags, virtualenv @@ -437,7 +440,6 @@ def build(options: Options, tmp_path: Path) -> None: identifier_tmp_dir = tmp_path / config.identifier identifier_tmp_dir.mkdir() built_wheel_dir = identifier_tmp_dir / "built_wheel" - repaired_wheel_dir = identifier_tmp_dir / "repaired_wheel" constraints_path = build_options.dependency_constraints.get_for_python_version( version=config.version, tmp_dir=identifier_tmp_dir @@ -452,15 +454,16 @@ def build(options: Options, tmp_path: Path) -> None: xbuild_tools=build_options.xbuild_tools, ) - compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) - if compatible_wheel: + skip_build = should_skip_build(built_wheels, config.identifier) + repaired_wheels: list[Path] + if skip_build: + compatible_wheels = find_all_compatible_wheels(built_wheels, config.identifier) log.step_end() print( - f"\nFound previously built wheel {compatible_wheel.name} " - f"that is compatible with {config.identifier}. " - "Skipping build step..." + f"\nFound previously built wheels {[p.name for p in compatible_wheels]}, " + f"that is compatible with {config.identifier}. Skipping build step..." ) - test_wheel = compatible_wheel + repaired_wheels = compatible_wheels else: if build_options.before_build: log.step("Running before_build...") @@ -511,35 +514,42 @@ def build(options: Options, tmp_path: Path) -> None: case _: assert_never(build_frontend) - built_wheel = next(built_wheel_dir.glob("*.whl")) - - if built_wheel.name.endswith("none-any.whl"): - raise errors.NonPlatformWheelError() - - repaired_wheel_dir.mkdir() - if build_options.repair_command: - log.step("Repairing wheel...") - - repair_command_prepared = prepare_command( - build_options.repair_command, - wheel=built_wheel, - dest_dir=repaired_wheel_dir, - package=build_options.package_dir, - project=".", - ) - shell(repair_command_prepared, env=env) - else: - shutil.move(str(built_wheel), repaired_wheel_dir) + built_wheels_list = list(built_wheel_dir.glob("*.whl")) + if not built_wheels_list: + msg = "Build step did not produce any wheels" + raise errors.FatalError(msg) + + for built_wheel in built_wheels_list: + if built_wheel.name.endswith("none-any.whl"): + raise errors.NonPlatformWheelError() + + repaired_wheels = [] + for wheel_idx, built_wheel in enumerate(built_wheels_list): + this_repaired_dir = identifier_tmp_dir / f"repaired_wheel_{wheel_idx}" + this_repaired_dir.mkdir() + if build_options.repair_command: + log.step("Repairing wheel...") + + repair_command_prepared = prepare_command( + build_options.repair_command, + wheel=built_wheel, + dest_dir=this_repaired_dir, + package=build_options.package_dir, + project=".", + ) + shell(repair_command_prepared, env=env) + else: + shutil.move(str(built_wheel), this_repaired_dir) - try: - repaired_wheel = next(repaired_wheel_dir.glob("*.whl")) - except StopIteration: - raise errors.RepairStepProducedNoWheelError() from None + try: + repaired_wheel = next(this_repaired_dir.glob("*.whl")) + except StopIteration: + raise errors.RepairStepProducedNoWheelError() from None - if repaired_wheel.name in {wheel.name for wheel in built_wheels}: - raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + if repaired_wheel.name in {wheel.name for wheel in built_wheels}: + raise errors.AlreadyBuiltWheelError(repaired_wheel.name) - test_wheel = repaired_wheel + repaired_wheels.append(repaired_wheel) log.step_end() @@ -585,27 +595,28 @@ def build(options: Options, tmp_path: Path) -> None: ) log.step("Installing test requirements...") - # Install the compiled wheel (with any test extras), plus + # Install the compiled wheels (with any test extras), plus # the test requirements. Use the --platform tag to force # the installation of iOS wheels; this requires the use of # --only-binary=:all: ios_version = test_env["IPHONEOS_DEPLOYMENT_TARGET"] platform_tag = f"ios_{ios_version.replace('.', '_')}_{config.arch}_{config.sdk}" - call( - "python", - "-m", - "pip", - "install", - "--only-binary=:all:", - "--platform", - platform_tag, - "--target", - testbed_path / "iOSTestbed" / "app_packages", - f"{test_wheel}{build_options.test_extras}", - *build_options.test_requires, - env=test_env, - ) + for test_wheel in repaired_wheels: + call( + "python", + "-m", + "pip", + "install", + "--only-binary=:all:", + "--platform", + platform_tag, + "--target", + testbed_path / "iOSTestbed" / "app_packages", + f"{test_wheel}{build_options.test_extras}", + *build_options.test_requires, + env=test_env, + ) log.step("Running test suite...") @@ -705,21 +716,23 @@ def build(options: Options, tmp_path: Path) -> None: log.step_end() - # We're all done here; move it to output (overwrite existing) - output_wheel: Path | None = None - if compatible_wheel is None: - output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) - moved_wheel = move_file(repaired_wheel, output_wheel) - if moved_wheel != output_wheel.resolve(): - log.warning( - f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" - ) - built_wheels.append(output_wheel) + # We're all done here; move wheels to output (overwrite existing) + output_wheels: list[Path] = [] + if not skip_build: + for repaired_wheel in repaired_wheels: + output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) + moved_wheel = move_file(repaired_wheel, output_wheel) + if moved_wheel != output_wheel.resolve(): + log.warning( + f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" + ) + built_wheels.append(output_wheel) + output_wheels.append(output_wheel) # Clean up shutil.rmtree(identifier_tmp_dir) - log.build_end(output_wheel) + log.build_end(output_wheels or None) except subprocess.CalledProcessError as error: msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" raise errors.FatalError(msg) from error diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py index d5d9366b7..a1cf53e0e 100644 --- a/cibuildwheel/platforms/linux.py +++ b/cibuildwheel/platforms/linux.py @@ -18,7 +18,10 @@ 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_all_compatible_wheels, + should_skip_build, +) if TYPE_CHECKING: from cibuildwheel.typing import PathOrStr @@ -249,13 +252,15 @@ 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: + skip_build = should_skip_build(built_wheels, config.identifier) + repaired_wheels: list[PurePosixPath] + if skip_build: + compatible_wheels = find_all_compatible_wheels(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..." + f"\nFound previously built wheels {[p.name for p in compatible_wheels]}, that's compatible with {config.identifier}. Skipping build step..." ) - repaired_wheel = compatible_wheel + repaired_wheels = compatible_wheels else: if build_options.before_build: log.step("Running before_build...") @@ -326,38 +331,48 @@ def build_in_container( case _: assert_never(build_frontend) - built_wheel = container.glob(built_wheel_dir, "*.whl")[0] - - repaired_wheel_dir = temp_dir / "repaired_wheel" - container.call(["rm", "-rf", repaired_wheel_dir]) - container.call(["mkdir", "-p", repaired_wheel_dir]) + built_wheels_list = container.glob(built_wheel_dir, "*.whl") + if not built_wheels_list: + msg = "Build step did not produce any wheels" + raise errors.FatalError(msg) - if built_wheel.name.endswith("none-any.whl"): - raise errors.NonPlatformWheelError() + for built_wheel in built_wheels_list: + if built_wheel.name.endswith("none-any.whl"): + raise errors.NonPlatformWheelError() + + repaired_wheels = [] + for built_wheel in built_wheels_list: + repaired_wheel_dir = temp_dir / "repaired_wheel" + container.call(["rm", "-rf", repaired_wheel_dir]) + container.call(["mkdir", "-p", repaired_wheel_dir]) + + if build_options.repair_command: + log.step("Repairing wheel...") + repair_command_prepared = prepare_command( + build_options.repair_command, + wheel=built_wheel, + dest_dir=repaired_wheel_dir, + package=container_package_dir, + project=container_project_path, + ) + container.call(["sh", "-c", repair_command_prepared], env=env) + else: + container.call(["mv", built_wheel, repaired_wheel_dir]) - if build_options.repair_command: - log.step("Repairing wheel...") - repair_command_prepared = prepare_command( - build_options.repair_command, - wheel=built_wheel, - dest_dir=repaired_wheel_dir, - package=container_package_dir, - project=container_project_path, - ) - container.call(["sh", "-c", repair_command_prepared], env=env) - else: - container.call(["mv", built_wheel, repaired_wheel_dir]) + match container.glob(repaired_wheel_dir, "*.whl"): + case []: + raise errors.RepairStepProducedNoWheelError() + case [repaired_wheel]: + pass + case too_many: + raise errors.RepairStepProducedMultipleWheelsError( + [p.name for p in too_many] + ) - match container.glob(repaired_wheel_dir, "*.whl"): - case []: - raise errors.RepairStepProducedNoWheelError() - case [repaired_wheel]: - pass - case too_many: - raise errors.RepairStepProducedMultipleWheelsError([p.name for p in too_many]) + if repaired_wheel.name in {wheel.name for wheel in built_wheels}: + raise errors.AlreadyBuiltWheelError(repaired_wheel.name) - if repaired_wheel.name in {wheel.name for wheel in built_wheels}: - raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + repaired_wheels.append(repaired_wheel) if build_options.test_command and build_options.test_selector(config.identifier): log.step("Testing wheel...") @@ -398,11 +413,12 @@ def build_in_container( ) container.call(["sh", "-c", before_test_prepared], env=virtualenv_env) - # Install the wheel we just built - container.call( - [*pip, "install", str(repaired_wheel) + build_options.test_extras], - env=virtualenv_env, - ) + # Install all the wheels we just built + for repaired_wheel in repaired_wheels: + container.call( + [*pip, "install", str(repaired_wheel) + build_options.test_extras], + env=virtualenv_env, + ) # Install any requirements to run the tests if build_options.test_requires: @@ -413,7 +429,7 @@ def build_in_container( build_options.test_command, project=container_project_path, package=container_package_dir, - wheel=repaired_wheel, + wheel=repaired_wheels[0], ) test_cwd = testing_temp_dir / "test_cwd" @@ -436,15 +452,16 @@ def build_in_container( # clean up test environment container.call(["rm", "-rf", testing_temp_dir]) - # move repaired wheel to output - output_wheel: Path | None = None - if compatible_wheel is None: + # move repaired wheels to output + output_wheels: list[Path] = [] + if not skip_build: container.call(["mkdir", "-p", container_output_dir]) - container.call(["mv", repaired_wheel, container_output_dir]) - built_wheels.append(container_output_dir / repaired_wheel.name) - output_wheel = options.globals.output_dir / repaired_wheel.name + for repaired_wheel in repaired_wheels: + container.call(["mv", repaired_wheel, container_output_dir]) + built_wheels.append(container_output_dir / repaired_wheel.name) + output_wheels.append(options.globals.output_dir / repaired_wheel.name) - log.build_end(output_wheel) + log.build_end(output_wheels or None) log.step("Copying wheels back to host...") # copy the output back into the host diff --git a/cibuildwheel/platforms/macos.py b/cibuildwheel/platforms/macos.py index 37c3d799c..d581371b1 100644 --- a/cibuildwheel/platforms/macos.py +++ b/cibuildwheel/platforms/macos.py @@ -27,7 +27,11 @@ from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file from cibuildwheel.util.helpers import prepare_command, unwrap -from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version +from cibuildwheel.util.packaging import ( + find_all_compatible_wheels, + get_pip_version, + should_skip_build, +) from cibuildwheel.venv import constraint_flags, find_uv, virtualenv @@ -438,7 +442,6 @@ def build(options: Options, tmp_path: Path) -> None: identifier_tmp_dir = tmp_path / config.identifier identifier_tmp_dir.mkdir() built_wheel_dir = identifier_tmp_dir / "built_wheel" - repaired_wheel_dir = identifier_tmp_dir / "repaired_wheel" config_is_arm64 = config.identifier.endswith("arm64") config_is_universal2 = config.identifier.endswith("universal2") @@ -456,13 +459,15 @@ def build(options: Options, tmp_path: Path) -> None: ) pip_version = None if use_uv else get_pip_version(env) - compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) - if compatible_wheel: + skip_build = should_skip_build(built_wheels, config.identifier) + repaired_wheels: list[Path] + if skip_build: + compatible_wheels = find_all_compatible_wheels(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..." + f"\nFound previously built wheels {[p.name for p in compatible_wheels]}, that's compatible with {config.identifier}. Skipping build step..." ) - repaired_wheel = compatible_wheel + repaired_wheels = compatible_wheels else: if build_options.before_build: log.step("Running before_build...") @@ -530,42 +535,51 @@ def build(options: Options, tmp_path: Path) -> None: case _: assert_never(build_frontend) - built_wheel = next(built_wheel_dir.glob("*.whl")) + built_wheels_list = list(built_wheel_dir.glob("*.whl")) + if not built_wheels_list: + msg = "Build step did not produce any wheels" + raise errors.FatalError(msg) - repaired_wheel_dir.mkdir() + for built_wheel in built_wheels_list: + if built_wheel.name.endswith("none-any.whl"): + raise errors.NonPlatformWheelError() - if built_wheel.name.endswith("none-any.whl"): - raise errors.NonPlatformWheelError() + repaired_wheels = [] + for wheel_idx, built_wheel in enumerate(built_wheels_list): + this_repaired_dir = identifier_tmp_dir / f"repaired_wheel_{wheel_idx}" + this_repaired_dir.mkdir() - if build_options.repair_command: - log.step("Repairing wheel...") + if build_options.repair_command: + log.step("Repairing wheel...") - if config_is_universal2: - delocate_archs = "x86_64,arm64" - elif config_is_arm64: - delocate_archs = "arm64" + if config_is_universal2: + delocate_archs = "x86_64,arm64" + elif config_is_arm64: + delocate_archs = "arm64" + else: + delocate_archs = "x86_64" + + repair_command_prepared = prepare_command( + build_options.repair_command, + wheel=built_wheel, + dest_dir=this_repaired_dir, + delocate_archs=delocate_archs, + package=build_options.package_dir, + project=".", + ) + shell(repair_command_prepared, env=env) else: - delocate_archs = "x86_64" - - repair_command_prepared = prepare_command( - build_options.repair_command, - wheel=built_wheel, - dest_dir=repaired_wheel_dir, - delocate_archs=delocate_archs, - package=build_options.package_dir, - project=".", - ) - shell(repair_command_prepared, env=env) - else: - shutil.move(str(built_wheel), repaired_wheel_dir) + shutil.move(str(built_wheel), this_repaired_dir) - try: - repaired_wheel = next(repaired_wheel_dir.glob("*.whl")) - except StopIteration: - raise errors.RepairStepProducedNoWheelError() from None + try: + repaired_wheel = next(this_repaired_dir.glob("*.whl")) + except StopIteration: + raise errors.RepairStepProducedNoWheelError() from None - if repaired_wheel.name in {wheel.name for wheel in built_wheels}: - raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + if repaired_wheel.name in {wheel.name for wheel in built_wheels}: + raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + + repaired_wheels.append(repaired_wheel) log.step_end() @@ -698,7 +712,7 @@ def build(options: Options, tmp_path: Path) -> None: ) shell_with_arch(before_test_prepared, env=virtualenv_env) - # install the wheel + # install the wheels if is_cp38 and python_arch == "x86_64": virtualenv_env_install_wheel = virtualenv_env.copy() virtualenv_env_install_wheel["SYSTEM_VERSION_COMPAT"] = "0" @@ -716,10 +730,11 @@ def build(options: Options, tmp_path: Path) -> None: else: virtualenv_env_install_wheel = virtualenv_env - pip_install( - f"{repaired_wheel}{build_options.test_extras}", - env=virtualenv_env_install_wheel, - ) + for repaired_wheel in repaired_wheels: + pip_install( + f"{repaired_wheel}{build_options.test_extras}", + env=virtualenv_env_install_wheel, + ) # test the wheel if build_options.test_requires: @@ -735,7 +750,7 @@ def build(options: Options, tmp_path: Path) -> None: build_options.test_command, project=Path.cwd(), package=build_options.package_dir.resolve(), - wheel=repaired_wheel, + wheel=repaired_wheels[0], ) test_cwd = identifier_tmp_dir / "test_cwd" @@ -760,21 +775,23 @@ def build(options: Options, tmp_path: Path) -> None: shell_with_arch(test_command_prepared, cwd=test_cwd, env=virtualenv_env) - # we're all done here; move it to output (overwrite existing) - output_wheel = None - if compatible_wheel is None: - output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) - moved_wheel = move_file(repaired_wheel, output_wheel) - if moved_wheel != output_wheel.resolve(): - log.warning( - f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" - ) - built_wheels.append(output_wheel) + # we're all done here; move wheels to output (overwrite existing) + output_wheels: list[Path] = [] + if not skip_build: + for repaired_wheel in repaired_wheels: + output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) + moved_wheel = move_file(repaired_wheel, output_wheel) + if moved_wheel != output_wheel.resolve(): + log.warning( + f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" + ) + built_wheels.append(output_wheel) + output_wheels.append(output_wheel) # clean up shutil.rmtree(identifier_tmp_dir) - log.build_end(output_wheel) + log.build_end(output_wheels or None) except subprocess.CalledProcessError as error: msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" raise errors.FatalError(msg) from error diff --git a/cibuildwheel/platforms/pyodide.py b/cibuildwheel/platforms/pyodide.py index e0560b4a0..c82b98adc 100644 --- a/cibuildwheel/platforms/pyodide.py +++ b/cibuildwheel/platforms/pyodide.py @@ -32,7 +32,11 @@ move_file, ) from cibuildwheel.util.helpers import prepare_command, unwrap, unwrap_preserving_paragraphs -from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version +from cibuildwheel.util.packaging import ( + find_all_compatible_wheels, + get_pip_version, + should_skip_build, +) from cibuildwheel.util.python_build_standalone import ( PythonBuildStandaloneError, create_python_build_standalone_environment, @@ -404,13 +408,16 @@ def build(options: Options, tmp_path: Path) -> None: oldmounts = env["_PYODIDE_EXTRA_MOUNTS"] + ":" env["_PYODIDE_EXTRA_MOUNTS"] = oldmounts + ":".join(extra_mounts) - compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) - if compatible_wheel: + skip_build = should_skip_build(built_wheels, config.identifier) + repaired_wheels: list[Path] + if skip_build: + compatible_wheels = find_all_compatible_wheels(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..." + f"\nFound previously built wheels {[p.name for p in compatible_wheels]}, " + f"that's compatible with {config.identifier}. Skipping build step..." ) - built_wheel = compatible_wheel + repaired_wheels = compatible_wheels else: if build_options.before_build: log.step("Running before_build...") @@ -436,30 +443,41 @@ def build(options: Options, tmp_path: Path) -> None: *extra_flags, env=env, ) - built_wheel = next(built_wheel_dir.glob("*.whl")) - - if built_wheel.name.endswith("none-any.whl"): - raise errors.NonPlatformWheelError() - - if build_options.repair_command: - log.step("Repairing wheel...") - - repair_command_prepared = prepare_command( - build_options.repair_command, - wheel=built_wheel, - dest_dir=repaired_wheel_dir, - package=build_options.package_dir, - project=".", - ) - shell(repair_command_prepared, env=env) - log.step_end() - else: - shutil.move(str(built_wheel), repaired_wheel_dir) - - repaired_wheel = next(repaired_wheel_dir.glob("*.whl")) - - if repaired_wheel.name in {wheel.name for wheel in built_wheels}: - raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + built_wheels_list = list(built_wheel_dir.glob("*.whl")) + if not built_wheels_list: + msg = "Build step did not produce any wheels" + raise errors.FatalError(msg) + + for built_wheel in built_wheels_list: + if built_wheel.name.endswith("none-any.whl"): + raise errors.NonPlatformWheelError() + + repaired_wheels = [] + for wheel_idx, built_wheel in enumerate(built_wheels_list): + this_repaired_dir = identifier_tmp_dir / f"repaired_wheel_{wheel_idx}" + this_repaired_dir.mkdir() + + if build_options.repair_command: + log.step("Repairing wheel...") + + repair_command_prepared = prepare_command( + build_options.repair_command, + wheel=built_wheel, + dest_dir=this_repaired_dir, + package=build_options.package_dir, + project=".", + ) + shell(repair_command_prepared, env=env) + log.step_end() + else: + shutil.move(str(built_wheel), this_repaired_dir) + + repaired_wheel = next(this_repaired_dir.glob("*.whl")) + + if repaired_wheel.name in {wheel.name for wheel in built_wheels}: + raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + + repaired_wheels.append(repaired_wheel) if build_options.test_command and build_options.test_selector(config.identifier): log.step("Testing wheel...") @@ -504,17 +522,18 @@ def build(options: Options, tmp_path: Path) -> None: build_options.before_test, project=".", package=build_options.package_dir, - wheel=repaired_wheel, + wheel=repaired_wheels[0], ) shell(before_test_prepared, env=virtualenv_env) - # install the wheel - call( - "pip", - "install", - f"{repaired_wheel}{build_options.test_extras}", - env=virtualenv_env, - ) + # install the wheels + for repaired_wheel in repaired_wheels: + call( + "pip", + "install", + f"{repaired_wheel}{build_options.test_extras}", + env=virtualenv_env, + ) # test the wheel if build_options.test_requires: @@ -545,17 +564,19 @@ def build(options: Options, tmp_path: Path) -> None: shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env) - # we're all done here; move it to output (overwrite existing) - output_wheel: Path | None = None - if compatible_wheel is None: - output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) - moved_wheel = move_file(repaired_wheel, output_wheel) - if moved_wheel != output_wheel.resolve(): - log.warning( - f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" - ) - built_wheels.append(output_wheel) - log.build_end(output_wheel) + # we're all done here; move wheels to output (overwrite existing) + output_wheels: list[Path] = [] + if not skip_build: + for repaired_wheel in repaired_wheels: + output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) + moved_wheel = move_file(repaired_wheel, output_wheel) + if moved_wheel != output_wheel.resolve(): + log.warning( + f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" + ) + built_wheels.append(output_wheel) + output_wheels.append(output_wheel) + log.build_end(output_wheels or None) except subprocess.CalledProcessError as error: msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" diff --git a/cibuildwheel/platforms/windows.py b/cibuildwheel/platforms/windows.py index 3bb9e86f7..2fd78568b 100644 --- a/cibuildwheel/platforms/windows.py +++ b/cibuildwheel/platforms/windows.py @@ -29,7 +29,11 @@ move_file, ) from cibuildwheel.util.helpers import prepare_command, unwrap -from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version +from cibuildwheel.util.packaging import ( + find_all_compatible_wheels, + get_pip_version, + should_skip_build, +) from cibuildwheel.venv import constraint_flags, find_uv, virtualenv @@ -421,7 +425,6 @@ def build(options: Options, tmp_path: Path) -> None: identifier_tmp_dir = tmp_path / config.identifier identifier_tmp_dir.mkdir() built_wheel_dir = identifier_tmp_dir / "built_wheel" - repaired_wheel_dir = identifier_tmp_dir / "repaired_wheel" constraints_path = build_options.dependency_constraints.get_for_python_version( version=config.version, @@ -438,13 +441,15 @@ def build(options: Options, tmp_path: Path) -> None: ) pip_version = None if use_uv else get_pip_version(env) - compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) - if compatible_wheel: + skip_build = should_skip_build(built_wheels, config.identifier) + repaired_wheels: list[Path] + if skip_build: + compatible_wheels = find_all_compatible_wheels(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..." + f"\nFound previously built wheels {[p.name for p in compatible_wheels]}, that's compatible with {config.identifier}. Skipping build step..." ) - repaired_wheel = compatible_wheel + repaired_wheels = compatible_wheels else: # run the before_build command if build_options.before_build: @@ -530,34 +535,43 @@ def build(options: Options, tmp_path: Path) -> None: case _: assert_never(build_frontend) - built_wheel = next(built_wheel_dir.glob("*.whl")) - - # repair the wheel - repaired_wheel_dir.mkdir() - - if built_wheel.name.endswith("none-any.whl"): - raise errors.NonPlatformWheelError() + built_wheels_list = list(built_wheel_dir.glob("*.whl")) + if not built_wheels_list: + msg = "Build step did not produce any wheels" + raise errors.FatalError(msg) + + for built_wheel in built_wheels_list: + if built_wheel.name.endswith("none-any.whl"): + raise errors.NonPlatformWheelError() + + repaired_wheels = [] + for wheel_idx, built_wheel in enumerate(built_wheels_list): + # repair the wheel + this_repaired_dir = identifier_tmp_dir / f"repaired_wheel_{wheel_idx}" + this_repaired_dir.mkdir() + + if build_options.repair_command: + log.step("Repairing wheel...") + repair_command_prepared = prepare_command( + build_options.repair_command, + wheel=built_wheel, + dest_dir=this_repaired_dir, + package=build_options.package_dir, + project=".", + ) + shell(repair_command_prepared, env=env) + else: + shutil.move(str(built_wheel), this_repaired_dir) - if build_options.repair_command: - log.step("Repairing wheel...") - repair_command_prepared = prepare_command( - build_options.repair_command, - wheel=built_wheel, - dest_dir=repaired_wheel_dir, - package=build_options.package_dir, - project=".", - ) - shell(repair_command_prepared, env=env) - else: - shutil.move(str(built_wheel), repaired_wheel_dir) + try: + repaired_wheel = next(this_repaired_dir.glob("*.whl")) + except StopIteration: + raise errors.RepairStepProducedNoWheelError() from None - try: - repaired_wheel = next(repaired_wheel_dir.glob("*.whl")) - except StopIteration: - raise errors.RepairStepProducedNoWheelError() from None + if repaired_wheel.name in {wheel.name for wheel in built_wheels}: + raise errors.AlreadyBuiltWheelError(repaired_wheel.name) - if repaired_wheel.name in {wheel.name for wheel in built_wheels}: - raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + repaired_wheels.append(repaired_wheel) test_selected = options.globals.test_selector(config.identifier) if test_selected and config.arch == "ARM64" != platform_module.machine(): @@ -608,13 +622,14 @@ def build(options: Options, tmp_path: Path) -> None: else: pip = ["pip"] - # install the wheel - call( - *pip, - "install", - str(repaired_wheel) + build_options.test_extras, - env=virtualenv_env, - ) + # install the wheels + for repaired_wheel in repaired_wheels: + call( + *pip, + "install", + str(repaired_wheel) + build_options.test_extras, + env=virtualenv_env, + ) # test the wheel if build_options.test_requires: @@ -641,27 +656,29 @@ def build(options: Options, tmp_path: Path) -> None: build_options.test_command, project=Path.cwd(), package=options.globals.package_dir.resolve(), - wheel=repaired_wheel, + wheel=repaired_wheels[0], ) shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env) - # we're all done here; move it to output (remove if already exists) - output_wheel = None - if compatible_wheel is None: - output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) - moved_wheel = move_file(repaired_wheel, output_wheel) - if moved_wheel != output_wheel.resolve(): - log.warning( - f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" - ) - built_wheels.append(output_wheel) + # we're all done here; move wheels to output (remove if already exists) + output_wheels: list[Path] = [] + if not skip_build: + for repaired_wheel in repaired_wheels: + output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) + moved_wheel = move_file(repaired_wheel, output_wheel) + if moved_wheel != output_wheel.resolve(): + log.warning( + f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" + ) + built_wheels.append(output_wheel) + output_wheels.append(output_wheel) # clean up # (we ignore errors because occasionally Windows fails to unlink a file and we # don't want to abort a build because of that) shutil.rmtree(identifier_tmp_dir, ignore_errors=True) - log.build_end(output_wheel) + log.build_end(output_wheels or None) except subprocess.CalledProcessError as error: msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" raise errors.FatalError(msg) from error diff --git a/cibuildwheel/util/packaging.py b/cibuildwheel/util/packaging.py index 82e3494c4..c47921eea 100644 --- a/cibuildwheel/util/packaging.py +++ b/cibuildwheel/util/packaging.py @@ -128,52 +128,81 @@ def get_pip_version(env: Mapping[str, str]) -> str: T = TypeVar("T", bound=PurePath) -def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None: - """ - Finds a wheel with an abi3 or a none ABI tag in `wheels` compatible with the Python interpreter - specified by `identifier` that is previously built. - """ - +def _wheel_is_compatible(wheel_name: str, identifier: str) -> bool: interpreter, platform = identifier.split("-", 1) interpreter = interpreter.split("_")[0] free_threaded = interpreter.endswith("t") if free_threaded: interpreter = interpreter[:-1] - for wheel in wheels: - _, _, _, tags = parse_wheel_filename(wheel.name) - for tag in tags: - if tag.abi == "abi3" and not free_threaded: - # ABI3 wheels must start with cp3 for impl and tag - if not (interpreter.startswith("cp3") and tag.interpreter.startswith("cp3")): - continue - elif tag.abi == "none": - # CPythonless wheels must include py3 tag - if tag.interpreter[:3] != "py3": - continue - else: - # Other types of wheels are not detected, this is looking for previously built wheels. - continue - if tag.interpreter != "py3" and int(tag.interpreter[3:]) > int(interpreter[3:]): - # If a minor version number is given, it has to be lower than the current one. + _, _, _, tags = parse_wheel_filename(wheel_name) + for tag in tags: + if tag.abi == "abi3" and not free_threaded: + # ABI3 wheels must start with cp3 for impl and tag + if not (interpreter.startswith("cp3") and tag.interpreter.startswith("cp3")): continue - - if platform.startswith(("manylinux", "musllinux", "macosx", "android", "ios")): - # On these platforms the wheel tag includes a platform version number, which we - # should ignore. - os_, arch = platform.split("_", 1) - if not tag.platform.startswith(os_): - continue - if not tag.platform.endswith(f"_{arch}"): - continue - elif platform.startswith("pyodide"): - # each Pyodide version has its own platform tag + elif tag.abi == "none": + # CPythonless wheels must include py3 tag + if tag.interpreter[:3] != "py3": + continue + else: + # Other types of wheels are not compatible. + return False + + if tag.interpreter != "py3" and int(tag.interpreter[3:]) > int(interpreter[3:]): + # If a minor version number is given, it has to be lower than the current one. + continue + + if platform.startswith(("manylinux", "musllinux", "macosx", "android", "ios")): + # On these platforms the wheel tag includes a platform version number, which we + # should ignore. + os_, arch = platform.split("_", 1) + if not tag.platform.startswith(os_): continue - # Windows should exactly match - elif tag.platform != platform: + if not tag.platform.endswith(f"_{arch}"): continue + elif platform.startswith("pyodide"): + # each Pyodide version has its own platform tag + continue + # Windows should exactly match + elif tag.platform != platform: + continue - # If all the filters above pass, then the wheel is a previously built compatible wheel. - return wheel + # If all the filters above pass, then the wheel is a previously built compatible wheel. + return True + + return False + +def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None: + """ + Finds a wheel with an abi3 or a none ABI tag in `wheels` compatible with the Python interpreter + specified by `identifier` that is previously built. + """ + for wheel in wheels: + if _wheel_is_compatible(wheel.name, identifier): + return wheel return None + + +def find_all_compatible_wheels(wheels: Sequence[T], identifier: str) -> list[T]: + """ + Returns all wheels in `wheels` that are compatible with `identifier` + (i.e. have an abi3 or none ABI tag matching the platform). + """ + return [wheel for wheel in wheels if _wheel_is_compatible(wheel.name, identifier)] + + +def should_skip_build(wheels: Sequence[T], identifier: str) -> bool: + """ + Returns True if we can skip the build for `identifier` because *all* + previously built wheels are compatible with it. + + Empty `wheels` returns False (nothing to skip). + If any previously built wheel is not compatible, we must rebuild + (necessary for workspace builds where one wheel might be abi3 and + another might be platform-specific). + """ + if not wheels: + return False + return all(_wheel_is_compatible(wheel.name, identifier) for wheel in wheels) diff --git a/test/test_uv_workspace.py b/test/test_uv_workspace.py index b80ba831a..648e5ec1d 100644 --- a/test/test_uv_workspace.py +++ b/test/test_uv_workspace.py @@ -62,10 +62,6 @@ def test_uv_workspace_pkg_b_meson_python( "eggs", "0.1.0", single_python=True, single_arch=is_windows ) - actual_wheels_normalized = { - packaging.utils.parse_wheel_filename(w) for w in actual_wheels - } - expected_wheels_normalized = { - packaging.utils.parse_wheel_filename(w) for w in expected_wheels - } + actual_wheels_normalized = {packaging.utils.parse_wheel_filename(w) for w in actual_wheels} + expected_wheels_normalized = {packaging.utils.parse_wheel_filename(w) for w in expected_wheels} assert actual_wheels_normalized == expected_wheels_normalized diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 3ce67e273..f959576e6 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -15,7 +15,11 @@ unwrap, unwrap_preserving_paragraphs, ) -from cibuildwheel.util.packaging import find_compatible_wheel +from cibuildwheel.util.packaging import ( + find_all_compatible_wheels, + find_compatible_wheel, + should_skip_build, +) def test_format_safe() -> None: @@ -103,6 +107,55 @@ def test_find_compatible_wheel_not_found(wheel: str, identifier: str) -> None: assert find_compatible_wheel([PurePath(wheel)], identifier) is None +@pytest.mark.parametrize( + ("wheels", "identifier", "expected_count"), + [ + (["foo-0.1-cp38-abi3-win_amd64.whl"], "cp310-win_amd64", 1), + ( + ["foo-0.1-cp38-abi3-win_amd64.whl", "bar-0.1-cp38-abi3-win_amd64.whl"], + "cp310-win_amd64", + 2, + ), + (["foo-0.1-cp38-cp38-win_amd64.whl"], "cp310-win_amd64", 0), + ([], "cp310-win_amd64", 0), + ( + [ + "foo-0.1-cp38-abi3-win_amd64.whl", + "bar-0.1-cp38-cp38-win_amd64.whl", + ], + "cp310-win_amd64", + 1, + ), + ], +) +def test_find_all_compatible_wheels( + wheels: list[str], identifier: str, expected_count: int +) -> None: + result = find_all_compatible_wheels([PurePath(w) for w in wheels], identifier) + assert len(result) == expected_count + + +@pytest.mark.parametrize( + ("wheels", "identifier", "expected"), + [ + ([], "cp310-win_amd64", False), + (["foo-0.1-cp38-abi3-win_amd64.whl"], "cp310-win_amd64", True), + (["foo-0.1-py3-none-win_amd64.whl"], "cp310-win_amd64", True), + (["foo-0.1-cp38-cp38-win_amd64.whl"], "cp310-win_amd64", False), + ( + [ + "foo-0.1-cp38-abi3-win_amd64.whl", + "bar-0.1-cp38-cp38-win_amd64.whl", + ], + "cp310-win_amd64", + False, + ), + ], +) +def test_should_skip_build(wheels: list[str], identifier: str, expected: bool) -> None: + assert should_skip_build([PurePath(w) for w in wheels], identifier) == expected + + def test_fix_ansi_codes_for_github_actions() -> None: input = textwrap.dedent( """ From a605c6253d89371f7acf3d7345af8199d9893f12 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 22 Apr 2026 15:17:58 -0400 Subject: [PATCH 5/5] chore: move one thing to pattern matching Signed-off-by: Henry Schreiner --- cibuildwheel/logger.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index 1290a5b0c..e0800b85c 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -179,8 +179,8 @@ def build_end(self, filename: Path | Sequence[Path] | None) -> None: print() print(f"{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration_str}") - if isinstance(filename, Sequence): - if not filename: + match filename: + case []: self.summary.append( BuildInfo( identifier=self.active_build_identifier, @@ -188,7 +188,7 @@ def build_end(self, filename: Path | Sequence[Path] | None) -> None: duration=duration, ) ) - else: + case [*_]: for f in filename: self.summary.append( BuildInfo( @@ -197,14 +197,14 @@ def build_end(self, filename: Path | Sequence[Path] | None) -> None: duration=duration, ) ) - else: - self.summary.append( - BuildInfo( - identifier=self.active_build_identifier, - filename=filename, - duration=duration, + case _: + self.summary.append( + BuildInfo( + identifier=self.active_build_identifier, + filename=filename, + duration=duration, + ) ) - ) self.build_start_time = None self.active_build_identifier = None