| #!/usr/bin/env python3 | |
| ########################################################################## | |
| # Apple XCframework build script | |
| # | |
| # This script simplifies the process of configuring, compiling and packaging an | |
| # XCframework for an Apple platform. | |
| # | |
| # At present, it only supports iOS, but it has been constructed so that it | |
| # could be used on any Apple platform. | |
| # | |
| # The simplest entry point is: | |
| # | |
| # $ python Platforms/Apple ci iOS | |
| # | |
| # which will: | |
| # * Clean any pre-existing build artefacts | |
| # * Configure and make a Python that can be used for the build | |
| # * Configure and make a Python for each supported iOS architecture and ABI | |
| # * Combine the outputs of the builds from the previous step into a single | |
| # XCframework, merging binaries into a "fat" binary if necessary | |
| # * Clone a copy of the testbed, configured to use the XCframework | |
| # * Construct a tarball containing the release artefacts | |
| # * Run the test suite using the generated XCframework. | |
| # | |
| # This is the complete sequence that would be needed in CI to build and test | |
| # a candidate release artefact. | |
| # | |
| # Each individual step can be invoked individually - there are commands to | |
| # clean, configure-build, make-build, configure-host, make-host, package, and | |
| # test. | |
| # | |
| # There is also a build command that can be used to combine the configure and | |
| # make steps for the build Python, an individual host, all hosts, or all | |
| # builds. | |
| ########################################################################## | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import platform | |
| import re | |
| import shlex | |
| import shutil | |
| import signal | |
| import subprocess | |
| import sys | |
| import sysconfig | |
| import time | |
| from collections.abc import Callable, Sequence | |
| from contextlib import contextmanager | |
| from datetime import datetime, timezone | |
| from os.path import basename, relpath | |
| from pathlib import Path | |
| from subprocess import CalledProcessError | |
| EnvironmentT = dict[str, str] | |
| ArgsT = Sequence[str | Path] | |
| SCRIPT_NAME = Path(__file__).name | |
| PYTHON_DIR = Path(__file__).resolve().parent.parent.parent | |
| CROSS_BUILD_DIR = PYTHON_DIR / "cross-build" | |
| HOSTS: dict[str, dict[str, dict[str, str]]] = { | |
| # Structure of this data: | |
| # * Platform identifier | |
| # * an XCframework slice that must exist for that platform | |
| # * a host triple: the multiarch spec for that host | |
| "iOS": { | |
| "ios-arm64": { | |
| "arm64-apple-ios": "arm64-iphoneos", | |
| }, | |
| "ios-arm64_x86_64-simulator": { | |
| "arm64-apple-ios-simulator": "arm64-iphonesimulator", | |
| "x86_64-apple-ios-simulator": "x86_64-iphonesimulator", | |
| }, | |
| }, | |
| } | |
| # The configure arguments needed to produce each ABI flag. Flags are listed in | |
| # the order that configure adds them to ABIFLAGS; any `--abiflags` value must | |
| # use the same order. | |
| ABIFLAG_CONFIGURE_ARGS: dict[str, list[str]] = { | |
| "t": ["--disable-gil"], | |
| "d": ["--with-pydebug"], | |
| } | |
| def validate_abiflags(value: str) -> str: | |
| """Validate an `--abiflags` value (for use as an argparse type).""" | |
| known = "".join(ABIFLAG_CONFIGURE_ARGS) | |
| for flag in value: | |
| if flag not in ABIFLAG_CONFIGURE_ARGS: | |
| raise argparse.ArgumentTypeError( | |
| f"unknown ABI flag {flag!r} (known flags: {known!r})" | |
| ) | |
| positions = [known.index(flag) for flag in value] | |
| if positions != sorted(set(positions)): | |
| raise argparse.ArgumentTypeError( | |
| f"ABI flags must be unique, and in the order {known!r}" | |
| ) | |
| return value | |
| def framework_name(abiflags: str) -> str: | |
| """The name of the Python framework for the given ABI flags.""" | |
| return f"Python{abiflags.upper()}" | |
| def abiflag_configure_args(abiflags: str) -> list[str]: | |
| """The configure arguments needed to produce the given ABI flags.""" | |
| return [arg for flag in abiflags for arg in ABIFLAG_CONFIGURE_ARGS[flag]] | |
| def cross_build_dir(base: Path, abiflags: str) -> Path: | |
| """The cross-build directory for the given base and ABI flags.""" | |
| return base.with_name(f"{base.name}-{abiflags}") if abiflags else base | |
| def subdir(name: str, create: bool = False) -> Path: | |
| """Ensure that a cross-build directory for the given name exists.""" | |
| path = CROSS_BUILD_DIR / name | |
| if not path.exists(): | |
| if not create: | |
| sys.exit( | |
| f"{path} does not exist. Create it by running the appropriate " | |
| f"`configure` subcommand of {SCRIPT_NAME}." | |
| ) | |
| else: | |
| path.mkdir(parents=True) | |
| return path | |
| def run( | |
| command: ArgsT, | |
| *, | |
| host: str | None = None, | |
| env: EnvironmentT | None = None, | |
| log: bool | None = True, | |
| **kwargs, | |
| ) -> subprocess.CompletedProcess: | |
| """Run a command in an Apple development environment. | |
| Optionally logs the executed command to the console. | |
| """ | |
| kwargs.setdefault("check", True) | |
| if env is None: | |
| env = os.environ.copy() | |
| if host: | |
| host_env = apple_env(host) | |
| print_env(host_env) | |
| env.update(host_env) | |
| if log: | |
| print(">", join_command(command)) | |
| return subprocess.run(command, env=env, **kwargs) | |
| def join_command(args: str | Path | ArgsT) -> str: | |
| """Format a command so it can be copied into a shell. | |
| Similar to `shlex.join`, but also accepts arguments which are Paths, or a | |
| single string/Path outside of a list. | |
| """ | |
| if isinstance(args, (str, Path)): | |
| return str(args) | |
| else: | |
| return shlex.join(map(str, args)) | |
| def print_env(env: EnvironmentT) -> None: | |
| """Format the environment so it can be pasted into a shell.""" | |
| for key, value in sorted(env.items()): | |
| print(f"export {key}={shlex.quote(value)}") | |
| def apple_env(host: str) -> EnvironmentT: | |
| """Construct an Apple development environment for the given host.""" | |
| env = { | |
| "PATH": ":".join([ | |
| str(PYTHON_DIR / "Platforms/Apple/iOS/Resources/bin"), | |
| str(subdir(host) / "prefix"), | |
| "/usr/bin", | |
| "/bin", | |
| "/usr/sbin", | |
| "/sbin", | |
| "/Library/Apple/usr/bin", | |
| ]), | |
| } | |
| return env | |
| def delete_path(name: str) -> None: | |
| """Delete the named cross-build directory, if it exists.""" | |
| path = CROSS_BUILD_DIR / name | |
| if path.exists(): | |
| print(f"Deleting {path} ...") | |
| shutil.rmtree(path) | |
| def all_host_triples(platform: str) -> list[str]: | |
| """Return all host triples for the given platform. | |
| The host triples are the platform definitions used as input to configure | |
| (e.g., "arm64-apple-ios-simulator"). | |
| """ | |
| triples = [] | |
| for slice_name, slice_parts in HOSTS[platform].items(): | |
| triples.extend(list(slice_parts)) | |
| return triples | |
| def clean(context: argparse.Namespace, target: str | None = None) -> None: | |
| """The implementation of the "clean" command.""" | |
| if target is None: | |
| target = context.host | |
| # If we're explicitly targeting the build, there's no platform or | |
| # distribution artefacts. If we're cleaning tests, we keep all built | |
| # artefacts. Otherwise, the built artefacts must be dirty, so we remove | |
| # them. | |
| if target not in {"build", "test"}: | |
| paths = ["dist", context.platform] + list(HOSTS[context.platform]) | |
| else: | |
| paths = [] | |
| if target in {"all", "build"}: | |
| paths.append("build") | |
| if target in {"all", "hosts"}: | |
| paths.extend(all_host_triples(context.platform)) | |
| elif target not in {"build", "test", "package"}: | |
| paths.append(target) | |
| if target in {"all", "hosts", "test"}: | |
| paths.extend([ | |
| path.name | |
| for path in CROSS_BUILD_DIR.glob(f"{context.platform}-testbed.*") | |
| ]) | |
| for path in paths: | |
| delete_path(path) | |
| def build_python_path() -> Path: | |
| """The path to the build Python binary.""" | |
| build_dir = subdir("build") | |
| binary = build_dir / "python" | |
| if not binary.is_file(): | |
| binary = binary.with_suffix(".exe") | |
| if not binary.is_file(): | |
| raise FileNotFoundError( | |
| f"Unable to find `python(.exe)` in {build_dir}" | |
| ) | |
| return binary | |
| @contextmanager | |
| def group(text: str): | |
| """A context manager that outputs a log marker around a section of a build. | |
| If running in a GitHub Actions environment, the GitHub syntax for | |
| collapsible log sections is used. | |
| """ | |
| if "GITHUB_ACTIONS" in os.environ: | |
| print(f"::group::{text}") | |
| else: | |
| print(f"===== {text} " + "=" * (70 - len(text))) | |
| yield | |
| if "GITHUB_ACTIONS" in os.environ: | |
| print("::endgroup::") | |
| else: | |
| print() | |
| @contextmanager | |
| def cwd(subdir: Path): | |
| """A context manager that sets the current working directory.""" | |
| orig = os.getcwd() | |
| os.chdir(subdir) | |
| yield | |
| os.chdir(orig) | |
| def configure_build_python(context: argparse.Namespace) -> None: | |
| """The implementation of the "configure-build" command.""" | |
| if context.clean: | |
| clean(context, "build") | |
| with ( | |
| group("Configuring build Python"), | |
| cwd(subdir("build", create=True)), | |
| ): | |
| command = [relpath(PYTHON_DIR / "configure")] | |
| command.extend(abiflag_configure_args(context.abiflags)) | |
| if context.args: | |
| command.extend(context.args) | |
| run(command) | |
| def make_build_python(context: argparse.Namespace) -> None: | |
| """The implementation of the "make-build" command.""" | |
| with ( | |
| group("Compiling build Python"), | |
| cwd(subdir("build")), | |
| ): | |
| run(["make", "-j", str(os.cpu_count())]) | |
| def pythoninfo_build_python(context: argparse.Namespace) -> None: | |
| """The implementation of the "pythoninfo-build" command.""" | |
| with ( | |
| group("Display build info of the build Python"), | |
| cwd(subdir("build")), | |
| ): | |
| run(["make", "pythoninfo"]) | |
| def apple_target(host: str) -> str: | |
| """Return the Apple platform identifier for a given host triple.""" | |
| for _, platform_slices in HOSTS.items(): | |
| for slice_name, slice_parts in platform_slices.items(): | |
| for host_triple, multiarch in slice_parts.items(): | |
| if host == host_triple: | |
| return ".".join(multiarch.split("-")[::-1]) | |
| raise KeyError(host) | |
| def apple_multiarch(host: str) -> str: | |
| """Return the multiarch descriptor for a given host triple.""" | |
| for _, platform_slices in HOSTS.items(): | |
| for slice_name, slice_parts in platform_slices.items(): | |
| for host_triple, multiarch in slice_parts.items(): | |
| if host == host_triple: | |
| return multiarch | |
| raise KeyError(host) | |
| def unpack_deps( | |
| platform: str, | |
| host: str, | |
| prefix_dir: Path, | |
| cache_dir: Path, | |
| ) -> None: | |
| """Unpack binary dependencies into a provided directory. | |
| Downloads binaries if they aren't already present. Downloads will be stored | |
| in provided cache directory. | |
| On iOS, as a safety mechanism, any dynamic libraries will be purged from | |
| the unpacked dependencies. | |
| """ | |
| # To create new builds of these dependencies, usually all that's necessary | |
| # is to push a tag to the cpython-apple-source-deps repository, and GitHub | |
| # Actions will do the rest. | |
| # | |
| # If you're a member of the Python core team, and you'd like to be able to | |
| # push these tags yourself, please contact Malcolm Smith or Russell | |
| # Keith-Magee. | |
| deps_url = "https://github.com/beeware/cpython-apple-source-deps/releases/download" | |
| for name_ver in [ | |
| "BZip2-1.0.8-2", | |
| "libFFI-3.4.7-2", | |
| "OpenSSL-3.5.9-1", | |
| "XZ-5.6.4-2", | |
| "mpdecimal-4.0.0-2", | |
| "zstd-1.5.7-1", | |
| ]: | |
| filename = f"{name_ver.lower()}-{apple_target(host)}.tar.gz" | |
| archive_path = download( | |
| f"{deps_url}/{name_ver}/{filename}", | |
| target_dir=cache_dir, | |
| ) | |
| shutil.unpack_archive(archive_path, prefix_dir) | |
| # Dynamic libraries will be preferentially linked over static; | |
| # On iOS, ensure that no dylibs are available in the prefix folder. | |
| if platform == "iOS": | |
| for dylib in prefix_dir.glob("**/*.dylib"): | |
| dylib.unlink() | |
| def download(url: str, target_dir: Path) -> Path: | |
| """Download the specified URL into the given directory. | |
| :return: The path to the downloaded archive. | |
| """ | |
| target_path = Path(target_dir).resolve() | |
| target_path.mkdir(exist_ok=True, parents=True) | |
| out_path = target_path / basename(url) | |
| if not Path(out_path).is_file(): | |
| run([ | |
| "curl", | |
| "-Lf", | |
| "--retry", | |
| "5", | |
| "--retry-all-errors", | |
| "-o", | |
| out_path, | |
| url, | |
| ]) | |
| else: | |
| print(f"Using cached version of {basename(url)}") | |
| return out_path | |
| def configure_host_python( | |
| context: argparse.Namespace, | |
| host: str | None = None, | |
| ) -> None: | |
| """The implementation of the "configure-host" command.""" | |
| if host is None: | |
| host = context.host | |
| if context.clean: | |
| clean(context, host) | |
| host_dir = subdir(host, create=True) | |
| prefix_dir = host_dir / "prefix" | |
| with group(f"Downloading dependencies ({host})"): | |
| if not prefix_dir.exists(): | |
| prefix_dir.mkdir() | |
| cache_dir = ( | |
| Path(context.cache_dir).resolve() | |
| if context.cache_dir | |
| else CROSS_BUILD_DIR / "downloads" | |
| ) | |
| unpack_deps(context.platform, host, prefix_dir, cache_dir) | |
| else: | |
| print("Dependencies already installed") | |
| with ( | |
| group(f"Configuring host Python ({host})"), | |
| cwd(host_dir), | |
| ): | |
| command = [ | |
| # Basic cross-compiling configuration | |
| relpath(PYTHON_DIR / "configure"), | |
| f"--host={host}", | |
| f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}", | |
| f"--with-build-python={build_python_path()}", | |
| "--enable-framework", | |
| # Dependent libraries. | |
| f"--with-openssl={prefix_dir}", | |
| f"LIBLZMA_CFLAGS=-I{prefix_dir}/include", | |
| f"LIBLZMA_LIBS=-L{prefix_dir}/lib -llzma", | |
| f"LIBFFI_CFLAGS=-I{prefix_dir}/include", | |
| f"LIBFFI_LIBS=-L{prefix_dir}/lib -lffi", | |
| f"LIBMPDEC_CFLAGS=-I{prefix_dir}/include", | |
| f"LIBMPDEC_LIBS=-L{prefix_dir}/lib -lmpdec", | |
| f"LIBZSTD_CFLAGS=-I{prefix_dir}/include", | |
| f"LIBZSTD_LIBS=-L{prefix_dir}/lib -lzstd", | |
| ] | |
| command.extend(abiflag_configure_args(context.abiflags)) | |
| if context.abiflags: | |
| command.append( | |
| f"--with-framework-name={framework_name(context.abiflags)}" | |
| ) | |
| if context.args: | |
| command.extend(context.args) | |
| run(command, host=host) | |
| def make_host_python( | |
| context: argparse.Namespace, | |
| host: str | None = None, | |
| ) -> None: | |
| """The implementation of the "make-host" command.""" | |
| if host is None: | |
| host = context.host | |
| with ( | |
| group(f"Compiling host Python ({host})"), | |
| cwd(subdir(host)), | |
| ): | |
| run(["make", "-j", str(os.cpu_count())], host=host) | |
| run(["make", "install"], host=host) | |
| def framework_path(host_triple: str, multiarch: str) -> Path: | |
| """The path to a built single-architecture framework product. | |
| :param host_triple: The host triple (e.g., arm64-apple-ios-simulator) | |
| :param multiarch: The multiarch identifier (e.g., arm64-simulator) | |
| """ | |
| return ( | |
| CROSS_BUILD_DIR | |
| / f"{host_triple}/Platforms/Apple/iOS/Frameworks/{multiarch}" | |
| ) | |
| def package_version(prefix_path: Path) -> str: | |
| """Extract the Python version being built from patchlevel.h.""" | |
| for path in prefix_path.glob("**/patchlevel.h"): | |
| text = path.read_text(encoding="utf-8") | |
| if match := re.search( | |
| r'\n\s*#define\s+PY_VERSION\s+"(.+)"\s*\n', text | |
| ): | |
| version = match[1] | |
| # If not building against a tagged commit, add a timestamp to the | |
| # version. Follow the PyPA version number rules, as this will make | |
| # it easier to process with other tools. The version will have a | |
| # `+` suffix once any official release has been made; a freshly | |
| # forked main branch will have a version of 3.X.0a0. | |
| if version.endswith("a0"): | |
| version += "+" | |
| if version.endswith("+"): | |
| version += datetime.now(timezone.utc).strftime("%Y%m%d.%H%M%S") | |
| return version | |
| sys.exit("Unable to determine Python version being packaged.") | |
| def lib_platform_files(dirname, names): | |
| """A file filter that ignores platform-specific files in lib.""" | |
| path = Path(dirname) | |
| if ( | |
| path.parts[-3] == "lib" | |
| and path.parts[-2].startswith("python") | |
| and path.parts[-1] == "lib-dynload" | |
| ): | |
| return names | |
| elif path.parts[-2] == "lib" and path.parts[-1].startswith("python"): | |
| ignored_names = { | |
| name | |
| for name in names | |
| if ( | |
| name.startswith("_sysconfigdata_") | |
| or name.startswith("_sysconfig_vars_") | |
| or name == "build-details.json" | |
| ) | |
| } | |
| elif path.parts[-1] == "lib": | |
| ignored_names = { | |
| name | |
| for name in names | |
| if name.startswith("libpython") and name.endswith(".dylib") | |
| } | |
| else: | |
| ignored_names = set() | |
| return ignored_names | |
| def lib_non_platform_files(dirname, names): | |
| """A file filter that ignores anything *except* platform-specific files | |
| in the lib directory. | |
| """ | |
| path = Path(dirname) | |
| if path.parts[-2] == "lib" and path.parts[-1].startswith("python"): | |
| return ( | |
| set(names) - lib_platform_files(dirname, names) - {"lib-dynload"} | |
| ) | |
| else: | |
| return set() | |
| def create_xcframework(platform: str, abiflags: str) -> str: | |
| """Build an XCframework from the component parts for the platform. | |
| :param platform: The platform being packaged (e.g., iOS). | |
| :param abiflags: The ABI flags of the build (e.g., "t"). | |
| :return: The version number of the Python version that was packaged. | |
| """ | |
| fw_name = framework_name(abiflags) | |
| # The stdlib directory only carries the threading ABI flag. | |
| abi_thread = "t" if "t" in abiflags else "" | |
| package_path = CROSS_BUILD_DIR / platform | |
| try: | |
| package_path.mkdir() | |
| except FileExistsError: | |
| raise RuntimeError( | |
| f"{platform} XCframework already exists; do you need to run " | |
| "with --clean?" | |
| ) from None | |
| frameworks = [] | |
| # Merge Frameworks for each component SDK. If there's only one architecture | |
| # for the SDK, we can use the compiled Python.framework as-is. However, if | |
| # there's more than architecture, we need to merge the individual built | |
| # frameworks into a merged "fat" framework. | |
| for slice_name, slice_parts in HOSTS[platform].items(): | |
| # Some parts are the same across all slices, so we use can any of the | |
| # host frameworks as the source for the merged version. Use the first | |
| # one on the list, as it's as representative as any other. | |
| first_host_triple, first_multiarch = next(iter(slice_parts.items())) | |
| first_framework = ( | |
| framework_path(first_host_triple, first_multiarch) | |
| / f"{fw_name}.framework" | |
| ) | |
| if len(slice_parts) == 1: | |
| # The first framework is the only framework, so copy it. | |
| print(f"Copying framework for {slice_name}...") | |
| frameworks.append(first_framework) | |
| else: | |
| print(f"Merging framework for {slice_name}...") | |
| slice_path = CROSS_BUILD_DIR / slice_name | |
| slice_framework = slice_path / f"{fw_name}.framework" | |
| slice_framework.mkdir(exist_ok=True, parents=True) | |
| # Copy the Info.plist | |
| shutil.copy( | |
| first_framework / "Info.plist", | |
| slice_framework / "Info.plist", | |
| ) | |
| # Copy the headers | |
| shutil.copytree( | |
| first_framework / "Headers", | |
| slice_framework / "Headers", | |
| ) | |
| # Create the "fat" library binary for the slice | |
| run( | |
| ["lipo", "-create", "-output", slice_framework / fw_name] | |
| + [ | |
| ( | |
| framework_path(host_triple, multiarch) | |
| / f"{fw_name}.framework/{fw_name}" | |
| ) | |
| for host_triple, multiarch in slice_parts.items() | |
| ] | |
| ) | |
| # Add this merged slice to the list to be added to the XCframework | |
| frameworks.append(slice_framework) | |
| print() | |
| print("Build XCframework...") | |
| cmd = [ | |
| "xcodebuild", | |
| "-create-xcframework", | |
| "-output", | |
| package_path / f"{fw_name}.xcframework", | |
| ] | |
| for framework in frameworks: | |
| cmd.extend(["-framework", framework]) | |
| run(cmd) | |
| # Extract the package version from the merged framework | |
| version = package_version(package_path / f"{fw_name}.xcframework") | |
| version_tag = ".".join(version.split(".")[:2]) | |
| # On non-macOS platforms, each framework in XCframework only contains the | |
| # headers, libPython, plus an Info.plist. Other resources like the standard | |
| # library and binary shims aren't allowed to live in framework; they need | |
| # to be copied in separately. | |
| print() | |
| print("Copy additional resources...") | |
| has_common_stdlib = False | |
| for slice_name, slice_parts in HOSTS[platform].items(): | |
| # Some parts are the same across all slices, so we can any of the | |
| # host frameworks as the source for the merged version. | |
| first_host_triple, first_multiarch = next(iter(slice_parts.items())) | |
| first_path = framework_path(first_host_triple, first_multiarch) | |
| first_framework = first_path / f"{fw_name}.framework" | |
| slice_path = package_path / f"{fw_name}.xcframework/{slice_name}" | |
| slice_framework = slice_path / f"{fw_name}.framework" | |
| # Copy the binary helpers | |
| print(f" - {slice_name} binaries") | |
| shutil.copytree(first_path / "bin", slice_path / "bin") | |
| # Copy the include path (a symlink to the framework headers) | |
| print(f" - {slice_name} include files") | |
| shutil.copytree( | |
| first_path / "include", | |
| slice_path / "include", | |
| symlinks=True, | |
| ) | |
| # Copy in the cross-architecture pyconfig.h | |
| shutil.copy( | |
| PYTHON_DIR / f"Platforms/Apple/{platform}/Resources/pyconfig.h", | |
| slice_framework / "Headers/pyconfig.h", | |
| ) | |
| print(f" - {slice_name} shared library") | |
| # Create a simlink for the fat library | |
| shared_lib = slice_path / f"lib/libpython{version_tag}{abiflags}.dylib" | |
| shared_lib.parent.mkdir() | |
| shared_lib.symlink_to(f"../{fw_name}.framework/{fw_name}") | |
| print(f" - {slice_name} architecture-specific files") | |
| for host_triple, multiarch in slice_parts.items(): | |
| print(f" - {multiarch} standard library") | |
| arch, _ = multiarch.split("-", 1) | |
| if not has_common_stdlib: | |
| print(" - using this architecture as the common stdlib") | |
| shutil.copytree( | |
| framework_path(host_triple, multiarch) / "lib", | |
| package_path / f"{fw_name}.xcframework/lib", | |
| ignore=lib_platform_files, | |
| symlinks=True, | |
| ) | |
| has_common_stdlib = True | |
| shutil.copytree( | |
| framework_path(host_triple, multiarch) / "lib", | |
| slice_path / f"lib-{arch}", | |
| ignore=lib_non_platform_files, | |
| symlinks=True, | |
| ) | |
| # Copy the host's pyconfig.h to an architecture-specific name. | |
| arch = multiarch.split("-")[0] | |
| host_path = ( | |
| CROSS_BUILD_DIR | |
| / host_triple | |
| / "Platforms/Apple/iOS/Frameworks" | |
| / multiarch | |
| ) | |
| host_framework = host_path / f"{fw_name}.framework" | |
| shutil.copy( | |
| host_framework / "Headers/pyconfig.h", | |
| slice_framework / f"Headers/pyconfig-{arch}.h", | |
| ) | |
| # Apple identifies certain libraries as "security risks"; if you | |
| # statically link those libraries into a Framework, you become | |
| # responsible for providing a privacy manifest for that framework. | |
| xcprivacy_file = { | |
| "OpenSSL": subdir(host_triple) | |
| / "prefix/share/OpenSSL.xcprivacy" | |
| } | |
| print(f" - {multiarch} xcprivacy files") | |
| for module, lib in [ | |
| ("_hashlib", "OpenSSL"), | |
| ("_ssl", "OpenSSL"), | |
| ]: | |
| shutil.copy( | |
| xcprivacy_file[lib], | |
| slice_path | |
| / f"lib-{arch}/python{version_tag}{abi_thread}" | |
| / f"lib-dynload/{module}.xcprivacy", | |
| ) | |
| print(" - build tools") | |
| shutil.copytree( | |
| PYTHON_DIR | |
| / "Platforms/Apple/testbed" | |
| / "Python.xcframework" | |
| / "build", | |
| package_path / f"{fw_name}.xcframework/build", | |
| ) | |
| return version | |
| def package(context: argparse.Namespace) -> None: | |
| """The implementation of the "package" command.""" | |
| if context.clean: | |
| clean(context, "package") | |
| with group("Building package"): | |
| # Create an XCframework | |
| version = create_xcframework(context.platform, context.abiflags) | |
| fw_name = framework_name(context.abiflags) | |
| # Clone testbed | |
| print() | |
| run([ | |
| sys.executable, | |
| "Platforms/Apple/testbed", | |
| "clone", | |
| "--platform", | |
| context.platform, | |
| "--framework", | |
| CROSS_BUILD_DIR / context.platform / f"{fw_name}.xcframework", | |
| CROSS_BUILD_DIR / context.platform / "testbed", | |
| ]) | |
| # Build the final archive | |
| archive_name = ( | |
| CROSS_BUILD_DIR | |
| / "dist" | |
| / ( | |
| f"python-{version}{context.abiflags}" | |
| f"-{context.platform}-XCframework" | |
| ) | |
| ) | |
| print() | |
| print("Create package archive...") | |
| shutil.make_archive( | |
| str(CROSS_BUILD_DIR / archive_name), | |
| format="gztar", | |
| root_dir=CROSS_BUILD_DIR / context.platform, | |
| base_dir=".", | |
| ) | |
| print() | |
| print(f"{archive_name.relative_to(PYTHON_DIR)}.tar.gz created.") | |
| def build(context: argparse.Namespace, host: str | None = None) -> None: | |
| """The implementation of the "build" command.""" | |
| if host is None: | |
| host = context.host | |
| if context.clean: | |
| clean(context, host) | |
| if host in {"all", "build"}: | |
| for step in [ | |
| configure_build_python, | |
| make_build_python, | |
| pythoninfo_build_python, | |
| ]: | |
| step(context) | |
| if host == "build": | |
| hosts = [] | |
| elif host in {"all", "hosts"}: | |
| hosts = all_host_triples(context.platform) | |
| else: | |
| hosts = [host] | |
| for step_host in hosts: | |
| for step in [ | |
| configure_host_python, | |
| make_host_python, | |
| ]: | |
| step(context, host=step_host) | |
| if host == "all": | |
| package(context) | |
| def test(context: argparse.Namespace, host: str | None = None) -> None: # noqa: PT028 | |
| """The implementation of the "test" command.""" | |
| if host is None: | |
| host = context.host | |
| if context.clean: | |
| clean(context, "test") | |
| with group(f"Test {'XCframework' if host in {'all', 'hosts'} else host}"): | |
| timestamp = str(time.time_ns())[:-6] | |
| testbed_dir = ( | |
| CROSS_BUILD_DIR / f"{context.platform}-testbed.{timestamp}" | |
| ) | |
| if host in {"all", "hosts"}: | |
| framework_path = ( | |
| CROSS_BUILD_DIR | |
| / context.platform | |
| / f"{framework_name(context.abiflags)}.xcframework" | |
| ) | |
| else: | |
| build_arch = platform.machine() | |
| host_arch = host.split("-")[0] | |
| if not host.endswith("-simulator"): | |
| print("Skipping test suite non-simulator build.") | |
| return | |
| elif build_arch != host_arch: | |
| print( | |
| f"Skipping test suite for an {host_arch} build " | |
| f"on an {build_arch} machine." | |
| ) | |
| return | |
| else: | |
| framework_path = ( | |
| CROSS_BUILD_DIR | |
| / host | |
| / f"Platforms/Apple/{context.platform}" | |
| / f"Frameworks/{apple_multiarch(host)}" | |
| ) | |
| run([ | |
| sys.executable, | |
| "Platforms/Apple/testbed", | |
| "clone", | |
| "--platform", | |
| context.platform, | |
| "--framework", | |
| framework_path, | |
| testbed_dir, | |
| ]) | |
| run( | |
| [ | |
| sys.executable, | |
| testbed_dir, | |
| "run", | |
| "--verbose", | |
| ] | |
| + ( | |
| ["--simulator", str(context.simulator)] | |
| if context.simulator | |
| else [] | |
| ) | |
| + [ | |
| "--", | |
| "test", | |
| f"--{context.ci_mode or 'fast'}-ci", | |
| "--single-process", | |
| "--no-randomize", | |
| "--pythoninfo", | |
| # Timeout handling requires subprocesses; explicitly setting | |
| # the timeout to -1 disables the faulthandler. | |
| "--timeout=-1", | |
| # Adding Python options requires the use of a subprocess to | |
| # start a new Python interpreter. | |
| "--dont-add-python-opts", | |
| ] | |
| ) | |
| def apple_sim_host(platform_name: str) -> str: | |
| """Determine the native simulator target for this platform.""" | |
| for _, slice_parts in HOSTS[platform_name].items(): | |
| for host_triple in slice_parts: | |
| parts = host_triple.split("-") | |
| if parts[0] == platform.machine() and parts[-1] == "simulator": | |
| return host_triple | |
| raise KeyError(platform_name) | |
| def ci(context: argparse.Namespace) -> None: | |
| """The implementation of the "ci" command. | |
| In "Fast" mode, this compiles the build python, and the simulator for the | |
| build machine's architecture; and runs the test suite with `--fast-ci` | |
| configuration. | |
| In "Slow" mode, it compiles the build python, plus all candidate | |
| architectures (both device and simulator); then runs the test suite with | |
| `--slow-ci` configuration. | |
| """ | |
| clean(context, "all") | |
| if context.ci_mode == "slow": | |
| # In slow mode, build and test the full XCframework | |
| build(context, host="all") | |
| test(context, host="all") | |
| else: | |
| # In fast mode, just build the simulator platform. | |
| sim_host = apple_sim_host(context.platform) | |
| build(context, host="build") | |
| build(context, host=sim_host) | |
| test(context, host=sim_host) | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "A tool for managing the build, package and test process of " | |
| "CPython on Apple platforms." | |
| ), | |
| ) | |
| parser.suggest_on_error = True | |
| subcommands = parser.add_subparsers(dest="subcommand", required=True) | |
| clean = subcommands.add_parser( | |
| "clean", | |
| help="Delete all build directories", | |
| ) | |
| configure_build = subcommands.add_parser( | |
| "configure-build", help="Run `configure` for the build Python" | |
| ) | |
| make_build = subcommands.add_parser( | |
| "make-build", help="Run `make` for the build Python" | |
| ) | |
| pythoninfo_build = subcommands.add_parser( | |
| "pythoninfo-build", help="Display build info of the build Python" | |
| ) | |
| configure_host = subcommands.add_parser( | |
| "configure-host", | |
| help="Run `configure` for a specific platform and target", | |
| ) | |
| make_host = subcommands.add_parser( | |
| "make-host", | |
| help="Run `make` for a specific platform and target", | |
| ) | |
| package = subcommands.add_parser( | |
| "package", | |
| help="Create a release package for the platform", | |
| ) | |
| build = subcommands.add_parser( | |
| "build", | |
| help="Build all platform targets and create the XCframework", | |
| ) | |
| test = subcommands.add_parser( | |
| "test", | |
| help="Run the testbed for a specific platform", | |
| ) | |
| ci = subcommands.add_parser( | |
| "ci", | |
| help="Run build, package, and test", | |
| ) | |
| # platform argument | |
| for cmd in [clean, configure_host, make_host, package, build, test, ci]: | |
| cmd.add_argument( | |
| "platform", | |
| choices=HOSTS.keys(), | |
| help="The target platform to build", | |
| ) | |
| # host triple argument | |
| for cmd in [configure_host, make_host]: | |
| cmd.add_argument( | |
| "host", | |
| help="The host triple to build (e.g., arm64-apple-ios-simulator)", | |
| ) | |
| # optional host triple argument | |
| for cmd in [clean, build, test]: | |
| cmd.add_argument( | |
| "host", | |
| nargs="?", | |
| default="all", | |
| help=( | |
| "The host triple to build (e.g., arm64-apple-ios-simulator), " | |
| "or 'build' for just the build platform, or 'hosts' for all " | |
| "host platforms, or 'all' for the build platform and all " | |
| "hosts. Defaults to 'all'" | |
| ), | |
| ) | |
| # --cross-build-dir and --abiflags arguments | |
| for cmd in [ | |
| clean, | |
| configure_build, | |
| make_build, | |
| pythoninfo_build, | |
| configure_host, | |
| make_host, | |
| build, | |
| package, | |
| test, | |
| ci, | |
| ]: | |
| cmd.add_argument( | |
| "--cross-build-dir", | |
| action="store", | |
| default=os.environ.get("CROSS_BUILD_DIR"), | |
| dest="cross_build_dir", | |
| type=Path, | |
| help=( | |
| "Base path of the cross-build directory " | |
| f"(default: {CROSS_BUILD_DIR}). Can also be set " | |
| "with the CROSS_BUILD_DIR environment variable. If " | |
| "--abiflags is specified, '-<abiflags>' is appended." | |
| ), | |
| ) | |
| cmd.add_argument( | |
| "--abiflags", | |
| default="", | |
| type=validate_abiflags, | |
| help=( | |
| "The ABI flags of the build: 't' (free-threaded; " | |
| "--disable-gil), 'd' (debug; --with-pydebug), or 'td' (both). " | |
| "Defaults to '' (a standard build)." | |
| ), | |
| ) | |
| # --clean option | |
| for cmd in [configure_build, configure_host, build, package, test, ci]: | |
| cmd.add_argument( | |
| "--clean", | |
| action="store_true", | |
| default=False, | |
| dest="clean", | |
| help="Delete the relevant build directories first", | |
| ) | |
| # --cache-dir option | |
| for cmd in [configure_host, build, ci]: | |
| cmd.add_argument( | |
| "--cache-dir", | |
| default=os.environ.get("CACHE_DIR"), | |
| help="The directory to store cached downloads.", | |
| ) | |
| # --simulator option | |
| for cmd in [test, ci]: | |
| cmd.add_argument( | |
| "--simulator", | |
| help=( | |
| "The name of the simulator to use (eg: 'iPhone 16e'). " | |
| "Defaults to the most recently released 'entry level' " | |
| "iPhone device. Device architecture and OS version can also " | |
| "be specified; e.g., " | |
| "`--simulator 'iPhone 16 Pro,arch=arm64,OS=26.0'` would " | |
| "run on an ARM64 iPhone 16 Pro simulator running iOS 26.0." | |
| ), | |
| ) | |
| group = cmd.add_mutually_exclusive_group() | |
| group.add_argument( | |
| "--fast-ci", | |
| action="store_const", | |
| dest="ci_mode", | |
| const="fast", | |
| help="Add test arguments for GitHub Actions", | |
| ) | |
| group.add_argument( | |
| "--slow-ci", | |
| action="store_const", | |
| dest="ci_mode", | |
| const="slow", | |
| help="Add test arguments for buildbots", | |
| ) | |
| for subcommand in [configure_build, configure_host, build, ci]: | |
| subcommand.add_argument( | |
| "args", nargs="*", help="Extra arguments to pass to `configure`" | |
| ) | |
| return parser.parse_args() | |
| def print_called_process_error(e: subprocess.CalledProcessError) -> None: | |
| for stream_name in ["stdout", "stderr"]: | |
| content = getattr(e, stream_name) | |
| stream = getattr(sys, stream_name) | |
| if content: | |
| stream.write(content) | |
| if not content.endswith("\n"): | |
| stream.write("\n") | |
| # shlex uses single quotes, so we surround the command with double quotes. | |
| print( | |
| f'Command "{join_command(e.cmd)}" returned exit status {e.returncode}' | |
| ) | |
| def main() -> None: | |
| # Handle SIGTERM the same way as SIGINT. This ensures that if we're | |
| # terminated by the buildbot worker, we'll make an attempt to clean up our | |
| # subprocesses. | |
| def signal_handler(*args): | |
| os.kill(os.getpid(), signal.SIGINT) | |
| signal.signal(signal.SIGTERM, signal_handler) | |
| # Process command line arguments | |
| context = parse_args() | |
| # Set the CROSS_BUILD_DIR, using any provided base directory, plus any ABI | |
| # flags as a suffix. | |
| global CROSS_BUILD_DIR | |
| if context.cross_build_dir: | |
| CROSS_BUILD_DIR = context.cross_build_dir.resolve() | |
| CROSS_BUILD_DIR = cross_build_dir(CROSS_BUILD_DIR, context.abiflags) | |
| dispatch: dict[str, Callable] = { | |
| "clean": clean, | |
| "configure-build": configure_build_python, | |
| "make-build": make_build_python, | |
| "pythoninfo-build": pythoninfo_build_python, | |
| "configure-host": configure_host_python, | |
| "make-host": make_host_python, | |
| "package": package, | |
| "build": build, | |
| "test": test, | |
| "ci": ci, | |
| } | |
| try: | |
| dispatch[context.subcommand](context) | |
| except CalledProcessError as e: | |
| print() | |
| print_called_process_error(e) | |
| sys.exit(1) | |
| except RuntimeError as e: | |
| print() | |
| print(e) | |
| sys.exit(2) | |
| if __name__ == "__main__": | |
| # Under the buildbot, stdout is not a TTY, but we must still flush after | |
| # every line to make sure our output appears in the correct order relative | |
| # to the output of our subprocesses. | |
| for stream in [sys.stdout, sys.stderr]: | |
| stream.reconfigure(line_buffering=True) | |
| main() |