Skip to content

Commit 26e1229

Browse files
freakboy3742mayeutjoerick
authored
feat: add support for building iOS wheels. (#2286)
* Add support for building iOS wheels. * Replace use of system() in test binary module. * Restored the 'minimal' approach of the minimal examples. * Split out platform details into standalone pages, and expand iOS platform details. * More doc corrections. * Bump support package to include fix for python/cpython#130292 * Ensure iOS tests are all run on the same xdist worker. * More iOS documentation tweaks. * Factor out common xcode version test utility. * Simplify iOS to a single platform with an expanded interpretation of arch. * I guess I should update the iOS tests as well... * Additional safety for missing iOS test output. * Remove DYLD_LIBRARY_PATH from the iOS environment. * Make test-sources mandatory for iOS builds. * Updates and clarifications to documentation. * Clarify what a slice is. * Normalize use of underscores in platform name. * Modify auto target to be matching CPU only. * Use consistent ordering of platforms in examples. * Use consistent naming in iOS archiectures. * Placate the linter. * Miscellaneous cleanups picked up by @joerick's review. * Correct the list of expected wheels. * Correct which 'native' we're actually checking. * Correct the docs links so they're all relative. * Correct the identification of free threaded builds. * Use target instead of host to describe the platform we're building for. * Rework iOS test to remove issue with log completeness. * Convert errors to FatalError Co-authored-by: Matthieu Darbois <[email protected]> Co-authored-by: Joe Rickerby <[email protected]> * Removed a repeated check for a valid python. * Update bin/update_pythons.py to update iOS support packages. * Document that iOS CI is available on other platforms. * Restore a comment needed for some platforms. * Small cleanups identified in code review Co-authored-by: Joe Rickerby <[email protected]> * Simplify logic to appease linter. * Modify dependency constraint handling to use new API. * Cosmetic change to trigger a CI rebuild. --------- Co-authored-by: Matthieu Darbois <[email protected]> Co-authored-by: Joe Rickerby <[email protected]>
1 parent eefd48e commit 26e1229

31 files changed

+1189
-240
lines changed

README.md

+26-22
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,18 @@ What does it do?
2424

2525
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:
2626

27-
| | macOS Intel | macOS Apple Silicon | Windows 64bit | Windows 32bit | Windows Arm64 | manylinux<br/>musllinux x86_64 | manylinux<br/>musllinux i686 | manylinux<br/>musllinux aarch64 | manylinux<br/>musllinux ppc64le | manylinux<br/>musllinux s390x | manylinux<br/>musllinux armv7l | Pyodide |
28-
|----------------|----|-----|-----|-----|-----|----|-----|----|-----|-----|---|-----|
29-
| CPython 3.8 ||||| N/A |||||| ✅⁵ | N/A |
30-
| CPython 3.9 ||||| ✅² |||||| ✅⁵ | N/A |
31-
| CPython 3.10 ||||| ✅² |||||| ✅⁵ | N/A |
32-
| CPython 3.11 ||||| ✅² |||||| ✅⁵ | N/A |
33-
| CPython 3.12 ||||| ✅² |||||| ✅⁵ | ✅⁴ |
34-
| CPython 3.13³ ||||| ✅² |||||| ✅⁵ | N/A |
35-
| PyPy 3.8 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
36-
| PyPy 3.9 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
37-
| PyPy 3.10 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
38-
| PyPy 3.11 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A |
27+
| | macOS Intel | macOS Apple Silicon | Windows 64bit | Windows 32bit | Windows Arm64 | manylinux<br/>musllinux x86_64 | manylinux<br/>musllinux i686 | manylinux<br/>musllinux aarch64 | manylinux<br/>musllinux ppc64le | manylinux<br/>musllinux s390x | manylinux<br/>musllinux armv7l | iOS | Pyodide |
28+
|----------------|----|-----|-----|-----|-----|----|-----|----|-----|-----|---|-----|-----|
29+
| CPython 3.8 ||||| N/A |||||| ✅⁵ | N/A | N/A |
30+
| CPython 3.9 ||||| ✅² |||||| ✅⁵ | N/A | N/A |
31+
| CPython 3.10 ||||| ✅² |||||| ✅⁵ | N/A | N/A |
32+
| CPython 3.11 ||||| ✅² |||||| ✅⁵ | N/A | N/A |
33+
| CPython 3.12 ||||| ✅² |||||| ✅⁵ | N/A | ✅⁴ |
34+
| CPython 3.13³ ||||| ✅² |||||| ✅⁵ | | N/A |
35+
| PyPy 3.8 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
36+
| PyPy 3.9 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
37+
| PyPy 3.10 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
38+
| PyPy 3.11 v7.3 |||| N/A | N/A | ✅¹ | ✅¹ | ✅¹ | N/A | N/A | N/A | N/A | N/A |
3939

4040
<sup>¹ PyPy is only supported for manylinux wheels.</sup><br>
4141
<sup>² Windows arm64 support is experimental.</sup><br>
@@ -55,18 +55,19 @@ Usage
5555

5656
`cibuildwheel` runs inside a CI service. Supported platforms depend on which service you're using:
5757

58-
| | Linux | macOS | Windows | Linux ARM | macOS ARM | Windows ARM |
59-
|-----------------|-------|-------|---------|-----------|-----------|-------------|
60-
| GitHub Actions |||||| ✅² |
61-
| Azure Pipelines |||| || ✅² |
62-
| Travis CI || ||| | |
63-
| AppVeyor |||| || ✅² |
64-
| CircleCI ||| ||| |
65-
| Gitlab CI |||| ✅¹ || |
66-
| Cirrus CI |||||| |
58+
| | Linux | macOS | Windows | Linux ARM | macOS ARM | Windows ARM | iOS |
59+
|-----------------|-------|-------|---------|-----------|-----------|-------------|-----|
60+
| GitHub Actions |||||| ✅² | ✅³ |
61+
| Azure Pipelines |||| || ✅² | ✅³ |
62+
| Travis CI || ||| | | |
63+
| AppVeyor |||| || ✅² | ✅³ |
64+
| CircleCI ||| ||| | ✅³ |
65+
| Gitlab CI |||| ✅¹ || | ✅³ |
66+
| Cirrus CI |||||| | ✅³ |
6767

6868
<sup[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.</sup><br>
69-
<sup[Uses cross-compilation](https://cibuildwheel.pypa.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform.</sup>
69+
<sup[Uses cross-compilation](https://cibuildwheel.pypa.io/en/stable/faq/#windows-arm64). It is not possible to test `arm64` on this CI platform.</sup><br>
70+
<sup>³ Requires a macOS runner; runs tests on the simulator for the runner's architecture.</sup>
7071

7172
<!--intro-end-->
7273

@@ -75,6 +76,7 @@ Example setup
7576

7677
To build manylinux, musllinux, macOS, and Windows wheels on GitHub Actions, you could use this `.github/workflows/wheels.yml`:
7778

79+
<!--generic-github-start-->
7880
```yaml
7981
name: Build
8082

@@ -102,12 +104,14 @@ jobs:
102104
# to supply options, put them in 'env', like:
103105
# env:
104106
# CIBW_SOME_OPTION: value
107+
# ...
105108

106109
- uses: actions/upload-artifact@v4
107110
with:
108111
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
109112
path: ./wheelhouse/*.whl
110113
```
114+
<!--generic-github-end-->
111115
112116
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).
113117

bin/generate_schema.py

+1
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ def as_object(d: dict[str, Any]) -> dict[str, Any]:
322322
"windows": as_object(not_linux),
323323
"macos": as_object(not_linux),
324324
"pyodide": as_object(not_linux),
325+
"ios": as_object(not_linux),
325326
}
326327

327328
oses["linux"]["properties"]["repair-wheel-command"] = {

bin/run_tests.py

+3
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@
4141
sys.executable,
4242
"-m",
4343
"pytest",
44+
"--dist",
45+
"loadgroup",
4446
f"--numprocesses={args.num_processes}",
4547
"-x",
4648
"--durations",
4749
"0",
4850
"--timeout=2400",
4951
"test",
52+
"-vv",
5053
]
5154

5255
if sys.platform.startswith("linux") and args.run_podman:

bin/update_pythons.py

+55-6
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ class ConfigWinPP(TypedDict):
4444
url: str
4545

4646

47-
class ConfigMacOS(TypedDict):
47+
class ConfigApple(TypedDict):
4848
identifier: str
4949
version: str
5050
url: str
5151

5252

53-
AnyConfig = ConfigWinCP | ConfigWinPP | ConfigMacOS
53+
AnyConfig = ConfigWinCP | ConfigWinPP | ConfigApple
5454

5555

5656
# 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:
154154
url=url,
155155
)
156156

157-
def update_version_macos(self, spec: Specifier) -> ConfigMacOS:
157+
def update_version_macos(self, spec: Specifier) -> ConfigApple:
158158
if self.arch not in {"64", "ARM64"}:
159159
msg = f"'{self.arch}' arch not supported yet on macOS"
160160
raise RuntimeError(msg)
@@ -178,7 +178,7 @@ def update_version_macos(self, spec: Specifier) -> ConfigMacOS:
178178
if "" in rf["platform"] == "darwin" and rf["arch"] == arch
179179
)
180180

181-
return ConfigMacOS(
181+
return ConfigApple(
182182
identifier=identifier,
183183
version=f"{version.major}.{version.minor}",
184184
url=url,
@@ -204,7 +204,7 @@ def __init__(self) -> None:
204204

205205
def update_version_macos(
206206
self, identifier: str, version: Version, spec: Specifier
207-
) -> ConfigMacOS | None:
207+
) -> ConfigApple | None:
208208
# see note above on Specifier.filter
209209
unsorted_versions = spec.filter(self.versions_dict)
210210
sorted_versions = sorted(unsorted_versions, reverse=True)
@@ -223,7 +223,7 @@ def update_version_macos(
223223

224224
urls = [rf["url"] for rf in file_info if file_ident in rf["url"]]
225225
if urls:
226-
return ConfigMacOS(
226+
return ConfigApple(
227227
identifier=identifier,
228228
version=f"{new_version.major}.{new_version.minor}",
229229
url=urls[0],
@@ -232,6 +232,48 @@ def update_version_macos(
232232
return None
233233

234234

235+
class CPythonIOSVersions:
236+
def __init__(self) -> None:
237+
response = requests.get(
238+
"https://api.github.com/repos/beeware/Python-Apple-support/releases",
239+
headers={
240+
"Accept": "application/vnd.github+json",
241+
"X-Github-Api-Version": "2022-11-28",
242+
},
243+
)
244+
response.raise_for_status()
245+
246+
releases_info = response.json()
247+
self.versions_dict: dict[Version, dict[int, str]] = {}
248+
249+
# Each release has a name like "3.13-b4"
250+
for release in releases_info:
251+
py_version, build = release["name"].split("-")
252+
version = Version(py_version)
253+
self.versions_dict.setdefault(version, {})
254+
255+
# There are several release assets associated with each release;
256+
# The name of the asset will be something like
257+
# "Python-3.11-iOS-support.b4.tar.gz". Store all builds that are
258+
# "-iOS-support" builds, retaining the download URL.
259+
for asset in release["assets"]:
260+
filename, build, _, _ = asset["name"].rsplit(".", 3)
261+
if filename.endswith("-iOS-support"):
262+
self.versions_dict[version][int(build[1:])] = asset["browser_download_url"]
263+
264+
def update_version_ios(self, identifier: str, version: Version) -> ConfigApple | None:
265+
# Return a config using the highest build number for the given version.
266+
urls = [url for _, url in sorted(self.versions_dict.get(version, {}).items())]
267+
if urls:
268+
return ConfigApple(
269+
identifier=identifier,
270+
version=str(version),
271+
url=urls[-1],
272+
)
273+
274+
return None
275+
276+
235277
# This is a universal interface to all the above Versions classes. Given an
236278
# identifier, it updates a config dict.
237279

@@ -250,6 +292,8 @@ def __init__(self) -> None:
250292
self.macos_pypy = PyPyVersions("64")
251293
self.macos_pypy_arm64 = PyPyVersions("ARM64")
252294

295+
self.ios_cpython = CPythonIOSVersions()
296+
253297
def update_config(self, config: MutableMapping[str, str]) -> None:
254298
identifier = config["identifier"]
255299
version = Version(config["version"])
@@ -282,6 +326,8 @@ def update_config(self, config: MutableMapping[str, str]) -> None:
282326
config_update = self.windows_t_arm64.update_version_windows(spec)
283327
elif "win_arm64" in identifier and identifier.startswith("cp"):
284328
config_update = self.windows_arm64.update_version_windows(spec)
329+
elif "ios" in identifier:
330+
config_update = self.ios_cpython.update_version_ios(identifier, version)
285331

286332
assert config_update is not None, f"{identifier} not found!"
287333
config.update(**config_update)
@@ -317,6 +363,9 @@ def update_pythons(force: bool, level: str) -> None:
317363
for config in configs["macos"]["python_configurations"]:
318364
all_versions.update_config(config)
319365

366+
for config in configs["ios"]["python_configurations"]:
367+
all_versions.update_config(config)
368+
320369
result_toml = dump_python_configurations(configs)
321370

322371
rich.print() # spacer

cibuildwheel/__main__.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any, Protocol, TextIO, assert_never
1616

1717
import cibuildwheel
18+
import cibuildwheel.ios
1819
import cibuildwheel.linux
1920
import cibuildwheel.macos
2021
import cibuildwheel.pyodide
@@ -93,13 +94,14 @@ def main_inner(global_options: GlobalOptions) -> None:
9394

9495
parser.add_argument(
9596
"--platform",
96-
choices=["auto", "linux", "macos", "windows", "pyodide"],
97+
choices=["auto", "linux", "macos", "windows", "pyodide", "ios"],
9798
default=None,
9899
help="""
99-
Platform to build for. Use this option to override the
100-
auto-detected platform. Specifying "macos" or "windows" only works
101-
on that operating system, but "linux" works on all three, as long
102-
as Docker/Podman is installed. Default: auto.
100+
Platform to build for. Use this option to override the auto-detected
101+
platform. Specifying "macos" or "windows" only works on that
102+
operating system. "linux" works on any desktop OS, as long as
103+
Docker/Podman is installed. "pyodide" only works on linux and macOS.
104+
"ios" only work on macOS. Default: auto.
103105
""",
104106
)
105107

@@ -240,6 +242,8 @@ def _compute_platform_only(only: str) -> PlatformName:
240242
return "windows"
241243
if "pyodide_" in only:
242244
return "pyodide"
245+
if "ios_" in only:
246+
return "ios"
243247
msg = f"Invalid --only='{only}', must be a build selector with a known platform"
244248
raise errors.ConfigurationError(msg)
245249

@@ -301,6 +305,8 @@ def get_platform_module(platform: PlatformName) -> PlatformModule:
301305
return cibuildwheel.macos
302306
if platform == "pyodide":
303307
return cibuildwheel.pyodide
308+
if platform == "ios":
309+
return cibuildwheel.ios
304310
assert_never(platform)
305311

306312

cibuildwheel/architecture.py

+31-5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"macos": "macOS",
1818
"windows": "Windows",
1919
"pyodide": "Pyodide",
20+
"ios": "iOS",
2021
}
2122

2223
ARCH_SYNONYMS: Final[list[dict[PlatformName, str | None]]] = [
@@ -63,6 +64,12 @@ class Architecture(StrEnum):
6364
# WebAssembly
6465
wasm32 = auto()
6566

67+
# iOS "multiarch" architectures that include both
68+
# the CPU architecture and the ABI.
69+
arm64_iphoneos = auto()
70+
arm64_iphonesimulator = auto()
71+
x86_64_iphonesimulator = auto()
72+
6673
@staticmethod
6774
def parse_config(config: str, platform: PlatformName) -> "set[Architecture]":
6875
result = set()
@@ -89,8 +96,8 @@ def parse_config(config: str, platform: PlatformName) -> "set[Architecture]":
8996

9097
@staticmethod
9198
def native_arch(platform: PlatformName) -> "Architecture | None":
92-
if platform == "pyodide":
93-
return Architecture.wasm32
99+
native_machine = platform_module.machine()
100+
native_architecture = Architecture(native_machine)
94101

95102
# Cross-platform support. Used for --print-build-identifiers or docker builds.
96103
host_platform: PlatformName = (
@@ -99,8 +106,18 @@ def native_arch(platform: PlatformName) -> "Architecture | None":
99106
else ("macos" if sys.platform.startswith("darwin") else "linux")
100107
)
101108

102-
native_machine = platform_module.machine()
103-
native_architecture = Architecture(native_machine)
109+
if platform == "pyodide":
110+
return Architecture.wasm32
111+
elif platform == "ios":
112+
# Can only build for iOS on macOS. The "native" architecture is the
113+
# simulator for the macOS native platform.
114+
if host_platform == "macos":
115+
if native_architecture == Architecture.x86_64:
116+
return Architecture.x86_64_iphonesimulator
117+
else:
118+
return Architecture.arm64_iphonesimulator
119+
else:
120+
return None
104121

105122
# we might need to rename the native arch to the machine we're running
106123
# on, as the same arch can have different names on different platforms
@@ -131,9 +148,13 @@ def auto_archs(platform: PlatformName) -> "set[Architecture]":
131148
elif Architecture.aarch64 in result and _check_aarch32_el0():
132149
result.add(Architecture.armv7l)
133150

134-
if platform == "windows" and Architecture.AMD64 in result:
151+
elif platform == "windows" and Architecture.AMD64 in result:
135152
result.add(Architecture.x86)
136153

154+
elif platform == "ios" and native_arch == Architecture.arm64_iphonesimulator:
155+
# Also build the device wheel if we're on ARM64.
156+
result.add(Architecture.arm64_iphoneos)
157+
137158
return result
138159

139160
@staticmethod
@@ -150,6 +171,11 @@ def all_archs(platform: PlatformName) -> "set[Architecture]":
150171
"macos": {Architecture.x86_64, Architecture.arm64, Architecture.universal2},
151172
"windows": {Architecture.x86, Architecture.AMD64, Architecture.ARM64},
152173
"pyodide": {Architecture.wasm32},
174+
"ios": {
175+
Architecture.x86_64_iphonesimulator,
176+
Architecture.arm64_iphonesimulator,
177+
Architecture.arm64_iphoneos,
178+
},
153179
}
154180
return all_archs_map[platform]
155181

0 commit comments

Comments
 (0)