diff --git a/README.md b/README.md
index 0a391a733..028b8e53d 100644
--- a/README.md
+++ b/README.md
@@ -24,18 +24,18 @@ What does it do?
While cibuildwheel itself requires a recent Python version to run (we support the last three releases), it can target the following versions to build wheels:
-| | macOS Intel | macOS Apple Silicon | Windows 64bit | Windows 32bit | Windows Arm64 | manylinux
musllinux x86_64 | manylinux
musllinux i686 | manylinux
musllinux aarch64 | manylinux
musllinux ppc64le | manylinux
musllinux s390x | manylinux
musllinux armv7l | Pyodide |
-|----------------|----|-----|-----|-----|-----|----|-----|----|-----|-----|---|-----|
-| CPython 3.8 | ✅ | ✅ | ✅ | ✅ | N/A | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A |
-| CPython 3.9 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A |
-| CPython 3.10 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A |
-| CPython 3.11 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A |
-| CPython 3.12 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | ✅⁴ |
-| CPython 3.13³ | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A |
-| PyPy 3.8 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
-| PyPy 3.9 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
-| PyPy 3.10 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
-| PyPy 3.11 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
+| | macOS Intel | macOS Apple Silicon | Windows 64bit | Windows 32bit | Windows Arm64 | manylinux
musllinux x86_64 | manylinux
musllinux i686 | manylinux
musllinux aarch64 | manylinux
musllinux ppc64le | manylinux
musllinux s390x | manylinux
musllinux armv7l | iOS | Pyodide |
+|----------------|----|-----|-----|-----|-----|----|-----|----|-----|-----|---|-----|-----|
+| CPython 3.8 | ✅ | ✅ | ✅ | ✅ | N/A | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A | N/A |
+| CPython 3.9 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A | N/A |
+| CPython 3.10 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A | N/A |
+| CPython 3.11 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A | N/A |
+| CPython 3.12 | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | N/A | ✅⁴ |
+| CPython 3.13³ | ✅ | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | ✅ | ✅⁵ | ✅ | N/A |
+| PyPy 3.8 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
+| PyPy 3.9 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
+| PyPy 3.10 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
+| PyPy 3.11 v7.3 | ✅ | ✅ | ✅ | N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
¹ PyPy is only supported for manylinux wheels.
² Windows arm64 support is experimental.
@@ -55,18 +55,19 @@ Usage
`cibuildwheel` runs inside a CI service. Supported platforms depend on which service you're using:
-| | Linux | macOS | Windows | Linux ARM | macOS ARM | Windows ARM |
-|-----------------|-------|-------|---------|-----------|-----------|-------------|
-| GitHub Actions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅² |
-| Azure Pipelines | ✅ | ✅ | ✅ | | ✅ | ✅² |
-| Travis CI | ✅ | | ✅ | ✅ | | |
-| AppVeyor | ✅ | ✅ | ✅ | | ✅ | ✅² |
-| CircleCI | ✅ | ✅ | | ✅ | ✅ | |
-| Gitlab CI | ✅ | ✅ | ✅ | ✅¹ | ✅ | |
-| Cirrus CI | ✅ | ✅ | ✅ | ✅ | ✅ | |
+| | Linux | macOS | Windows | Linux ARM | macOS ARM | Windows ARM | iOS |
+|-----------------|-------|-------|---------|-----------|-----------|-------------|-----|
+| GitHub Actions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅² | ✅³ |
+| Azure Pipelines | ✅ | ✅ | ✅ | | ✅ | ✅² | ✅³ |
+| Travis CI | ✅ | | ✅ | ✅ | | | |
+| AppVeyor | ✅ | ✅ | ✅ | | ✅ | ✅² | ✅³ |
+| CircleCI | ✅ | ✅ | | ✅ | ✅ | | ✅³ |
+| Gitlab CI | ✅ | ✅ | ✅ | ✅¹ | ✅ | | ✅³ |
+| Cirrus CI | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅³ |
¹ [Requires emulation](https://cibuildwheel.pypa.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.pypa.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform.
+² [Uses cross-compilation](https://cibuildwheel.pypa.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform.
+³ Requires a macOS runner; runs tests on the simulator for the runner's architecture.
@@ -75,6 +76,7 @@ Example setup
To build manylinux, musllinux, macOS, and Windows wheels on GitHub Actions, you could use this `.github/workflows/wheels.yml`:
+
```yaml
name: Build
@@ -102,12 +104,14 @@ jobs:
# to supply options, put them in 'env', like:
# env:
# CIBW_SOME_OPTION: value
+ # ...
- uses: actions/upload-artifact@v4
with:
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
path: ./wheelhouse/*.whl
```
+
For more information, including PyPI deployment, and the use of other CI services or the dedicated GitHub Action, check out the [documentation](https://cibuildwheel.pypa.io) and the [examples](https://github.com/pypa/cibuildwheel/tree/main/examples).
diff --git a/bin/generate_schema.py b/bin/generate_schema.py
index ec4229381..b82a91136 100755
--- a/bin/generate_schema.py
+++ b/bin/generate_schema.py
@@ -322,6 +322,7 @@ def as_object(d: dict[str, Any]) -> dict[str, Any]:
"windows": as_object(not_linux),
"macos": as_object(not_linux),
"pyodide": as_object(not_linux),
+ "ios": as_object(not_linux),
}
oses["linux"]["properties"]["repair-wheel-command"] = {
diff --git a/bin/run_tests.py b/bin/run_tests.py
index e4ea6311d..2aed4f01a 100755
--- a/bin/run_tests.py
+++ b/bin/run_tests.py
@@ -41,12 +41,15 @@
sys.executable,
"-m",
"pytest",
+ "--dist",
+ "loadgroup",
f"--numprocesses={args.num_processes}",
"-x",
"--durations",
"0",
"--timeout=2400",
"test",
+ "-vv",
]
if sys.platform.startswith("linux") and args.run_podman:
diff --git a/bin/update_pythons.py b/bin/update_pythons.py
index 035acbd9b..7b8ea1474 100755
--- a/bin/update_pythons.py
+++ b/bin/update_pythons.py
@@ -44,13 +44,13 @@ class ConfigWinPP(TypedDict):
url: str
-class ConfigMacOS(TypedDict):
+class ConfigApple(TypedDict):
identifier: str
version: str
url: str
-AnyConfig = ConfigWinCP | ConfigWinPP | ConfigMacOS
+AnyConfig = ConfigWinCP | ConfigWinPP | ConfigApple
# The following set of "Versions" classes allow the initial call to the APIs to
@@ -154,7 +154,7 @@ def update_version_windows(self, spec: Specifier) -> ConfigWinCP:
url=url,
)
- def update_version_macos(self, spec: Specifier) -> ConfigMacOS:
+ def update_version_macos(self, spec: Specifier) -> ConfigApple:
if self.arch not in {"64", "ARM64"}:
msg = f"'{self.arch}' arch not supported yet on macOS"
raise RuntimeError(msg)
@@ -178,7 +178,7 @@ def update_version_macos(self, spec: Specifier) -> ConfigMacOS:
if "" in rf["platform"] == "darwin" and rf["arch"] == arch
)
- return ConfigMacOS(
+ return ConfigApple(
identifier=identifier,
version=f"{version.major}.{version.minor}",
url=url,
@@ -204,7 +204,7 @@ def __init__(self) -> None:
def update_version_macos(
self, identifier: str, version: Version, spec: Specifier
- ) -> ConfigMacOS | None:
+ ) -> ConfigApple | None:
# see note above on Specifier.filter
unsorted_versions = spec.filter(self.versions_dict)
sorted_versions = sorted(unsorted_versions, reverse=True)
@@ -223,7 +223,7 @@ def update_version_macos(
urls = [rf["url"] for rf in file_info if file_ident in rf["url"]]
if urls:
- return ConfigMacOS(
+ return ConfigApple(
identifier=identifier,
version=f"{new_version.major}.{new_version.minor}",
url=urls[0],
@@ -232,6 +232,48 @@ def update_version_macos(
return None
+class CPythonIOSVersions:
+ def __init__(self) -> None:
+ response = requests.get(
+ "https://api.github.com/repos/beeware/Python-Apple-support/releases",
+ headers={
+ "Accept": "application/vnd.github+json",
+ "X-Github-Api-Version": "2022-11-28",
+ },
+ )
+ response.raise_for_status()
+
+ releases_info = response.json()
+ self.versions_dict: dict[Version, dict[int, str]] = {}
+
+ # Each release has a name like "3.13-b4"
+ for release in releases_info:
+ py_version, build = release["name"].split("-")
+ version = Version(py_version)
+ self.versions_dict.setdefault(version, {})
+
+ # There are several release assets associated with each release;
+ # The name of the asset will be something like
+ # "Python-3.11-iOS-support.b4.tar.gz". Store all builds that are
+ # "-iOS-support" builds, retaining the download URL.
+ for asset in release["assets"]:
+ filename, build, _, _ = asset["name"].rsplit(".", 3)
+ if filename.endswith("-iOS-support"):
+ self.versions_dict[version][int(build[1:])] = asset["browser_download_url"]
+
+ def update_version_ios(self, identifier: str, version: Version) -> ConfigApple | None:
+ # Return a config using the highest build number for the given version.
+ urls = [url for _, url in sorted(self.versions_dict.get(version, {}).items())]
+ if urls:
+ return ConfigApple(
+ identifier=identifier,
+ version=str(version),
+ url=urls[-1],
+ )
+
+ return None
+
+
# This is a universal interface to all the above Versions classes. Given an
# identifier, it updates a config dict.
@@ -250,6 +292,8 @@ def __init__(self) -> None:
self.macos_pypy = PyPyVersions("64")
self.macos_pypy_arm64 = PyPyVersions("ARM64")
+ self.ios_cpython = CPythonIOSVersions()
+
def update_config(self, config: MutableMapping[str, str]) -> None:
identifier = config["identifier"]
version = Version(config["version"])
@@ -282,6 +326,8 @@ def update_config(self, config: MutableMapping[str, str]) -> None:
config_update = self.windows_t_arm64.update_version_windows(spec)
elif "win_arm64" in identifier and identifier.startswith("cp"):
config_update = self.windows_arm64.update_version_windows(spec)
+ elif "ios" in identifier:
+ config_update = self.ios_cpython.update_version_ios(identifier, version)
assert config_update is not None, f"{identifier} not found!"
config.update(**config_update)
@@ -317,6 +363,9 @@ def update_pythons(force: bool, level: str) -> None:
for config in configs["macos"]["python_configurations"]:
all_versions.update_config(config)
+ for config in configs["ios"]["python_configurations"]:
+ all_versions.update_config(config)
+
result_toml = dump_python_configurations(configs)
rich.print() # spacer
diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py
index fa3245741..e16adca9c 100644
--- a/cibuildwheel/__main__.py
+++ b/cibuildwheel/__main__.py
@@ -15,6 +15,7 @@
from typing import Any, Protocol, TextIO, assert_never
import cibuildwheel
+import cibuildwheel.ios
import cibuildwheel.linux
import cibuildwheel.macos
import cibuildwheel.pyodide
@@ -93,13 +94,14 @@ def main_inner(global_options: GlobalOptions) -> None:
parser.add_argument(
"--platform",
- choices=["auto", "linux", "macos", "windows", "pyodide"],
+ choices=["auto", "linux", "macos", "windows", "pyodide", "ios"],
default=None,
help="""
- Platform to build for. Use this option to override the
- auto-detected platform. Specifying "macos" or "windows" only works
- on that operating system, but "linux" works on all three, as long
- as Docker/Podman is installed. Default: auto.
+ Platform to build for. Use this option to override the auto-detected
+ platform. Specifying "macos" or "windows" only works on that
+ operating system. "linux" works on any desktop OS, as long as
+ Docker/Podman is installed. "pyodide" only works on linux and macOS.
+ "ios" only work on macOS. Default: auto.
""",
)
@@ -240,6 +242,8 @@ def _compute_platform_only(only: str) -> PlatformName:
return "windows"
if "pyodide_" in only:
return "pyodide"
+ if "ios_" in only:
+ return "ios"
msg = f"Invalid --only='{only}', must be a build selector with a known platform"
raise errors.ConfigurationError(msg)
@@ -301,6 +305,8 @@ def get_platform_module(platform: PlatformName) -> PlatformModule:
return cibuildwheel.macos
if platform == "pyodide":
return cibuildwheel.pyodide
+ if platform == "ios":
+ return cibuildwheel.ios
assert_never(platform)
diff --git a/cibuildwheel/architecture.py b/cibuildwheel/architecture.py
index 7af966ade..de17c75dd 100644
--- a/cibuildwheel/architecture.py
+++ b/cibuildwheel/architecture.py
@@ -17,6 +17,7 @@
"macos": "macOS",
"windows": "Windows",
"pyodide": "Pyodide",
+ "ios": "iOS",
}
ARCH_SYNONYMS: Final[list[dict[PlatformName, str | None]]] = [
@@ -63,6 +64,12 @@ class Architecture(StrEnum):
# WebAssembly
wasm32 = auto()
+ # iOS "multiarch" architectures that include both
+ # the CPU architecture and the ABI.
+ arm64_iphoneos = auto()
+ arm64_iphonesimulator = auto()
+ x86_64_iphonesimulator = auto()
+
@staticmethod
def parse_config(config: str, platform: PlatformName) -> "set[Architecture]":
result = set()
@@ -89,8 +96,8 @@ def parse_config(config: str, platform: PlatformName) -> "set[Architecture]":
@staticmethod
def native_arch(platform: PlatformName) -> "Architecture | None":
- if platform == "pyodide":
- return Architecture.wasm32
+ native_machine = platform_module.machine()
+ native_architecture = Architecture(native_machine)
# Cross-platform support. Used for --print-build-identifiers or docker builds.
host_platform: PlatformName = (
@@ -99,8 +106,18 @@ def native_arch(platform: PlatformName) -> "Architecture | None":
else ("macos" if sys.platform.startswith("darwin") else "linux")
)
- native_machine = platform_module.machine()
- native_architecture = Architecture(native_machine)
+ if platform == "pyodide":
+ return Architecture.wasm32
+ elif platform == "ios":
+ # Can only build for iOS on macOS. The "native" architecture is the
+ # simulator for the macOS native platform.
+ if host_platform == "macos":
+ if native_architecture == Architecture.x86_64:
+ return Architecture.x86_64_iphonesimulator
+ else:
+ return Architecture.arm64_iphonesimulator
+ else:
+ return None
# we might need to rename the native arch to the machine we're running
# on, as the same arch can have different names on different platforms
@@ -131,9 +148,13 @@ def auto_archs(platform: PlatformName) -> "set[Architecture]":
elif Architecture.aarch64 in result and _check_aarch32_el0():
result.add(Architecture.armv7l)
- if platform == "windows" and Architecture.AMD64 in result:
+ elif platform == "windows" and Architecture.AMD64 in result:
result.add(Architecture.x86)
+ elif platform == "ios" and native_arch == Architecture.arm64_iphonesimulator:
+ # Also build the device wheel if we're on ARM64.
+ result.add(Architecture.arm64_iphoneos)
+
return result
@staticmethod
@@ -150,6 +171,11 @@ def all_archs(platform: PlatformName) -> "set[Architecture]":
"macos": {Architecture.x86_64, Architecture.arm64, Architecture.universal2},
"windows": {Architecture.x86, Architecture.AMD64, Architecture.ARM64},
"pyodide": {Architecture.wasm32},
+ "ios": {
+ Architecture.x86_64_iphonesimulator,
+ Architecture.arm64_iphonesimulator,
+ Architecture.arm64_iphoneos,
+ },
}
return all_archs_map[platform]
diff --git a/cibuildwheel/ios.py b/cibuildwheel/ios.py
new file mode 100644
index 000000000..e3b6c0a47
--- /dev/null
+++ b/cibuildwheel/ios.py
@@ -0,0 +1,586 @@
+from __future__ import annotations
+
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+from collections.abc import Sequence, Set
+from dataclasses import dataclass
+from pathlib import Path
+from typing import assert_never
+
+from filelock import FileLock
+
+from . import errors
+from .architecture import Architecture
+from .environment import ParsedEnvironment
+from .frontend import (
+ BuildFrontendConfig,
+ BuildFrontendName,
+ get_build_frontend_extra_flags,
+)
+from .logger import log
+from .macos import install_cpython as install_build_cpython
+from .options import Options
+from .selector import BuildSelector
+from .typing import PathOrStr
+from .util import resources
+from .util.cmd import call, shell
+from .util.file import (
+ CIBW_CACHE_PATH,
+ copy_test_sources,
+ download,
+ move_file,
+)
+from .util.helpers import prepare_command
+from .util.packaging import (
+ combine_constraints,
+ find_compatible_wheel,
+ get_pip_version,
+)
+from .venv import virtualenv
+
+
+@dataclass(frozen=True)
+class PythonConfiguration:
+ version: str
+ identifier: str
+ url: str
+ build_url: str
+
+ @property
+ def sdk(self) -> str:
+ return self.multiarch.rsplit("-", 1)[1]
+
+ @property
+ def arch(self) -> str:
+ return self.multiarch.rsplit("-", 1)[0]
+
+ @property
+ def multiarch(self) -> str:
+ # The multiarch identifier, as reported by `sys.implementation._multiarch`
+ return "-".join(self.identifier.split("-ios_")[1].rsplit("_", 1))
+
+ @property
+ def is_simulator(self) -> bool:
+ return self.identifier.endswith("_iphonesimulator")
+
+ @property
+ def xcframework_slice(self) -> str:
+ "XCframeworks include binaries for multiple ABIs; which ABI section should be used?"
+ return "ios-arm64_x86_64-simulator" if self.is_simulator else "ios-arm64"
+
+
+def get_python_configurations(
+ build_selector: BuildSelector,
+ architectures: Set[Architecture],
+) -> list[PythonConfiguration]:
+ # iOS builds are always cross builds; we need to install a macOS Python as
+ # well. Rather than duplicate the location of the URL of macOS installers,
+ # load the macos configurations, determine the macOS configuration that
+ # matches the platform we're building, and embed that URL in the parsed iOS
+ # configuration.
+ macos_python_configs = resources.read_python_configs("macos")
+
+ def build_url(config_dict: dict[str, str]) -> str:
+ # The iOS identifier will be something like cp313-ios_arm64_iphoneos.
+ # Drop the iphoneos suffix, then replace ios with macosx to yield
+ # cp313-macosx_arm64, which will be a macOS build identifier.
+ modified_ios_identifier = config_dict["identifier"].rsplit("_", 1)[0]
+ macos_identifier = modified_ios_identifier.replace("ios", "macosx")
+ matching = [
+ config for config in macos_python_configs if config["identifier"] == macos_identifier
+ ]
+ return matching[0]["url"]
+
+ # Load the platform configuration
+ full_python_configs = resources.read_python_configs("ios")
+ # Build the configurations, annotating with macOS URL details.
+ python_configurations = [
+ PythonConfiguration(
+ **item,
+ build_url=build_url(item),
+ )
+ for item in full_python_configs
+ ]
+
+ # Filter out configs that don't match any of the selected architectures
+ python_configurations = [
+ c
+ for c in python_configurations
+ if any(c.identifier.endswith(f"-ios_{a.value}") for a in architectures)
+ ]
+
+ # Skip builds as required by BUILD/SKIP
+ python_configurations = [c for c in python_configurations if build_selector(c.identifier)]
+
+ return python_configurations
+
+
+def install_target_cpython(tmp: Path, config: PythonConfiguration, free_threading: bool) -> Path:
+ if free_threading:
+ msg = "Free threading builds aren't available for iOS (yet)"
+ raise errors.FatalError(msg)
+
+ # Install an iOS build of CPython
+ ios_python_tar_gz = config.url.rsplit("/", 1)[-1]
+ extension = ".tar.gz"
+ assert ios_python_tar_gz.endswith(extension)
+ installation_path = CIBW_CACHE_PATH / ios_python_tar_gz[: -len(extension)]
+ with FileLock(str(installation_path) + ".lock"):
+ if not installation_path.exists():
+ downloaded_tar_gz = tmp / ios_python_tar_gz
+ download(config.url, downloaded_tar_gz)
+ installation_path.mkdir(parents=True, exist_ok=True)
+ call("tar", "-C", installation_path, "-xf", downloaded_tar_gz)
+ downloaded_tar_gz.unlink()
+
+ return installation_path
+
+
+def cross_virtualenv(
+ *,
+ py_version: str,
+ target_python: Path,
+ multiarch: str,
+ build_python: Path,
+ venv_path: Path,
+ dependency_constraint_flags: Sequence[PathOrStr],
+) -> dict[str, str]:
+ """Create a cross-compilation virtual environment.
+
+ In a cross-compilation environment, the *target* is the platform where the
+ code will ultimately run, and the *build* is the platform where you're
+ running the compilation. When building iOS wheels, iOS is the target machine
+ and macOS is the build machine. The terminology around these machines varies
+ between build tools (configure uses "host" and "build"; cmake uses "target" and
+ "build host").
+
+ A cross-compilation virtualenv is an environment that is based on the
+ *build* python (so that binaries can execute); but it modifies the
+ environment at startup so that any request for platform details (such as
+ `sys.platform` or `sysconfig.get_platform()`) return details of the target
+ platform. It also applies a loader patch so that any virtualenv created by
+ the cross-compilation environment will also be a cross-compilation
+ environment.
+
+ :param py_version: The Python version (major.minor) in use
+ :param target_python: The path to the python binary for the target platform
+ :param multiarch: The multiarch tag for the target platform (i.e., the value
+ of `sys.implementation._multiarch`)
+ :param build_python: The path to the python binary for the build platform
+ :param venv_path: The path where the cross virtual environment should be
+ created.
+ :param dependency_constraint_flags: Any flags that should be used when
+ constraining dependencies in the environment.
+ """
+ # Create an initial macOS virtual environment
+ env = virtualenv(
+ py_version,
+ build_python,
+ venv_path,
+ dependency_constraint_flags,
+ use_uv=False,
+ )
+
+ # Convert the macOS virtual environment into an iOS virtual environment
+ # using the cross-platform conversion script in the iOS distribution.
+
+ # target_python is the path to the Python binary;
+ # determine the root of the XCframework slice that is being used.
+ slice_path = target_python.parent.parent
+ call(
+ "python",
+ str(slice_path / f"platform-config/{multiarch}/make_cross_venv.py"),
+ str(venv_path),
+ env=env,
+ cwd=venv_path,
+ )
+
+ # When running on macOS, it's easy for the build environment to leak into
+ # the target environment, especially when building for ARM64 (because the
+ # build architecture is the same as the target architecture). The primary
+ # culprit for this is Homebrew libraries leaking in as dependencies for iOS
+ # libraries.
+ #
+ # To prevent problems, set the PATH to isolate the build environment from
+ # sources that could introduce incompatible binaries.
+ env["PATH"] = os.pathsep.join(
+ [
+ # The target python's binary directory
+ str(target_python.parent),
+ # The cross-platform environments binary directory
+ str(venv_path / "bin"),
+ # Cargo's binary directory (to allow for Rust compilation)
+ str(Path.home() / ".cargo" / "bin"),
+ # The bare minimum Apple system paths.
+ "/usr/bin",
+ "/bin",
+ "/usr/sbin",
+ "/sbin",
+ "/Library/Apple/usr/bin",
+ ]
+ )
+ # Also unset DYLD_LIBRARY_PATH to ensure that no macOS libraries will be
+ # found and linked.
+ env.pop("DYLD_LIBRARY_PATH", None)
+
+ return env
+
+
+def setup_python(
+ tmp: Path,
+ python_configuration: PythonConfiguration,
+ dependency_constraint_flags: Sequence[PathOrStr],
+ environment: ParsedEnvironment,
+ build_frontend: BuildFrontendName,
+) -> tuple[Path, dict[str, str]]:
+ if build_frontend == "build[uv]":
+ msg = "uv doesn't support iOS"
+ raise errors.FatalError(msg)
+
+ # An iOS environment requires 2 python installs - one for the build machine
+ # (macOS), and one for the target (iOS). We'll only ever interact with the
+ # *target* python, but the build Python needs to exist to act as the base
+ # for a cross venv.
+ tmp.mkdir()
+ implementation_id = python_configuration.identifier.split("-")[0]
+ log.step(f"Installing Build Python {implementation_id}...")
+ if implementation_id.startswith("cp"):
+ free_threading = "t-ios" in python_configuration.identifier
+ build_python = install_build_cpython(
+ tmp,
+ python_configuration.version,
+ python_configuration.build_url,
+ free_threading,
+ )
+ else:
+ msg = f"Unknown Python implementation: {implementation_id}"
+ raise errors.FatalError(msg)
+
+ assert build_python.exists(), (
+ f"{build_python.name} not found, has {list(build_python.parent.iterdir())}"
+ )
+
+ log.step(f"Installing Target Python {implementation_id}...")
+ target_install_path = install_target_cpython(tmp, python_configuration, free_threading)
+ target_python = (
+ target_install_path
+ / "Python.xcframework"
+ / python_configuration.xcframework_slice
+ / "bin"
+ / f"python{python_configuration.version}"
+ )
+
+ assert target_python.exists(), (
+ f"{target_python.name} not found, has {list(target_install_path.iterdir())}"
+ )
+
+ log.step("Creating cross build environment...")
+
+ venv_path = tmp / "venv"
+ env = cross_virtualenv(
+ py_version=python_configuration.version,
+ target_python=target_python,
+ multiarch=python_configuration.multiarch,
+ build_python=build_python,
+ venv_path=venv_path,
+ dependency_constraint_flags=dependency_constraint_flags,
+ )
+ venv_bin_path = venv_path / "bin"
+ assert venv_bin_path.exists()
+
+ # We version pip ourselves, so we don't care about pip version checking
+ env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
+
+ # upgrade pip to the version matching our constraints
+ # if necessary, reinstall it to ensure that it's available on PATH as 'pip'
+ pip = ["python", "-m", "pip"]
+ call(
+ *pip,
+ "install",
+ "--upgrade",
+ "pip",
+ *dependency_constraint_flags,
+ env=env,
+ cwd=venv_path,
+ )
+
+ # Apply our environment after pip is ready
+ env = environment.as_dictionary(prev_environment=env)
+
+ # Check what Python version we're on
+ which_python = call("which", "python", env=env, capture_stdout=True).strip()
+ print(which_python)
+ if which_python != str(venv_bin_path / "python"):
+ msg = (
+ "cibuildwheel: python 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 python above it."
+ )
+ raise errors.FatalError(msg)
+ call("python", "--version", env=env)
+
+ # Check what pip version we're on
+ assert (venv_bin_path / "pip").exists()
+ which_pip = call("which", "pip", env=env, capture_stdout=True).strip()
+ print(which_pip)
+ if which_pip != str(venv_bin_path / "pip"):
+ msg = (
+ "cibuildwheel: 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)
+ call("pip", "--version", env=env)
+
+ # Ensure that IPHONEOS_DEPLOYMENT_TARGET is set in the environment
+ env.setdefault("IPHONEOS_DEPLOYMENT_TARGET", "13.0")
+
+ log.step("Installing build tools...")
+ if build_frontend == "pip":
+ # No additional build tools required
+ pass
+ elif build_frontend == "build":
+ call(
+ "pip",
+ "install",
+ "--upgrade",
+ "build[virtualenv]",
+ *dependency_constraint_flags,
+ env=env,
+ )
+ else:
+ assert_never(build_frontend)
+
+ return target_install_path, env
+
+
+def build(options: Options, tmp_path: Path) -> None:
+ if sys.platform != "darwin":
+ msg = "iOS binaries can only be built on macOS"
+ raise errors.FatalError(msg)
+
+ python_configurations = get_python_configurations(
+ build_selector=options.globals.build_selector,
+ architectures=options.globals.architectures,
+ )
+
+ if not python_configurations:
+ return
+
+ try:
+ before_all_options_identifier = python_configurations[0].identifier
+ before_all_options = options.build_options(before_all_options_identifier)
+
+ if before_all_options.before_all:
+ log.step("Running before_all...")
+ env = before_all_options.environment.as_dictionary(prev_environment=os.environ)
+ env.setdefault("IPHONEOS_DEPLOYMENT_TARGET", "13.0")
+ before_all_prepared = prepare_command(
+ before_all_options.before_all,
+ project=".",
+ package=before_all_options.package_dir,
+ )
+ shell(before_all_prepared, env=env)
+
+ built_wheels: list[Path] = []
+
+ for config in python_configurations:
+ build_options = options.build_options(config.identifier)
+ build_frontend = build_options.build_frontend or BuildFrontendConfig("pip")
+ # uv doesn't support iOS
+ if build_frontend.name == "build[uv]":
+ msg = "uv doesn't support iOS"
+ raise errors.FatalError(msg)
+
+ log.build_start(config.identifier)
+
+ identifier_tmp_dir = tmp_path / config.identifier
+ identifier_tmp_dir.mkdir()
+ built_wheel_dir = identifier_tmp_dir / "built_wheel"
+
+ constraints_path = build_options.dependency_constraints.get_for_python_version(
+ version=config.version, tmp_dir=identifier_tmp_dir
+ )
+ dependency_constraint_flags: Sequence[PathOrStr] = (
+ ["-c", constraints_path] if constraints_path else []
+ )
+
+ target_install_path, env = setup_python(
+ identifier_tmp_dir / "build",
+ config,
+ dependency_constraint_flags,
+ build_options.environment,
+ build_frontend.name,
+ )
+ pip_version = get_pip_version(env)
+
+ compatible_wheel = find_compatible_wheel(built_wheels, config.identifier)
+ if compatible_wheel:
+ log.step_end()
+ print(
+ f"\nFound previously built wheel {compatible_wheel.name} "
+ f"that is compatible with {config.identifier}. "
+ "Skipping build step..."
+ )
+ test_wheel = compatible_wheel
+ else:
+ if build_options.before_build:
+ log.step("Running before_build...")
+ before_build_prepared = prepare_command(
+ build_options.before_build,
+ project=".",
+ package=build_options.package_dir,
+ )
+ shell(before_build_prepared, env=env)
+
+ log.step("Building wheel...")
+ built_wheel_dir.mkdir()
+
+ extra_flags = get_build_frontend_extra_flags(
+ build_frontend, build_options.build_verbosity, build_options.config_settings
+ )
+
+ build_env = env.copy()
+ build_env["VIRTUALENV_PIP"] = pip_version
+ if constraints_path:
+ combine_constraints(build_env, constraints_path, None)
+
+ if build_frontend.name == "pip":
+ # Path.resolve() is needed. Without it pip wheel may try to
+ # fetch package from pypi.org. See
+ # https://github.com/pypa/cibuildwheel/pull/369
+ call(
+ "python",
+ "-m",
+ "pip",
+ "wheel",
+ build_options.package_dir.resolve(),
+ f"--wheel-dir={built_wheel_dir}",
+ "--no-deps",
+ *extra_flags,
+ env=build_env,
+ )
+ elif build_frontend.name == "build":
+ call(
+ "python",
+ "-m",
+ "build",
+ build_options.package_dir,
+ "--wheel",
+ f"--outdir={built_wheel_dir}",
+ *extra_flags,
+ env=build_env,
+ )
+ else:
+ assert_never(build_frontend)
+
+ test_wheel = built_wheel = next(built_wheel_dir.glob("*.whl"))
+
+ if built_wheel.name.endswith("none-any.whl"):
+ raise errors.NonPlatformWheelError()
+
+ log.step_end()
+
+ if build_options.test_command and build_options.test_selector(config.identifier):
+ if not config.is_simulator:
+ log.step("Skipping tests on non-simulator SDK")
+ elif config.arch != os.uname().machine:
+ log.step("Skipping tests on non-native simulator architecture")
+ else:
+ if build_options.before_test:
+ before_test_prepared = prepare_command(
+ build_options.before_test,
+ project=".",
+ package=build_options.package_dir,
+ )
+ shell(before_test_prepared, env=env)
+
+ log.step("Setting up test harness...")
+ # Clone the testbed project into the build directory
+ testbed_path = identifier_tmp_dir / "testbed"
+ call(
+ "python",
+ target_install_path / "testbed",
+ "clone",
+ testbed_path,
+ env=build_env,
+ )
+
+ if not build_options.test_sources:
+ # iOS requires an explicit test-sources, as the project directory
+ # isn't visible on the simulator.
+
+ msg = "Testing on iOS requires a definition of test-sources."
+ raise errors.FatalError(msg)
+
+ # Copy the test sources to the testbed app
+ copy_test_sources(
+ build_options.test_sources,
+ build_options.package_dir,
+ testbed_path / "iOSTestbed" / "app",
+ )
+
+ log.step("Installing test requirements...")
+ # Install the compiled wheel (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 = build_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=build_env,
+ )
+
+ log.step("Running test suite...")
+ try:
+ call(
+ "python",
+ testbed_path,
+ "run",
+ *(["--verbose"] if build_options.build_verbosity > 0 else []),
+ "--",
+ *(shlex.split(build_options.test_command)),
+ env=build_env,
+ )
+ failed = False
+ except subprocess.CalledProcessError:
+ failed = True
+
+ log.step_end(success=not failed)
+
+ if failed:
+ log.error(f"Test suite failed on {config.identifier}")
+ sys.exit(1)
+
+ # We're all done here; move it to output (overwrite existing)
+ if compatible_wheel is None:
+ output_wheel = build_options.output_dir.joinpath(built_wheel.name)
+ moved_wheel = move_file(built_wheel, output_wheel)
+ if moved_wheel != output_wheel.resolve():
+ log.warning(
+ f"{built_wheel} was moved to {moved_wheel} instead of {output_wheel}"
+ )
+ built_wheels.append(output_wheel)
+
+ # Clean up
+ shutil.rmtree(identifier_tmp_dir)
+
+ log.build_end()
+ 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/logger.py b/cibuildwheel/logger.py
index ec50da8ab..391786a04 100644
--- a/cibuildwheel/logger.py
+++ b/cibuildwheel/logger.py
@@ -35,6 +35,9 @@
"macosx_universal2": "macOS Universal 2 - x86_64 and arm64",
"macosx_arm64": "macOS arm64 - Apple Silicon",
"pyodide_wasm32": "Pyodide",
+ "ios_arm64_iphoneos": "iOS Device (ARM64)",
+ "ios_arm64_iphonesimulator": "iOS Simulator (ARM64)",
+ "ios_x86_64_iphonesimulator": "iOS Simulator (x86_64)",
}
diff --git a/cibuildwheel/resources/build-platforms.toml b/cibuildwheel/resources/build-platforms.toml
index e3078f991..b5fa071a2 100644
--- a/cibuildwheel/resources/build-platforms.toml
+++ b/cibuildwheel/resources/build-platforms.toml
@@ -163,3 +163,10 @@ python_configurations = [
python_configurations = [
{ identifier = "cp312-pyodide_wasm32", version = "3.12", pyodide_version = "0.27.0", pyodide_build_version = "0.29.2", emscripten_version = "3.1.58", node_version = "v20" },
]
+
+[ios]
+python_configurations = [
+ { identifier = "cp313-ios_arm64_iphoneos", version = "3.13", url = "https://github.com/beeware/Python-Apple-support/releases/download/3.13-b5/Python-3.13-iOS-support.b5.tar.gz" },
+ { identifier = "cp313-ios_x86_64_iphonesimulator", version = "3.13", url = "https://github.com/beeware/Python-Apple-support/releases/download/3.13-b5/Python-3.13-iOS-support.b5.tar.gz" },
+ { identifier = "cp313-ios_arm64_iphonesimulator", version = "3.13", url = "https://github.com/beeware/Python-Apple-support/releases/download/3.13-b5/Python-3.13-iOS-support.b5.tar.gz" },
+]
diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json
index 2a811d2fe..4a84606cf 100644
--- a/cibuildwheel/resources/cibuildwheel.schema.json
+++ b/cibuildwheel/resources/cibuildwheel.schema.json
@@ -956,6 +956,51 @@
"$ref": "#/properties/test-requires"
}
}
+ },
+ "ios": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "archs": {
+ "$ref": "#/properties/archs"
+ },
+ "before-all": {
+ "$ref": "#/properties/before-all"
+ },
+ "before-build": {
+ "$ref": "#/properties/before-build"
+ },
+ "before-test": {
+ "$ref": "#/properties/before-test"
+ },
+ "build-frontend": {
+ "$ref": "#/properties/build-frontend"
+ },
+ "build-verbosity": {
+ "$ref": "#/properties/build-verbosity"
+ },
+ "config-settings": {
+ "$ref": "#/properties/config-settings"
+ },
+ "dependency-versions": {
+ "$ref": "#/properties/dependency-versions"
+ },
+ "environment": {
+ "$ref": "#/properties/environment"
+ },
+ "repair-wheel-command": {
+ "$ref": "#/properties/repair-wheel-command"
+ },
+ "test-command": {
+ "$ref": "#/properties/test-command"
+ },
+ "test-extras": {
+ "$ref": "#/properties/test-extras"
+ },
+ "test-requires": {
+ "$ref": "#/properties/test-requires"
+ }
+ }
}
}
}
diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml
index b8e86c731..6b65e6369 100644
--- a/cibuildwheel/resources/defaults.toml
+++ b/cibuildwheel/resources/defaults.toml
@@ -51,4 +51,6 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest
[tool.cibuildwheel.windows]
+[tool.cibuildwheel.ios]
+
[tool.cibuildwheel.pyodide]
diff --git a/cibuildwheel/typing.py b/cibuildwheel/typing.py
index 87923c1e5..420ef05f6 100644
--- a/cibuildwheel/typing.py
+++ b/cibuildwheel/typing.py
@@ -12,7 +12,7 @@
PathOrStr = str | os.PathLike[str]
-PlatformName = Literal["linux", "macos", "windows", "pyodide"]
+PlatformName = Literal["linux", "macos", "windows", "pyodide", "ios"]
PLATFORMS: Final[frozenset[PlatformName]] = frozenset(typing.get_args(PlatformName))
diff --git a/cibuildwheel/util/packaging.py b/cibuildwheel/util/packaging.py
index 2c8795b8d..f6619dd92 100644
--- a/cibuildwheel/util/packaging.py
+++ b/cibuildwheel/util/packaging.py
@@ -134,7 +134,7 @@ def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None:
specified by `identifier` that is previously built.
"""
- interpreter, platform = identifier.split("-")
+ interpreter, platform = identifier.split("-", 1)
free_threaded = interpreter.endswith("t")
if free_threaded:
interpreter = interpreter[:-1]
@@ -157,8 +157,9 @@ def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None:
# If a minor version number is given, it has to be lower than the current one.
continue
- if platform.startswith(("manylinux", "musllinux", "macosx")):
- # Linux, macOS require the beginning and ending match (macos/manylinux version doesn't need to)
+ if platform.startswith(("manylinux", "musllinux", "macosx", "ios")):
+ # Linux, macOS, and iOS require the beginning and ending match
+ # (macos/manylinux/iOS version number doesn't need to match)
os_, arch = platform.split("_", 1)
if not tag.platform.startswith(os_):
continue
diff --git a/docs/contributing.md b/docs/contributing.md
index 237708999..4eea79599 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -36,7 +36,7 @@ So, if we can, I'd like to improve the experience on errors as well. In [this](h
### Running the tests
-When making a change to the codebase, you can run tests locally for quicker feedback than the CI runs on a PR. You can [run them directly](#making-a-venv), but the easiest way to run tests is using [nox](https://nox.thea.codes/).
+When making a change to the codebase, you can run tests locally for quicker feedback than the CI runs on a PR. You can run them directly, but the easiest way to run tests is using [nox](https://nox.thea.codes/).
You can run all the tests locally by doing:
diff --git a/docs/options.md b/docs/options.md
index 0dfaf0a72..080c66bb8 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -247,7 +247,7 @@ environment variables will completely override any TOML configuration.
> Override the auto-detected target platform
-Options: `auto` `linux` `macos` `windows` `pyodide`
+Options: `auto` `linux` `macos` `windows` `ios` `pyodide`
Default: `auto`
@@ -255,6 +255,7 @@ Default: `auto`
- For `linux`, you need [Docker or Podman](#container-engine) running, on Linux, macOS, or Windows.
- For `macos` and `windows`, you need to be running on the respective system, with a working compiler toolchain installed - Xcode Command Line tools for macOS, and MSVC for Windows.
+- For `ios` you need to be running on macOS, with Xcode and the iOS simulator installed.
- For `pyodide` you need to be on an x86-64 linux runner and `python3.12` must be available in `PATH`.
This option can also be set using the [command-line option](#command-line) `--platform`. This option is not available in the `pyproject.toml` config.
@@ -288,34 +289,22 @@ When setting the options, you can use shell-style globbing syntax, as per [fnmat