diff --git a/cibuildwheel/platforms/windows.py b/cibuildwheel/platforms/windows.py index edb0c997..f0b12068 100644 --- a/cibuildwheel/platforms/windows.py +++ b/cibuildwheel/platforms/windows.py @@ -318,12 +318,22 @@ def setup_python( log.step("Installing build tools...") match build_frontend: + case "pip": + call( + "pip", + "install", + "--upgrade", + "delvewheel", + *constraint_flags(dependency_constraint), + env=env, + ) case "build": call( "pip", "install", "--upgrade", "build[virtualenv]", + "delvewheel", *constraint_flags(dependency_constraint), env=env, ) @@ -337,9 +347,25 @@ def setup_python( where_python, "--upgrade", "build[virtualenv]", + "delvewheel", *constraint_flags(dependency_constraint), env=env, ) + case "uv": + assert uv_path is not None + call( + uv_path, + "pip", + "install", + "--python", + where_python, + "--upgrade", + "delvewheel", + *constraint_flags(dependency_constraint), + env=env, + ) + case _: + assert_never(build_frontend) if python_libs_base: # Set up the environment for various backends to enable cross-compilation diff --git a/cibuildwheel/resources/constraints-pyodide312.txt b/cibuildwheel/resources/constraints-pyodide312.txt index d74cb73f..4a9e1323 100644 --- a/cibuildwheel/resources/constraints-pyodide312.txt +++ b/cibuildwheel/resources/constraints-pyodide312.txt @@ -37,7 +37,8 @@ packaging==26.2 # build # pyodide-build # wheel -pip==26.1.1 +pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" +pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r .nox/update_constraints/tmp/constraints-pyodide.in platformdirs==4.9.6 # via diff --git a/cibuildwheel/resources/constraints-python310.txt b/cibuildwheel/resources/constraints-python310.txt index 8a6a3ddb..e36c1168 100644 --- a/cibuildwheel/resources/constraints-python310.txt +++ b/cibuildwheel/resources/constraints-python310.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -20,7 +22,10 @@ packaging==26.2 # via # build # delocate -pip==26.1.1 +pefile==2024.8.26 + # via delvewheel +pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" +pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via diff --git a/cibuildwheel/resources/constraints-python311.txt b/cibuildwheel/resources/constraints-python311.txt index e119bfbe..043692b3 100644 --- a/cibuildwheel/resources/constraints-python311.txt +++ b/cibuildwheel/resources/constraints-python311.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -18,6 +20,8 @@ packaging==26.2 # via # build # delocate +pefile==2024.8.26 + # via delvewheel pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/constraints-python312.txt b/cibuildwheel/resources/constraints-python312.txt index e119bfbe..043692b3 100644 --- a/cibuildwheel/resources/constraints-python312.txt +++ b/cibuildwheel/resources/constraints-python312.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -18,6 +20,8 @@ packaging==26.2 # via # build # delocate +pefile==2024.8.26 + # via delvewheel pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/constraints-python313.txt b/cibuildwheel/resources/constraints-python313.txt index 119f5611..043692b3 100644 --- a/cibuildwheel/resources/constraints-python313.txt +++ b/cibuildwheel/resources/constraints-python313.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -18,7 +20,10 @@ packaging==26.2 # via # build # delocate -pip==26.1.1 +pefile==2024.8.26 + # via delvewheel +pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" +pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via diff --git a/cibuildwheel/resources/constraints-python314.txt b/cibuildwheel/resources/constraints-python314.txt index 119f5611..043692b3 100644 --- a/cibuildwheel/resources/constraints-python314.txt +++ b/cibuildwheel/resources/constraints-python314.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -18,7 +20,10 @@ packaging==26.2 # via # build # delocate -pip==26.1.1 +pefile==2024.8.26 + # via delvewheel +pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" +pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via diff --git a/cibuildwheel/resources/constraints-python315.txt b/cibuildwheel/resources/constraints-python315.txt index 119f5611..043692b3 100644 --- a/cibuildwheel/resources/constraints-python315.txt +++ b/cibuildwheel/resources/constraints-python315.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -18,7 +20,10 @@ packaging==26.2 # via # build # delocate -pip==26.1.1 +pefile==2024.8.26 + # via delvewheel +pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" +pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via diff --git a/cibuildwheel/resources/constraints-python39.txt b/cibuildwheel/resources/constraints-python39.txt index 6ccbeb72..e23de627 100644 --- a/cibuildwheel/resources/constraints-python39.txt +++ b/cibuildwheel/resources/constraints-python39.txt @@ -6,6 +6,8 @@ build==1.4.4 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.19.1 @@ -20,6 +22,8 @@ packaging==26.2 # via # build # delocate +pefile==2024.8.26 + # via delvewheel pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.4.0 diff --git a/cibuildwheel/resources/constraints.in b/cibuildwheel/resources/constraints.in index 50bfabb6..8f40eb61 100644 --- a/cibuildwheel/resources/constraints.in +++ b/cibuildwheel/resources/constraints.in @@ -1,4 +1,5 @@ pip build delocate +delvewheel virtualenv diff --git a/cibuildwheel/resources/constraints.txt b/cibuildwheel/resources/constraints.txt index 119f5611..043692b3 100644 --- a/cibuildwheel/resources/constraints.txt +++ b/cibuildwheel/resources/constraints.txt @@ -6,6 +6,8 @@ build==1.5.0 # via -r cibuildwheel/resources/constraints.in delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in +delvewheel==1.12.1 + # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv filelock==3.29.0 @@ -18,7 +20,10 @@ packaging==26.2 # via # build # delocate -pip==26.1.1 +pefile==2024.8.26 + # via delvewheel +pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows" +pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows" # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml index 78895bf9..2807a1a5 100644 --- a/cibuildwheel/resources/defaults.toml +++ b/cibuildwheel/resources/defaults.toml @@ -58,6 +58,7 @@ repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel}" repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}" [tool.cibuildwheel.windows] +repair-wheel-command = "delvewheel repair -w {dest_dir} -v {wheel}" [tool.cibuildwheel.android] diff --git a/docs/options.md b/docs/options.md index f9f7d0f8..3d0f3f67 100644 --- a/docs/options.md +++ b/docs/options.md @@ -923,6 +923,7 @@ Default: - on Linux: `'auditwheel repair -w {dest_dir} {wheel}'` - on macOS: `'delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}'` +- on Windows: `'delvewheel repair -w {dest_dir} -v {wheel}'` - on Android: There is no default command, but cibuildwheel will add `libc++` to the wheel if anything links against it. Setting a command will replace this behavior. - on Pyodide: You can use `pyodide auditwheel repair --libdir /path/to/libraries --output-dir {dest_dir} {wheel}` command to repair the wheel. @@ -947,29 +948,35 @@ The command is run in a shell, so you can run multiple commands like `cmd1 && cm Platform-specific environment variables are also available:
`CIBW_REPAIR_WHEEL_COMMAND_MACOS` | `CIBW_REPAIR_WHEEL_COMMAND_WINDOWS` | `CIBW_REPAIR_WHEEL_COMMAND_LINUX` | `CIBW_REPAIR_WHEEL_COMMAND_ANDROID` | `CIBW_REPAIR_WHEEL_COMMAND_IOS` | `CIBW_REPAIR_WHEEL_COMMAND_PYODIDE` -!!! tip - cibuildwheel doesn't yet ship a default repair command for Windows. +!!! note "Windows: telling delvewheel where to find DLLs" + On Windows, delvewheel searches the directories on `PATH` for external DLL dependencies. If your DLLs are already discoverable via `PATH`, (say, installed by a package manager that adds itself and the relevant directories to `PATH`), the default repair command should be sufficient. - **If that's an issue for you, check out [delvewheel]** - a new package that aims to do the same as auditwheel or delocate for Windows. + If your project links against DLLs in a custom location – such as a [vcpkg](https://vcpkg.io/) or [Conan](https://conan.io/) install tree, or a manually built library directory, you may pass `--add-path` to tell delvewheel where to look. The flag can be used multiple times for more than one directory: - Because delvewheel is still relatively early-stage, cibuildwheel does not yet run it by default. However, we'd recommend giving it a try! See the examples below for usage. + ```toml + [tool.cibuildwheel.windows] + repair-wheel-command = "delvewheel repair --add-path C:/vcpkg/installed/x64-windows/bin --add-path C:/mylibs/bin -w {dest_dir} -v {wheel}" + ``` - [Delvewheel]: https://github.com/adang1345/delvewheel + You can also reference environment variables expanded by the shell at build time, for example if the path is set during `before-build`: + + ```yaml + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair --add-path %VCPKG_INSTALLED_DIR%\\x64-windows\\bin -w {dest_dir} -v {wheel}" + ``` #### Examples !!! tab examples "pyproject.toml" ```toml - # Use delvewheel on windows - [tool.cibuildwheel.windows] - before-build = "pip install delvewheel" - repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" - # Don't repair macOS wheels [tool.cibuildwheel.macos] repair-wheel-command = "" + # Don't repair Windows wheels + [tool.cibuildwheel.windows] + repair-wheel-command = "" + # Pass the `--lib-sdir .` flag to auditwheel on Linux [tool.cibuildwheel.linux] repair-wheel-command = "auditwheel repair --lib-sdir . -w {dest_dir} {wheel}" @@ -994,7 +1001,7 @@ Platform-specific environment variables are also available:
] [tool.cibuildwheel.windows] repair-wheel-command = [ - "copy {wheel} {dest_dir}", + "delvewheel repair -w {dest_dir} -v {wheel}", "pipx run abi3audit --strict --report {wheel}", ] ``` @@ -1005,13 +1012,12 @@ Platform-specific environment variables are also available:
!!! tab examples "Environment variables" ```yaml - # Use delvewheel on windows - CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel" - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" - # Don't repair macOS wheels CIBW_REPAIR_WHEEL_COMMAND_MACOS: "" + # Don't repair Windows wheels + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "" + # Pass the `--lib-sdir .` flag to auditwheel on Linux CIBW_REPAIR_WHEEL_COMMAND_LINUX: "auditwheel repair --lib-sdir . -w {dest_dir} {wheel}" @@ -1028,7 +1034,7 @@ Platform-specific environment variables are also available:
delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - copy {wheel} {dest_dir} && + delvewheel repair -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} ``` diff --git a/pyproject.toml b/pyproject.toml index d5c92213..8cb32292 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -247,6 +247,7 @@ ignore = ["W002"] # constraints-*.txt are allowed to be duplicates of one anoth ignore-words-list = [ "sur", "assertin", + "Fo", ] skip = [ '^docs/working-examples\.md', diff --git a/test/test_dependency_versions.py b/test/test_dependency_versions.py index c7d11e5b..97462401 100644 --- a/test/test_dependency_versions.py +++ b/test/test_dependency_versions.py @@ -10,7 +10,7 @@ from cibuildwheel.util import resources from . import test_projects, utils -VERSION_REGEX = r"([\w-]+)==([^\s]+)" +VERSION_REGEX = r"([\w-]+)==([^;\s]+)" CHECK_VERSIONS_SCRIPT = """\ ''' @@ -73,7 +73,13 @@ def test_check_versions_script( def get_versions_from_constraint_file(constraint_file: Path) -> dict[str, str]: constraint_file_text = constraint_file.read_text(encoding="utf-8") - return dict(re.findall(VERSION_REGEX, constraint_file_text)) + # Use the first occurrence of each package name. The marker-bearing lines + # (such as the GraalPy-specific pip pin) come after the default pin, so the + # first match is the one that applies to the majority of builds. + result: dict[str, str] = {} + for name, version in re.findall(VERSION_REGEX, constraint_file_text): + result.setdefault(name, version) + return result @pytest.mark.parametrize("python_version", ["3.9", "3.13"]) diff --git a/test/test_projects/__init__.py b/test/test_projects/__init__.py index b0107b72..cdbdf601 100644 --- a/test/test_projects/__init__.py +++ b/test/test_projects/__init__.py @@ -1,5 +1,5 @@ from .base import TestProject from .meson import new_meson_project -from .setuptools import new_c_project +from .setuptools import new_c_project, new_c_project_with_missing_dll -__all__ = ("TestProject", "new_c_project", "new_meson_project") +__all__ = ("TestProject", "new_c_project", "new_c_project_with_missing_dll", "new_meson_project") diff --git a/test/test_projects/setuptools.py b/test/test_projects/setuptools.py index 30b33caf..20f8bfb5 100644 --- a/test/test_projects/setuptools.py +++ b/test/test_projects/setuptools.py @@ -3,6 +3,79 @@ import jinja2 from .base import TestProject from .c import SPAM_C_TEMPLATE +_SPAM_C_WITH_MISSING_DLL = """\ +#include + +int cibwtest_add(int a, int b); + +static PyObject *spam_filter(PyObject *self, PyObject *args) +{ + const char *content; + int sts; + if (!PyArg_ParseTuple(args, "s", &content)) + return NULL; + sts = strcmp(content, "spam") != 0; + cibwtest_add(0, 0); + return PyLong_FromLong(sts); +} + +static PyMethodDef module_methods[] = { + {"filter", (PyCFunction)spam_filter, METH_VARARGS, "Execute a shell command."}, + {NULL} +}; + +PyMODINIT_FUNC PyInit_spam(void) +{ + static struct PyModuleDef moduledef = { + PyModuleDef_HEAD_INIT, "spam", "Example module", -1, module_methods, + }; + return PyModule_Create(&moduledef); +} +""" + +_SETUP_PY_WITH_MISSING_DLL = """\ +import subprocess +from pathlib import Path +from setuptools import setup, Extension +from setuptools.command.build_ext import build_ext as _orig_build_ext + +here = Path(__file__).parent +dll_dir = here / "_cibwtest_dll" + +class build_ext(_orig_build_ext): + def build_extensions(self): + if not self.compiler.initialized: + self.compiler.initialize() + dll_dir.mkdir(exist_ok=True) + + machine = { + "win-arm64": "ARM64", + "win-amd64": "X64", + "win32": "X86", + }.get(self.plat_name, "X64") + + def_file = dll_dir / "cibwtest.def" + def_file.write_text("EXPORTS\\n cibwtest_add\\n") + subprocess.check_call([ + self.compiler.lib, + f"/def:{def_file}", + "/name:cibwtest.dll", + f"/out:{dll_dir / 'cibwtest.lib'}", + f"/machine:{machine}", + ]) + super().build_extensions() + +setup( + ext_modules=[Extension( + "spam", + sources=["spam.c"], + libraries=["cibwtest"], + library_dirs=[str(dll_dir)], + )], + cmdclass={"build_ext": build_ext}, +) +""" + SETUP_PY_TEMPLATE = r""" import os import sys @@ -38,6 +111,24 @@ version = 0.1.0 """ +def new_c_project_with_missing_dll() -> TestProject: + """ + A Windows-only test project whose extension links against cibwtest.dll, a DLL + built into a subdirectory that is not on PATH. delvewheel will find the import + in the PE table but cannot locate the file, so repair fails by default. + Setting repair-wheel-command to "" disables repair and lets the build succeed. + """ + project = TestProject() + project.files.update( + { + "spam.c": _SPAM_C_WITH_MISSING_DLL, + "setup.py": _SETUP_PY_WITH_MISSING_DLL, + "setup.cfg": jinja2.Template(SETUP_CFG_TEMPLATE), + } + ) + return project + + def new_c_project( *, spam_c_top_level_add: str = "", diff --git a/test/test_windows.py b/test/test_windows.py index fac779b2..903e579b 100644 --- a/test/test_windows.py +++ b/test/test_windows.py @@ -8,6 +8,76 @@ import pytest from . import test_projects, utils basic_project = test_projects.new_c_project() +missing_dll_project = test_projects.new_c_project_with_missing_dll() + + +def test_delvewheel_runs_by_default(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + if utils.get_platform() != "windows": + pytest.skip("This test is only relevant to Windows") + + skip_if_no_msvc() + + project_dir = tmp_path / "project" + basic_project.generate(project_dir) + + utils.cibuildwheel_run(project_dir, add_args=["--archs", "native"], single_python=True) + + captured = capfd.readouterr() + assert "Repairing wheel" in captured.out + + +def test_delvewheel_disabled_by_empty_repair_command( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + if utils.get_platform() != "windows": + pytest.skip("This test is only relevant to Windows") + + skip_if_no_msvc() + + project_dir = tmp_path / "project" + basic_project.generate(project_dir) + + utils.cibuildwheel_run( + project_dir, + add_args=["--archs", "native"], + add_env={"CIBW_REPAIR_WHEEL_COMMAND_WINDOWS": ""}, + single_python=True, + ) + + captured = capfd.readouterr() + assert "Repairing wheel" not in captured.out + + +def test_delvewheel_fails_when_dll_is_missing(tmp_path: Path) -> None: + if utils.get_platform() != "windows": + pytest.skip("This test is only relevant to Windows") + + skip_if_no_msvc() + + project_dir = tmp_path / "project" + missing_dll_project.generate(project_dir) + + with pytest.raises(subprocess.CalledProcessError): + utils.cibuildwheel_run(project_dir, add_args=["--archs", "native"], single_python=True) + + +def test_delvewheel_disabled_allows_build_with_missing_dll(tmp_path: Path) -> None: + if utils.get_platform() != "windows": + pytest.skip("This test is only relevant to Windows") + + skip_if_no_msvc() + + project_dir = tmp_path / "project" + missing_dll_project.generate(project_dir) + + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_args=["--archs", "native"], + add_env={"CIBW_REPAIR_WHEEL_COMMAND_WINDOWS": ""}, + single_python=True, + ) + + assert len(actual_wheels) == 1 def skip_if_no_msvc(arm64: bool = False) -> None: diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index 463cd20f..6d415be3 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -215,14 +215,16 @@ def get_default_repair_command(platform: str) -> str: return "auditwheel repair -w {dest_dir} {wheel}" elif platform == "macos": return "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}" - elif platform in {"windows", "pyodide"}: + elif platform == "windows": + return "delvewheel repair -w {dest_dir} -v {wheel}" + elif platform == "pyodide": return "" else: msg = f"Unknown platform: {platform!r}" raise ValueError(msg) -@pytest.mark.parametrize("repair_command", [None, "repair", "repair -w {dest_dir} {wheel}"]) +@pytest.mark.parametrize("repair_command", [None, "", "repair", "repair -w {dest_dir} {wheel}"]) @pytest.mark.parametrize("platform_specific", [False, True]) def test_repair_command( repair_command: str | None, @@ -242,7 +244,9 @@ def test_repair_command( build_options = intercepted_build_args.args[0].build_options(identifier=None) - expected_repair = repair_command or get_default_repair_command(platform) + expected_repair = ( + get_default_repair_command(platform) if repair_command is None else repair_command + ) assert build_options.repair_command == expected_repair