diff --git a/README.md b/README.md index bfb40a63d..03d71f9bf 100644 --- a/README.md +++ b/README.md @@ -51,19 +51,20 @@ Usage `cibuildwheel` runs inside a CI service. Supported platforms depend on which service you're using: -| | Linux | macOS | Windows | Linux ARM | macOS ARM | -|-----------------|-------|-------|---------|-----------|-----------| -| GitHub Actions | ✅ | ✅ | ✅ | ✅¹ | ✅² | -| Azure Pipelines | ✅ | ✅ | ✅ | | ✅² | -| Travis CI | ✅ | | ✅ | ✅ | | -| AppVeyor | ✅ | ✅ | ✅ | | ✅² | -| CircleCI | ✅ | ✅ | | | ✅² | -| Gitlab CI | ✅ | | | | | -| Cirrus CI | ✅ | ✅³ | ✅ | ✅ | ✅ | +| | Linux | macOS | Windows | Linux ARM | macOS ARM | Windows ARM | +|-----------------|-------|-------|---------|-----------|-----------|-------------| +| GitHub Actions | ✅ | ✅ | ✅ | ✅¹ | ✅² | ✅⁴ | +| Azure Pipelines | ✅ | ✅ | ✅ | | ✅² | ✅⁴ | +| Travis CI | ✅ | | ✅ | ✅ | | | +| AppVeyor | ✅ | ✅ | ✅ | | ✅² | ✅⁴ | +| CircleCI | ✅ | ✅ | | | ✅² | | +| Gitlab CI | ✅ | | | | | | +| Cirrus CI | ✅ | ✅³ | ✅ | ✅ | ✅ | | ¹ [Requires emulation](https://cibuildwheel.readthedocs.io/en/stable/faq/#emulation), distributed separately. Other services may also support Linux ARM through emulation or third-party build hosts, but these are not tested in our CI.
² [Uses cross-compilation](https://cibuildwheel.readthedocs.io/en/stable/faq/#universal2). It is not possible to test `arm64` and the `arm64` part of a `universal2` wheel on this CI platform.
³ [Uses cross-compilation](https://cibuildwheel.readthedocs.io/en/stable/faq/#universal2). Thanks to Rosetta 2 emulation, it is possible to test `x86_64` and both parts of a `universal2` wheel on this CI platform.
+⁴ [Uses cross-compilation](https://cibuildwheel.readthedocs.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform. diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index fe44e5f47..70f22997c 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -126,6 +126,13 @@ def step_end_with_error(self, error: BaseException | str) -> None: self.step_end(success=False) self.error(error) + def notice(self, message: str) -> None: + if self.fold_mode == "github": + print(f"::notice::{message}\n", file=sys.stderr) + else: + c = self.colors + print(f"{c.bold}Note{c.end}: {message}\n", file=sys.stderr) + def warning(self, message: str) -> None: if self.fold_mode == "github": print(f"::warning::{message}\n", file=sys.stderr) diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 9e7dfff54..95faa569d 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -1,9 +1,11 @@ from __future__ import annotations import os +import platform as platform_module import shutil import subprocess import sys +import textwrap from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -33,15 +35,22 @@ read_python_configs, shell, split_config_settings, + unwrap, virtualenv, ) def get_nuget_args(version: str, arch: str, output_directory: Path) -> list[str]: - platform_suffix = {"32": "x86", "64": "", "ARM64": "arm64"} - python_name = "python" + platform_suffix[arch] + package_name = { + "32": "pythonx86", + "64": "python", + "ARM64": "pythonarm64", + # Aliases for platform.machine() return values + "x86": "pythonx86", + "AMD64": "python", + }[arch] return [ - python_name, + package_name, "-Version", version, "-FallbackSource", @@ -121,6 +130,82 @@ def install_pypy(tmp: Path, arch: str, url: str) -> Path: return installation_path / "python.exe" +def setup_setuptools_cross_compile( + tmp: Path, + python_configuration: PythonConfiguration, + python_libs_base: Path, + env: dict[str, str], +) -> None: + distutils_cfg = tmp / "extra-setup.cfg" + env["DIST_EXTRA_CONFIG"] = str(distutils_cfg) + log.notice(f"Setting DIST_EXTRA_CONFIG={distutils_cfg} for cross-compilation") + + # Ensure our additional import libraries are made available, and explicitly + # set the platform name + map_plat = {"32": "win32", "64": "win-amd64", "ARM64": "win-arm64"} + plat_name = map_plat[python_configuration.arch] + # (This file must be default/locale encoding, so we can't pass 'encoding') + distutils_cfg.write_text( + textwrap.dedent( + f"""\ + [build] + plat_name={plat_name} + [build_ext] + library_dirs={python_libs_base} + plat_name={plat_name} + [bdist_wheel] + plat_name={plat_name} + """ + ) + ) + + # setuptools builds require explicit override of PYD extension + # This is because it always gets the extension from the running + # interpreter, and has no logic to construct it. Currently, CPython's + # extensions follow our identifiers, but if they ever diverge in the + # future, we will need to store new data + log.notice( + f"Setting SETUPTOOLS_EXT_SUFFIX=.{python_configuration.identifier}.pyd for cross-compilation" + ) + env["SETUPTOOLS_EXT_SUFFIX"] = f".{python_configuration.identifier}.pyd" + + # Cross-compilation requires fixes that only exist in setuptools's copy of + # distutils, so ensure that it is activated + # Since not all projects can handle the newer distutils, display a warning + # to help them figure out what may have gone wrong if this breaks for them + log.notice("Setting SETUPTOOLS_USE_DISTUTILS=local as it is required for cross-compilation") + env["SETUPTOOLS_USE_DISTUTILS"] = "local" + + +def setup_rust_cross_compile( + tmp: Path, + python_configuration: PythonConfiguration, + python_libs_base: Path, + env: dict[str, str], +) -> None: + # Assume that MSVC will be used, because we already know that we are + # cross-compiling. MinGW users can set CARGO_BUILD_TARGET themselves + # and we will respect the existing value. + cargo_target = { + "64": "x86_64-pc-windows-msvc", + "32": "i686-pc-windows-msvc", + "ARM64": "aarch64-pc-windows-msvc", + }.get(python_configuration.arch) + + # CARGO_BUILD_TARGET is the variable used by Cargo and setuptools_rust + if env.get("CARGO_BUILD_TARGET"): + if env["CARGO_BUILD_TARGET"] != cargo_target: + log.notice("Not overriding CARGO_BUILD_TARGET as it has already been set") + # No message if it was set to what we were planning to set it to + elif cargo_target: + log.notice(f"Setting CARGO_BUILD_TARGET={cargo_target} for cross-compilation") + env["CARGO_BUILD_TARGET"] = cargo_target + else: + log.warning( + f"Unable to configure Rust cross-compilation for architecture {python_configuration.arch}" + ) + + def setup_python( tmp: Path, python_configuration: PythonConfiguration, @@ -130,9 +215,22 @@ def setup_python( ) -> dict[str, str]: tmp.mkdir() implementation_id = python_configuration.identifier.split("-")[0] + python_libs_base = None log.step(f"Installing Python {implementation_id}...") if implementation_id.startswith("cp"): - base_python = install_cpython(python_configuration.version, python_configuration.arch) + native_arch = platform_module.machine() + if python_configuration.arch == "ARM64" != native_arch: + # To cross-compile for ARM64, we need a native CPython to run the + # build, and a copy of the ARM64 import libraries ('.\libs\*.lib') + # for any extension modules. + python_libs_base = install_cpython( + python_configuration.version, python_configuration.arch + ) + python_libs_base = python_libs_base.parent / "libs" + log.step(f"Installing native Python {native_arch} for cross-compilation...") + base_python = install_cpython(python_configuration.version, native_arch) + else: + base_python = install_cpython(python_configuration.version, python_configuration.arch) elif implementation_id.startswith("pp"): assert python_configuration.url is not None base_python = install_pypy(tmp, python_configuration.arch, python_configuration.url) @@ -234,6 +332,11 @@ def setup_python( else: assert_never(build_frontend) + if python_libs_base: + # Set up the environment for various backends to enable cross-compilation + setup_setuptools_cross_compile(tmp, python_configuration, python_libs_base, env) + setup_rust_cross_compile(tmp, python_configuration, python_libs_base, env) + return env @@ -296,7 +399,9 @@ def build(options: Options, tmp_path: Path) -> None: if build_options.before_build: log.step("Running before_build...") before_build_prepared = prepare_command( - build_options.before_build, project=".", package=options.globals.package_dir + build_options.before_build, + project=".", + package=options.globals.package_dir, ) shell(before_build_prepared, env=env) @@ -367,7 +472,9 @@ def build(options: Options, tmp_path: Path) -> None: 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 + build_options.repair_command, + wheel=built_wheel, + dest_dir=repaired_wheel_dir, ) shell(repair_command_prepared, env=env) else: @@ -379,6 +486,19 @@ def build(options: Options, tmp_path: Path) -> None: raise AlreadyBuiltWheelError(repaired_wheel.name) if build_options.test_command and options.globals.test_selector(config.identifier): + if config.arch == "ARM64" != platform_module.machine(): + log.warning( + unwrap( + """ + While arm64 wheels can be built on other platforms, they cannot + be tested. An arm64 runner is required. To silence this warning, + set `CIBW_TEST_SKIP: *-win_arm64`. + """ + ) + ) + # skip this test + continue + log.step("Testing wheel...") # set up a virtual environment to install and test from, to make sure # there are no dependencies that were pulled in at build time. diff --git a/docs/faq.md b/docs/faq.md index a32d95b96..7571cf56c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -341,7 +341,7 @@ See [#816](https://github.com/pypa/cibuildwheel/issues/816), thanks to @phoeriou ### Windows: 'ImportError: DLL load failed: The specific module could not be found' -Visual Studio and MSVC link the compiled binary wheels to the Microsoft Visual C++ Runtime. Normally, these are included with Python, but when compiling with a newer version of Visual Studio, it is possible users will run into problems on systems that do not have these runtime libraries installed. The solution is to ask users to download the corresponding Visual C++ Redistributable from the [Microsoft website](https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads) and install it. Since a Python installation normally includes these VC++ Redistributable files for [the version of the MSVC compiler used to compile Python](https://wiki.python.org/moin/WindowsCompilers), this is typically only a problem when compiling a Python C extension with a newer compiler. +Visual Studio and MSVC link the compiled binary wheels to the Microsoft Visual C++ Runtime. Normally, the C parts of the runtime are included with Python, but the C++ components are not. When compiling modules using C++, it is possible users will run into problems on systems that do not have the full set of runtime libraries installed. The solution is to ask users to download the corresponding Visual C++ Redistributable from the [Microsoft website](https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads) and install it. Additionally, Visual Studio 2019 started linking to an even newer DLL, `VCRUNTIME140_1.dll`, besides the `VCRUNTIME140.dll` that is included with recent Python versions (starting from Python 3.5; see [here](https://wiki.python.org/moin/WindowsCompilers) for more details on the corresponding Visual Studio & MSVC versions used to compile the different Python versions). To avoid this extra dependency on `VCRUNTIME140_1.dll`, the [`/d2FH4-` flag](https://devblogs.microsoft.com/cppblog/making-cpp-exception-handling-smaller-x64/) can be added to the MSVC invocations (check out [this issue](https://github.com/pypa/cibuildwheel/issues/423) for details and references). CPython 3.8.3 and all versions after it have this extra DLL, so it is only needed for 3.8 and earlier. @@ -357,4 +357,12 @@ To add the `/d2FH4-` flag to a standard `setup.py` using `setuptools`, the `extr ], ``` -To investigate the dependencies of a C extension (i.e., the `.pyd` file, a DLL in disguise) on Windows, [Dependency Walker](http://www.dependencywalker.com/) is a great tool. +To investigate the dependencies of a C extension (i.e., the `.pyd` file, a DLL in disguise) on Windows, [Dependency Walker](http://www.dependencywalker.com/) is a great tool. For diagnosing a failing import, the [dlltracer](https://pypi.org/project/dlltracer/) tool may also provide additional details. + +### Windows ARM64 builds {: #windows-arm64} + +`cibuildwheel` supports cross-compiling `ARM64` wheels on all Windows runners, but a native ARM64 runner is required for testing. On non-native runners, tests for ARM64 wheels will be automatically skipped with a warning. Add `*-win_arm64` to your `CIBW_TEST_SKIP` setting to suppress the warning. + +Cross-compilation on Windows relies on a supported build backend. Supported backends use an environment variable to specify their target platform (the one they are compiling native modules for, as opposed to the one they are running on), which is set in [cibuildwheels/windows.py](https://github.com/pypa/cibuildwheel/blob/main/cibuildwheel/windows.py) before building. Currently, `setuptools>=65.4.1` and `setuptools_rust` are the only supported backends. + +By default, `ARM64` is not enabled when running on non-ARM64 runners. Use [`CIBW_ARCHS`](options.md#archs) to select it. diff --git a/docs/options.md b/docs/options.md index 498b15d08..55352159b 100644 --- a/docs/options.md +++ b/docs/options.md @@ -347,11 +347,14 @@ On macOS, this option can be used to cross-compile between `x86_64`, On Linux, this option can be used to build non-native architectures under emulation. See [this guide](faq.md#emulation) for more information. +On Windows, this option can be used to compile for `ARM64` from an Intel +machine, provided the cross-compiling tools are installed. + Options: - Linux: `x86_64` `i686` `aarch64` `ppc64le` `s390x` - macOS: `x86_64` `arm64` `universal2` -- Windows: `AMD64` `x86` +- Windows: `AMD64` `x86` `ARM64` - `auto`: The default archs for your machine - see the table below. - `auto64`: Just the 64-bit auto archs - `auto32`: Just the 32-bit auto archs @@ -366,6 +369,7 @@ Default: `auto` |---|---|---|---|---| | Linux / Intel | `x86_64` | `x86_64` `i686` | `x86_64` | `i686` | | Windows / Intel | `AMD64` | `AMD64` `x86` | `AMD64` | `x86` | +| Windows / ARM64 | `ARM64` | `ARM64` | `ARM64` | | | macOS / Intel | `x86_64` | `x86_64` | `x86_64` | | | macOS / Apple Silicon | `arm64` | `arm64` | `arm64` | | diff --git a/test/test_windows.py b/test/test_windows.py new file mode 100644 index 000000000..bbd1aa0a4 --- /dev/null +++ b/test/test_windows.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +import subprocess +import textwrap + +import pytest + +from . import test_projects, utils + +basic_project = test_projects.new_c_project() + + +def skip_if_no_msvc(arm64=False): + programfiles = os.getenv("ProgramFiles(x86)", "") or os.getenv("ProgramFiles", "") + if not programfiles: + pytest.skip("Requires %ProgramFiles(x86)% variable to be set") + + vswhere = os.path.join(programfiles, "Microsoft Visual Studio", "Installer", "vswhere.exe") + if not os.path.isfile(vswhere): + pytest.skip("Requires Visual Studio installation") + + require = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + if arm64: + require = "Microsoft.VisualStudio.Component.VC.Tools.ARM64" + + if not subprocess.check_output( + [ + vswhere, + "-latest", + "-prerelease", + "-property", + "installationPath", + "-requires", + require, + ] + ): + pytest.skip("Requires ARM64 compiler to be installed") + + +@pytest.mark.parametrize("use_pyproject_toml", [True, False]) +def test_wheel_tag_is_correct_when_using_windows_cross_compile(tmp_path, use_pyproject_toml): + if utils.platform != "windows": + pytest.skip("This test is only relevant to Windows") + + skip_if_no_msvc(arm64=True) + + if use_pyproject_toml: + basic_project.files["pyproject.toml"] = textwrap.dedent( + """ + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + """ + ) + + project_dir = tmp_path / "project" + basic_project.generate(project_dir) + + # build the wheels + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_BUILD": "cp310-*", + }, + add_args=["--archs", "ARM64"], + ) + + # check that the expected wheels are produced + expected_wheels = [ + "spam-0.1.0-cp310-cp310-win_arm64.whl", + ] + + print("actual_wheels", actual_wheels) + print("expected_wheels", expected_wheels) + + assert set(actual_wheels) == set(expected_wheels)