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 <mayeut@users.noreply.github.com> Co-authored-by: Joe Rickerby <joerick@mac.com> * 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 <joerick@mac.com> * 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 <mayeut@users.noreply.github.com> Co-authored-by: Joe Rickerby <joerick@mac.com>
This commit is contained in:
co-authored by
Matthieu Darbois
Joe Rickerby
parent
eefd48e266
commit
26e12290b5
@@ -15,6 +15,7 @@ from tempfile import mkdtemp
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ PRETTY_NAMES: Final[dict[PlatformName, str]] = {
|
||||
"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 @@ class Architecture(StrEnum):
|
||||
|
||||
@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 @@ class Architecture(StrEnum):
|
||||
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 @@ class Architecture(StrEnum):
|
||||
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 @@ class Architecture(StrEnum):
|
||||
"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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +35,9 @@ PLATFORM_IDENTIFIER_DESCRIPTIONS: Final[dict[str, str]] = {
|
||||
"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)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +51,6 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest
|
||||
|
||||
[tool.cibuildwheel.windows]
|
||||
|
||||
[tool.cibuildwheel.ios]
|
||||
|
||||
[tool.cibuildwheel.pyodide]
|
||||
|
||||
@@ -12,7 +12,7 @@ __all__ = (
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user