gh-138171: Migrate iOS testbed location and add Apple build script (#138176)

Adds tooling to generate and test an iOS XCframework, in a way that will also facilitate
adding other XCframework targets for other Apple platforms (tvOS, watchOS, visionOS and 
even macOS, potentially).

---------
Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
This commit is contained in:
Russell Keith-Magee 2025-09-19 13:23:38 +01:00 committed by GitHub
parent 9243a4b933
commit 35c7e52b3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1708 additions and 612 deletions

1
.github/CODEOWNERS vendored
View file

@ -150,6 +150,7 @@ Lib/test/test_android.py @mhsmith @freakboy3742
# iOS
Doc/using/ios.rst @freakboy3742
Lib/_ios_support.py @freakboy3742
Apple/ @freakboy3742
iOS/ @freakboy3742
# macOS

18
.gitignore vendored
View file

@ -71,15 +71,15 @@ Lib/test/data/*
/Makefile
/Makefile.pre
/iOSTestbed.*
iOS/Frameworks/
iOS/Resources/Info.plist
iOS/testbed/build
iOS/testbed/Python.xcframework/ios-*/bin
iOS/testbed/Python.xcframework/ios-*/include
iOS/testbed/Python.xcframework/ios-*/lib
iOS/testbed/Python.xcframework/ios-*/Python.framework
iOS/testbed/iOSTestbed.xcodeproj/project.xcworkspace
iOS/testbed/iOSTestbed.xcodeproj/xcuserdata
Apple/iOS/Frameworks/
Apple/iOS/Resources/Info.plist
Apple/testbed/build
Apple/testbed/Python.xcframework/*/bin
Apple/testbed/Python.xcframework/*/include
Apple/testbed/Python.xcframework/*/lib
Apple/testbed/Python.xcframework/*/Python.framework
Apple/testbed/*Testbed.xcodeproj/project.xcworkspace
Apple/testbed/*Testbed.xcodeproj/xcuserdata
Mac/Makefile
Mac/PythonLauncher/Info.plist
Mac/PythonLauncher/Makefile

990
Apple/__main__.py Normal file
View file

@ -0,0 +1,990 @@
#!/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 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 Sequence
from contextlib import contextmanager
from datetime import datetime, timezone
from os.path import basename, relpath
from pathlib import Path
from subprocess import CalledProcessError
from typing import Callable
EnvironmentT = dict[str, str]
ArgsT = Sequence[str | Path]
SCRIPT_NAME = Path(__file__).name
PYTHON_DIR = Path(__file__).resolve().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",
},
},
}
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 / "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 = "all") -> None:
"""The implementation of the "clean" command."""
# 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")]
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 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.
"""
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.0.16-2",
"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()
unpack_deps(context.platform, host, prefix_dir, context.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()}",
"--with-system-libmpdec",
"--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",
]
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}/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 the lib directory.
"""
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 = set(
name
for name in names
if (
name.startswith("_sysconfigdata_")
or name.startswith("_sysconfig_vars_")
or name == "build-details.json"
)
)
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) -> str:
"""Build an XCframework from the component parts for the platform.
:return: The version number of the Python verion that was packaged.
"""
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)
/ "Python.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 / "Python.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 / "Python"]
+ [
(
framework_path(host_triple, multiarch)
/ "Python.framework/Python"
)
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 / "Python.xcframework",
]
for framework in frameworks:
cmd.extend(["-framework", framework])
run(cmd)
# Extract the package version from the merged framework
version = package_version(package_path / "Python.xcframework")
# 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 / "Python.framework"
slice_path = package_path / f"Python.xcframework/{slice_name}"
slice_framework = slice_path / "Python.framework"
# Copy the binary helpers
print(f" - {slice_name} binaries")
shutil.copytree(first_path / "bin", slice_path / "bin")
# Copy the include path (this will be 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"Apple/{platform}/Resources/pyconfig.h",
slice_framework / "Headers/pyconfig.h",
)
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 / "Python.xcframework/lib",
ignore=lib_platform_files,
)
has_common_stdlib = True
shutil.copytree(
framework_path(host_triple, multiarch) / "lib",
slice_path / f"lib-{arch}",
ignore=lib_non_platform_files,
)
# Copy the host's pyconfig.h to an architecture-specific name.
arch = multiarch.split("-")[0]
host_path = (
CROSS_BUILD_DIR
/ host_triple
/ "Apple/iOS/Frameworks"
/ multiarch
)
host_framework = host_path / "Python.framework"
shutil.copy(
host_framework / "Headers/pyconfig.h",
slice_framework / f"Headers/pyconfig-{arch}.h",
)
print(" - build tools")
shutil.copytree(
PYTHON_DIR / "Apple/testbed/Python.xcframework/build",
package_path / "Python.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)
# Clone testbed
print()
run(
[
sys.executable,
"Apple/testbed",
"clone",
"--platform",
context.platform,
"--framework",
CROSS_BUILD_DIR / context.platform / "Python.xcframework",
CROSS_BUILD_DIR / context.platform / "testbed",
]
)
# Build the final archive
archive_name = (
CROSS_BUILD_DIR
/ "dist"
/ f"python-{version}-{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,
]:
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 in {"all", "hosts"}:
package(context)
def test(context: argparse.Namespace, host: str | None = None) -> None:
"""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 / "Python.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"Apple/{context.platform}"
/ f"Frameworks/{apple_multiarch(host)}"
)
run(
[
sys.executable,
"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",
"--slow-ci" if context.slow else "--fast-ci",
"--single-process",
"--no-randomize",
# 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 ci(context: argparse.Namespace) -> None:
"""The implementation of the "ci" command."""
clean(context, "all")
build(context, host="all")
test(context, host="all")
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"
)
subcommands.add_parser(
"make-build", help="Run `make` for 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'"
),
)
# --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="./cross-build/downloads",
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."
),
)
cmd.add_argument(
"--slow",
action="store_true",
help="Run tests with --slow-ci options.",
)
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()
dispatch: dict[str, Callable] = {
"clean": clean,
"configure-build": configure_build_python,
"make-build": make_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__":
main()

328
Apple/iOS/README.md Normal file
View file

@ -0,0 +1,328 @@
# Python on iOS README
**iOS support is [tier 3](https://peps.python.org/pep-0011/#tier-3).**
This document provides a quick overview of some iOS specific features in the
Python distribution.
These instructions are only needed if you're planning to compile Python for iOS
yourself. Most users should *not* need to do this. If you're looking to
experiment with writing an iOS app in Python, tools such as [BeeWare's
Briefcase](https://briefcase.readthedocs.io) and [Kivy's
Buildozer](https://buildozer.readthedocs.io) will provide a much more
approachable user experience.
## Compilers for building on iOS
Building for iOS requires the use of Apple's Xcode tooling. It is strongly
recommended that you use the most recent stable release of Xcode. This will
require the use of the most (or second-most) recently released macOS version,
as Apple does not maintain Xcode for older macOS versions. The Xcode Command
Line Tools are not sufficient for iOS development; you need a *full* Xcode
install.
If you want to run your code on the iOS simulator, you'll also need to install
an iOS Simulator Platform. You should be prompted to select an iOS Simulator
Platform when you first run Xcode. Alternatively, you can add an iOS Simulator
Platform by selecting an open the Platforms tab of the Xcode Settings panel.
## Building Python on iOS
### ABIs and Architectures
iOS apps can be deployed on physical devices, and on the iOS simulator. Although
the API used on these devices is identical, the ABI is different - you need to
link against different libraries for an iOS device build (`iphoneos`) or an
iOS simulator build (`iphonesimulator`).
Apple uses the `XCframework` format to allow specifying a single dependency
that supports multiple ABIs. An `XCframework` is a wrapper around multiple
ABI-specific frameworks that share a common API.
iOS can also support different CPU architectures within each ABI. At present,
there is only a single supported architecture on physical devices - ARM64.
However, the *simulator* supports 2 architectures - ARM64 (for running on Apple
Silicon machines), and x86_64 (for running on older Intel-based machines).
To support multiple CPU architectures on a single platform, Apple uses a "fat
binary" format - a single physical file that contains support for multiple
architectures. It is possible to compile and use a "thin" single architecture
version of a binary for testing purposes; however, the "thin" binary will not be
portable to machines using other architectures.
### Building a multi-architecture iOS XCframework
The `Apple` subfolder of the Python repository acts as a build script that
can be used to coordinate the compilation of a complete iOS XCframework. To use
it, run::
python Apple build iOS
This will:
* Configure and compile a version of Python to run on the build machine
* Download pre-compiled binary dependencies for each platform
* Configure and build a `Python.framework` for each required architecture and
iOS SDK
* Merge the multiple `Python.framework` folders into a single `Python.xcframework`
* Produce a `.tar.gz` archive in the `cross-build/dist` folder containing
the `Python.xcframework`, plus a copy of the Testbed app pre-configured to
use the XCframework.
The `Apple` build script has other entry points that will perform the
individual parts of the overall `build` target, plus targets to test the
build, clean the `cross-build` folder of iOS build products, and perform a
complete "build and test" CI run. The `--clean` flag can also be used on
individual commands to ensure that a stale build product are removed before
building.
### Building a single-architecture framework
If you're using the `Apple` build script, you won't need to build
individual frameworks. However, if you do need to manually configure an iOS
Python build for a single framework, the following options are available.
#### iOS specific arguments to configure
* `--enable-framework[=DIR]`
This argument specifies the location where the Python.framework will be
installed. If `DIR` is not specified, the framework will be installed into
a subdirectory of the `iOS/Frameworks` folder.
This argument *must* be provided when configuring iOS builds. iOS does not
support non-framework builds.
* `--with-framework-name=NAME`
Specify the name for the Python framework; defaults to `Python`.
> [!NOTE]
> Unless you know what you're doing, changing the name of the Python
> framework on iOS is not advised. If you use this option, you won't be able
> to run the `Apple` build script without making significant manual
> alterations, and you won't be able to use any binary packages unless you
> compile them yourself using your own framework name.
#### Building Python for iOS
The Python build system will create a `Python.framework` that supports a
*single* ABI with a *single* architecture. Unlike macOS, iOS does not allow a
framework to contain non-library content, so the iOS build will produce a
`bin` and `lib` folder in the same output folder as `Python.framework`.
The `lib` folder will be needed at runtime to support the Python library.
If you want to use Python in a real iOS project, you need to produce multiple
`Python.framework` builds, one for each ABI and architecture. iOS builds of
Python *must* be constructed as framework builds. To support this, you must
provide the `--enable-framework` flag when configuring the build. The build
also requires the use of cross-compilation. The minimal commands for building
Python for the ARM64 iOS simulator will look something like:
```
export PATH="$(pwd)/Apple/iOS/Resources/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin"
./configure \
--enable-framework \
--host=arm64-apple-ios-simulator \
--build=arm64-apple-darwin \
--with-build-python=/path/to/python.exe
make
make install
```
In this invocation:
* `Apple/iOS/Resources/bin` has been added to the path, providing some shims for the
compilers and linkers needed by the build. Xcode requires the use of `xcrun`
to invoke compiler tooling. However, if `xcrun` is pre-evaluated and the
result passed to `configure`, these results can embed user- and
version-specific paths into the sysconfig data, which limits the portability
of the compiled Python. Alternatively, if `xcrun` is used *as* the compiler,
it requires that compiler variables like `CC` include spaces, which can
cause significant problems with many C configuration systems which assume that
`CC` will be a single executable.
To work around this problem, the `Apple/iOS/Resources/bin` folder contains some
wrapper scripts that present as simple compilers and linkers, but wrap
underlying calls to `xcrun`. This allows configure to use a `CC`
definition without spaces, and without user- or version-specific paths, while
retaining the ability to adapt to the local Xcode install. These scripts are
included in the `bin` directory of an iOS install.
These scripts will, by default, use the currently active Xcode installation.
If you want to use a different Xcode installation, you can use
`xcode-select` to set a new default Xcode globally, or you can use the
`DEVELOPER_DIR` environment variable to specify an Xcode install. The
scripts will use the default `iphoneos`/`iphonesimulator` SDK version for
the select Xcode install; if you want to use a different SDK, you can set the
`IOS_SDK_VERSION` environment variable. (e.g, setting
`IOS_SDK_VERSION=17.1` would cause the scripts to use the `iphoneos17.1`
and `iphonesimulator17.1` SDKs, regardless of the Xcode default.)
The path has also been cleared of any user customizations. A common source of
bugs is for tools like Homebrew to accidentally leak macOS binaries into an iOS
build. Resetting the path to a known "bare bones" value is the easiest way to
avoid these problems.
* `--host` is the architecture and ABI that you want to build, in GNU compiler
triple format. This will be one of:
- `arm64-apple-ios` for ARM64 iOS devices.
- `arm64-apple-ios-simulator` for the iOS simulator running on Apple
Silicon devices.
- `x86_64-apple-ios-simulator` for the iOS simulator running on Intel
devices.
* `--build` is the GNU compiler triple for the machine that will be running
the compiler. This is one of:
- `arm64-apple-darwin` for Apple Silicon devices.
- `x86_64-apple-darwin` for Intel devices.
* `/path/to/python.exe` is the path to a Python binary on the machine that
will be running the compiler. This is needed because the Python compilation
process involves running some Python code. On a normal desktop build of
Python, you can compile a python interpreter and then use that interpreter to
run Python code. However, the binaries produced for iOS won't run on macOS, so
you need to provide an external Python interpreter. This interpreter must be
the same version as the Python that is being compiled. To be completely safe,
this should be the *exact* same commit hash. However, the longer a Python
release has been stable, the more likely it is that this constraint can be
relaxed - the same micro version will often be sufficient.
* The `install` target for iOS builds is slightly different to other
platforms. On most platforms, `make install` will install the build into
the final runtime location. This won't be the case for iOS, as the final
runtime location will be on a physical device.
However, you still need to run the `install` target for iOS builds, as it
performs some final framework assembly steps. The location specified with
`--enable-framework` will be the location where `make install` will
assemble the complete iOS framework. This completed framework can then
be copied and relocated as required.
For a full CPython build, you also need to specify the paths to iOS builds of
the binary libraries that CPython depends on (such as XZ, LibFFI and OpenSSL).
This can be done by defining library specific environment variables (such as
`LIBLZMA_CFLAGS`, `LIBLZMA_LIBS`), and the `--with-openssl` configure
option. Versions of these libraries pre-compiled for iOS can be found in [this
repository](https://github.com/beeware/cpython-apple-source-deps/releases).
LibFFI is especially important, as many parts of the standard library
(including the `platform`, `sysconfig` and `webbrowser` modules) require
the use of the `ctypes` module at runtime.
By default, Python will be compiled with an iOS deployment target (i.e., the
minimum supported iOS version) of 13.0. To specify a different deployment
target, provide the version number as part of the `--host` argument - for
example, `--host=arm64-apple-ios15.4-simulator` would compile an ARM64
simulator build with a deployment target of 15.4.
## Testing Python on iOS
### Testing a multi-architecture framework
Once you have a built an XCframework, you can test that framework by running:
$ python Apple test iOS
### Testing a single-architecture framework
The `Apple/testbed` folder that contains an Xcode project that is able to run
the Python test suite on Apple platforms. This project converts the Python test
suite into a single test case in Xcode's XCTest framework. The single XCTest
passes if the test suite passes.
To run the test suite, configure a Python build for an iOS simulator (i.e.,
`--host=arm64-apple-ios-simulator` or `--host=x86_64-apple-ios-simulator`
), specifying a framework build (i.e. `--enable-framework`). Ensure that your
`PATH` has been configured to include the `Apple/iOS/Resources/bin` folder and
exclude any non-iOS tools, then run:
```
make all
make install
make testios
```
This will:
* Build an iOS framework for your chosen architecture;
* Finalize the single-platform framework;
* Make a clean copy of the testbed project;
* Install the Python iOS framework into the copy of the testbed project; and
* Run the test suite on an "entry-level device" simulator (i.e., an iPhone SE,
iPhone 16e, or a similar).
On success, the test suite will exit and report successful completion of the
test suite. On a 2022 M1 MacBook Pro, the test suite takes approximately 15
minutes to run; a couple of extra minutes is required to compile the testbed
project, and then boot and prepare the iOS simulator.
### Debugging test failures
Running `python Apple test iOS` generates a standalone version of the
`Apple/testbed` project, and runs the full test suite. It does this using
`Apple/testbed` itself - the folder is an executable module that can be used
to create and run a clone of the testbed project. The standalone version of the
testbed will be created in a directory named
`cross-build/iOS-testbed.<timestamp>`.
You can generate your own standalone testbed instance by running:
```
python cross-build/iOS/testbed clone my-testbed
```
In this invocation, `my-testbed` is the name of the folder for the new
testbed clone.
If you've built your own XCframework, or you only want to test a single architecture,
you can construct a standalone testbed instance by running:
```
python Apple/testbed clone --platform iOS --framework <path/to/framework> my-testbed
```
The framework path can be the path path to a `Python.xcframework`, or the
path to a folder that contains a single-platform `Python.framework`.
You can then use the `my-testbed` folder to run the Python test suite,
passing in any command line arguments you may require. For example, if you're
trying to diagnose a failure in the `os` module, you might run:
```
python my-testbed run -- test -W test_os
```
This is the equivalent of running `python -m test -W test_os` on a desktop
Python build. Any arguments after the `--` will be passed to testbed as if
they were arguments to `python -m` on a desktop machine.
### Testing in Xcode
You can also open the testbed project in Xcode by running:
```
open my-testbed/iOSTestbed.xcodeproj
```
This will allow you to use the full Xcode suite of tools for debugging.
The arguments used to run the test suite are defined as part of the test plan.
To modify the test plan, select the test plan node of the project tree (it
should be the first child of the root node), and select the "Configurations"
tab. Modify the "Arguments Passed On Launch" value to change the testing
arguments.
The test plan also disables parallel testing, and specifies the use of the
`Testbed.lldbinit` file for providing configuration of the debugger. The
default debugger configuration disables automatic breakpoints on the
`SIGINT`, `SIGUSR1`, `SIGUSR2`, and `SIGXFSZ` signals.
### Testing on an iOS device
To test on an iOS device, the app needs to be signed with known developer
credentials. To obtain these credentials, you must have an iOS Developer
account, and your Xcode install will need to be logged into your account (see
the Accounts tab of the Preferences dialog).
Once the project is open, and you're signed into your Apple Developer account,
select the root node of the project tree (labeled "iOSTestbed"), then the
"Signing & Capabilities" tab in the details page. Select a development team
(this will likely be your own name), and plug in a physical device to your
macOS machine with a USB cable. You should then be able to select your physical
device from the list of targets in the pulldown in the Xcode titlebar.

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} ar "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET} "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} clang++ -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET} "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET} -E "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} ar "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang++ -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator -E "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} strip -arch arm64 "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} strip -arch arm64 "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} ar "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target x86_64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang++ -target x86_64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target x86_64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator -E "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} strip -arch x86_64 "$@"

View file

@ -19,7 +19,7 @@
<string>iPhoneOS</string>
</array>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>

View file

@ -0,0 +1,137 @@
# Utility methods for use in an Xcode project.
#
# An iOS XCframework cannot include any content other than the library binary
# and relevant metadata. However, Python requires a standard library at runtime.
# Therefore, it is necessary to add a build step to an Xcode app target that
# processes the standard library and puts the content into the final app.
#
# In general, these tools will be invoked after bundle resources have been
# copied into the app, but before framework embedding (and signing).
#
# The following is an example script, assuming that:
# * Python.xcframework is in the root of the project
# * There is an `app` folder that contains the app code
# * There is an `app_packages` folder that contains installed Python packages.
# -----
# set -e
# source $PROJECT_DIR/Python.xcframework/build/build_utils.sh
# install_python Python.xcframework app app_packages
# -----
# Copy the standard library from the XCframework into the app bundle.
#
# Accepts one argument:
# 1. The path, relative to the root of the Xcode project, where the Python
# XCframework can be found.
install_stdlib() {
PYTHON_XCFRAMEWORK_PATH=$1
mkdir -p "$CODESIGNING_FOLDER_PATH/python/lib"
if [ "$EFFECTIVE_PLATFORM_NAME" = "-iphonesimulator" ]; then
echo "Installing Python modules for iOS Simulator"
if [ -d "$PROJECT_DIR/$PYTHON_XCFRAMEWORK_PATH/ios-arm64-simulator" ]; then
SLICE_FOLDER="ios-arm64-simulator"
else
SLICE_FOLDER="ios-arm64_x86_64-simulator"
fi
else
echo "Installing Python modules for iOS Device"
SLICE_FOLDER="ios-arm64"
fi
# If the XCframework has a shared lib folder, then it's a full framework.
# Copy both the common and slice-specific part of the lib directory.
# Otherwise, it's a single-arch framework; use the "full" lib folder.
if [ -d "$PROJECT_DIR/$PYTHON_XCFRAMEWORK_PATH/lib" ]; then
rsync -au --delete "$PROJECT_DIR/$PYTHON_XCFRAMEWORK_PATH/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
rsync -au "$PROJECT_DIR/$PYTHON_XCFRAMEWORK_PATH/$SLICE_FOLDER/lib-$ARCHS/" "$CODESIGNING_FOLDER_PATH/python/lib/"
else
rsync -au --delete "$PROJECT_DIR/$PYTHON_XCFRAMEWORK_PATH/$SLICE_FOLDER/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
fi
}
# Convert a single .so library into a framework that iOS can load.
#
# Accepts three arguments:
# 1. The path, relative to the root of the Xcode project, where the Python
# XCframework can be found.
# 2. The base path, relative to the installed location in the app bundle, that
# needs to be processed. Any .so file found in this path (or a subdirectory
# of it) will be processed.
# 2. The full path to a single .so file to process. This path should include
# the base path.
install_dylib () {
PYTHON_XCFRAMEWORK_PATH=$1
INSTALL_BASE=$2
FULL_EXT=$3
# The name of the extension file
EXT=$(basename "$FULL_EXT")
# The location of the extension file, relative to the bundle
RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/}
# The path to the extension file, relative to the install base
PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}
# The full dotted name of the extension module, constructed from the file path.
FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d "." -f 1 | tr "/" ".");
# A bundle identifier; not actually used, but required by Xcode framework packaging
FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr "_" "-")
# The name of the framework folder.
FRAMEWORK_FOLDER="Frameworks/$FULL_MODULE_NAME.framework"
# If the framework folder doesn't exist, create it.
if [ ! -d "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER" ]; then
echo "Creating framework for $RELATIVE_EXT"
mkdir -p "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER"
cp "$PROJECT_DIR/$PYTHON_XCFRAMEWORK_PATH/build/$PLATFORM_FAMILY_NAME-dylib-Info-template.plist" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
plutil -replace CFBundleExecutable -string "$FULL_MODULE_NAME" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
plutil -replace CFBundleIdentifier -string "$FRAMEWORK_BUNDLE_ID" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
fi
echo "Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
mv "$FULL_EXT" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
# Create a placeholder .fwork file where the .so was
echo "$FRAMEWORK_FOLDER/$FULL_MODULE_NAME" > ${FULL_EXT%.so}.fwork
# Create a back reference to the .so file location in the framework
echo "${RELATIVE_EXT%.so}.fwork" > "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin"
echo "Signing framework as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)..."
/usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER"
}
# Process all the dynamic libraries in a path into Framework format.
#
# Accepts two arguments:
# 1. The path, relative to the root of the Xcode project, where the Python
# XCframework can be found.
# 2. The base path, relative to the installed location in the app bundle, that
# needs to be processed. Any .so file found in this path (or a subdirectory
# of it) will be processed.
process_dylibs () {
PYTHON_XCFRAMEWORK_PATH=$1
LIB_PATH=$2
find "$CODESIGNING_FOLDER_PATH/$LIB_PATH" -name "*.so" | while read FULL_EXT; do
install_dylib $PYTHON_XCFRAMEWORK_PATH "$LIB_PATH/" "$FULL_EXT"
done
}
# The entry point for post-processing a Python XCframework.
#
# Accepts 1 or more arguments:
# 1. The path, relative to the root of the Xcode project, where the Python
# XCframework can be found. If the XCframework is in the root of the project,
# 2+. The path of a package, relative to the root of the packaged app, that contains
# library content that should be processed for binary libraries.
install_python() {
PYTHON_XCFRAMEWORK_PATH=$1
shift
install_stdlib $PYTHON_XCFRAMEWORK_PATH
PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib")
echo "Install Python $PYTHON_VER standard library extension modules..."
process_dylibs $PYTHON_XCFRAMEWORK_PATH python/lib/$PYTHON_VER/lib-dynload
for package_path in $@; do
echo "Installing $package_path extension modules ..."
process_dylibs $PYTHON_XCFRAMEWORK_PATH $package_path
done
}

View file

@ -1,11 +1,11 @@
#import <XCTest/XCTest.h>
#import <Python/Python.h>
@interface iOSTestbedTests : XCTestCase
@interface TestbedTests : XCTestCase
@end
@implementation iOSTestbedTests
@implementation TestbedTests
- (void)testPython {
@ -41,14 +41,14 @@
// The processInfo arguments contain the binary that is running,
// followed by the arguments defined in the test plan. This means:
// run_module = test_args[1]
// argv = ["iOSTestbed"] + test_args[2:]
// argv = ["Testbed"] + test_args[2:]
test_args = [[NSProcessInfo processInfo] arguments];
if (test_args == NULL) {
NSLog(@"Unable to identify test arguments.");
}
NSLog(@"Test arguments: %@", test_args);
argv = malloc(sizeof(char *) * ([test_args count] - 1));
argv[0] = "iOSTestbed";
argv[0] = "Testbed";
for (int i = 1; i < [test_args count] - 1; i++) {
argv[i] = [[test_args objectAtIndex:i+1] UTF8String];
}

View file

@ -6,6 +6,9 @@
import sys
from pathlib import Path
TEST_SLICES = {
"iOS": "ios-arm64_x86_64-simulator",
}
DECODE_ARGS = ("UTF-8", "backslashreplace")
@ -21,45 +24,49 @@
# Select a simulator device to use.
def select_simulator_device():
def select_simulator_device(platform):
# List the testing simulators, in JSON format
raw_json = subprocess.check_output(["xcrun", "simctl", "list", "-j"])
json_data = json.loads(raw_json)
# Any device will do; we'll look for "SE" devices - but the name isn't
# consistent over time. Older Xcode versions will use "iPhone SE (Nth
# generation)"; As of 2025, they've started using "iPhone 16e".
#
# When Xcode is updated after a new release, new devices will be available
# and old ones will be dropped from the set available on the latest iOS
# version. Select the one with the highest minimum runtime version - this
# is an indicator of the "newest" released device, which should always be
# supported on the "most recent" iOS version.
se_simulators = sorted(
(devicetype["minRuntimeVersion"], devicetype["name"])
for devicetype in json_data["devicetypes"]
if devicetype["productFamily"] == "iPhone"
and (
(
"iPhone " in devicetype["name"]
and devicetype["name"].endswith("e")
if platform == "iOS":
# Any iOS device will do; we'll look for "SE" devices - but the name isn't
# consistent over time. Older Xcode versions will use "iPhone SE (Nth
# generation)"; As of 2025, they've started using "iPhone 16e".
#
# When Xcode is updated after a new release, new devices will be available
# and old ones will be dropped from the set available on the latest iOS
# version. Select the one with the highest minimum runtime version - this
# is an indicator of the "newest" released device, which should always be
# supported on the "most recent" iOS version.
se_simulators = sorted(
(devicetype["minRuntimeVersion"], devicetype["name"])
for devicetype in json_data["devicetypes"]
if devicetype["productFamily"] == "iPhone"
and (
(
"iPhone " in devicetype["name"]
and devicetype["name"].endswith("e")
)
or "iPhone SE " in devicetype["name"]
)
or "iPhone SE " in devicetype["name"]
)
)
simulator = se_simulators[-1][1]
else:
raise ValueError(f"Unknown platform {platform}")
return se_simulators[-1][1]
return simulator
def xcode_test(location, simulator, verbose):
def xcode_test(location: Path, platform: str, simulator: str, verbose: bool):
# Build and run the test suite on the named simulator.
args = [
"-project",
str(location / "iOSTestbed.xcodeproj"),
str(location / f"{platform}Testbed.xcodeproj"),
"-scheme",
"iOSTestbed",
f"{platform}Testbed",
"-destination",
f"platform=iOS Simulator,name={simulator}",
f"platform={platform} Simulator,name={simulator}",
"-derivedDataPath",
str(location / "DerivedData"),
]
@ -89,10 +96,24 @@ def xcode_test(location, simulator, verbose):
exit(status)
def copy(src, tgt):
"""An all-purpose copy.
If src is a file, it is copied. If src is a symlink, it is copied *as a
symlink*. If src is a directory, the full tree is duplicated, with symlinks
being preserved.
"""
if src.is_file() or src.is_symlink():
shutil.copyfile(src, tgt, follow_symlinks=False)
else:
shutil.copytree(src, tgt, symlinks=True)
def clone_testbed(
source: Path,
target: Path,
framework: Path,
platform: str,
apps: list[Path],
) -> None:
if target.exists():
@ -101,11 +122,11 @@ def clone_testbed(
if framework is None:
if not (
source / "Python.xcframework/ios-arm64_x86_64-simulator/bin"
source / "Python.xcframework" / TEST_SLICES[platform] / "bin"
).is_dir():
print(
f"The testbed being cloned ({source}) does not contain "
f"a simulator framework. Re-run with --framework"
"a framework with slices. Re-run with --framework"
)
sys.exit(11)
else:
@ -124,33 +145,49 @@ def clone_testbed(
print("Cloning testbed project:")
print(f" Cloning {source}...", end="")
shutil.copytree(source, target, symlinks=True)
# Only copy the files for the platform being cloned plus the files common
# to all platforms. The XCframework will be copied later, if needed.
target.mkdir(parents=True)
for name in [
"__main__.py",
"TestbedTests",
"Testbed.lldbinit",
f"{platform}Testbed",
f"{platform}Testbed.xcodeproj",
f"{platform}Testbed.xctestplan",
]:
copy(source / name, target / name)
print(" done")
orig_xc_framework_path = source / "Python.xcframework"
xc_framework_path = target / "Python.xcframework"
sim_framework_path = xc_framework_path / "ios-arm64_x86_64-simulator"
test_framework_path = xc_framework_path / TEST_SLICES[platform]
if framework is not None:
if framework.suffix == ".xcframework":
print(" Installing XCFramework...", end="")
if xc_framework_path.is_dir():
shutil.rmtree(xc_framework_path)
else:
xc_framework_path.unlink(missing_ok=True)
xc_framework_path.symlink_to(
framework.relative_to(xc_framework_path.parent, walk_up=True)
)
print(" done")
else:
print(" Installing simulator framework...", end="")
if sim_framework_path.is_dir():
shutil.rmtree(sim_framework_path)
# We're only installing a slice of a framework; we need
# to do a full tree copy to make sure we don't damage
# symlinked content.
shutil.copytree(orig_xc_framework_path, xc_framework_path)
if test_framework_path.is_dir():
shutil.rmtree(test_framework_path)
else:
sim_framework_path.unlink(missing_ok=True)
sim_framework_path.symlink_to(
framework.relative_to(sim_framework_path.parent, walk_up=True)
test_framework_path.unlink(missing_ok=True)
test_framework_path.symlink_to(
framework.relative_to(test_framework_path.parent, walk_up=True)
)
print(" done")
else:
copy(orig_xc_framework_path, xc_framework_path)
if (
xc_framework_path.is_symlink()
and not xc_framework_path.readlink().is_absolute()
@ -158,39 +195,39 @@ def clone_testbed(
# XCFramework is a relative symlink. Rewrite the symlink relative
# to the new location.
print(" Rewriting symlink to XCframework...", end="")
orig_xc_framework_path = (
resolved_xc_framework_path = (
source / xc_framework_path.readlink()
).resolve()
xc_framework_path.unlink()
xc_framework_path.symlink_to(
orig_xc_framework_path.relative_to(
resolved_xc_framework_path.relative_to(
xc_framework_path.parent, walk_up=True
)
)
print(" done")
elif (
sim_framework_path.is_symlink()
and not sim_framework_path.readlink().is_absolute()
test_framework_path.is_symlink()
and not test_framework_path.readlink().is_absolute()
):
print(" Rewriting symlink to simulator framework...", end="")
# Simulator framework is a relative symlink. Rewrite the symlink
# relative to the new location.
orig_sim_framework_path = (
source / "Python.XCframework" / sim_framework_path.readlink()
orig_test_framework_path = (
source / "Python.XCframework" / test_framework_path.readlink()
).resolve()
sim_framework_path.unlink()
sim_framework_path.symlink_to(
orig_sim_framework_path.relative_to(
sim_framework_path.parent, walk_up=True
test_framework_path.unlink()
test_framework_path.symlink_to(
orig_test_framework_path.relative_to(
test_framework_path.parent, walk_up=True
)
)
print(" done")
else:
print(" Using pre-existing iOS framework.")
print(" Using pre-existing Python framework.")
for app_src in apps:
print(f" Installing app {app_src.name!r}...", end="")
app_target = target / f"iOSTestbed/app/{app_src.name}"
app_target = target / f"Testbed/app/{app_src.name}"
if app_target.is_dir():
shutil.rmtree(app_target)
shutil.copytree(app_src, app_target)
@ -199,9 +236,9 @@ def clone_testbed(
print(f"Successfully cloned testbed: {target.resolve()}")
def update_test_plan(testbed_path, args):
def update_test_plan(testbed_path, platform, args):
# Modify the test plan to use the requested test arguments.
test_plan_path = testbed_path / "iOSTestbed.xctestplan"
test_plan_path = testbed_path / f"{platform}Testbed.xctestplan"
with test_plan_path.open("r", encoding="utf-8") as f:
test_plan = json.load(f)
@ -213,32 +250,50 @@ def update_test_plan(testbed_path, args):
json.dump(test_plan, f, indent=2)
def run_testbed(simulator: str | None, args: list[str], verbose: bool = False):
def run_testbed(
platform: str,
simulator: str | None,
args: list[str],
verbose: bool = False,
):
location = Path(__file__).parent
print("Updating test plan...", end="")
update_test_plan(location, args)
update_test_plan(location, platform, args)
print(" done.")
if simulator is None:
simulator = select_simulator_device()
simulator = select_simulator_device(platform)
print(f"Running test on {simulator}")
xcode_test(location, simulator=simulator, verbose=verbose)
xcode_test(
location,
platform=platform,
simulator=simulator,
verbose=verbose,
)
def main():
# Look for directories like `iOSTestbed` as an indicator of the platforms
# that the testbed folder supports. The original source testbed can support
# many platforms, but when cloned, only one platform is preserved.
available_platforms = [
platform
for platform in ["iOS"]
if (Path(__file__).parent / f"{platform}Testbed").is_dir()
]
parser = argparse.ArgumentParser(
description=(
"Manages the process of testing a Python project in the iOS simulator."
"Manages the process of testing an Apple Python project through Xcode."
),
)
subcommands = parser.add_subparsers(dest="subcommand")
clone = subcommands.add_parser(
"clone",
description=(
"Clone the testbed project, copying in an iOS Python framework and"
"Clone the testbed project, copying in a Python framework and"
"any specified application code."
),
help="Clone a testbed project to a new location.",
@ -250,6 +305,13 @@ def main():
"XCFramework) to use when running the testbed"
),
)
clone.add_argument(
"--platform",
dest="platform",
choices=available_platforms,
default=available_platforms[0],
help=f"The platform to target (default: {available_platforms[0]})",
)
clone.add_argument(
"--app",
dest="apps",
@ -272,6 +334,13 @@ def main():
),
help="Run a testbed project",
)
run.add_argument(
"--platform",
dest="platform",
choices=available_platforms,
default=available_platforms[0],
help=f"The platform to target (default: {available_platforms[0]})",
)
run.add_argument(
"--simulator",
help=(
@ -306,22 +375,26 @@ def main():
framework=Path(context.framework).resolve()
if context.framework
else None,
platform=context.platform,
apps=[Path(app) for app in context.apps],
)
elif context.subcommand == "run":
if test_args:
if not (
Path(__file__).parent
/ "Python.xcframework/ios-arm64_x86_64-simulator/bin"
/ "Python.xcframework"
/ TEST_SLICES[context.platform]
/ "bin"
).is_dir():
print(
f"Testbed does not contain a compiled iOS framework. Use "
f"Testbed does not contain a compiled Python framework. Use "
f"`python {sys.argv[0]} clone ...` to create a runnable "
f"clone of this testbed."
)
sys.exit(20)
run_testbed(
platform=context.platform,
simulator=context.simulator,
verbose=context.verbose,
args=test_args,

View file

@ -11,12 +11,11 @@
607A66222B0EFA390010BFC8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607A66212B0EFA390010BFC8 /* Assets.xcassets */; };
607A66252B0EFA390010BFC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607A66232B0EFA390010BFC8 /* LaunchScreen.storyboard */; };
607A66282B0EFA390010BFC8 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 607A66272B0EFA390010BFC8 /* main.m */; };
607A66322B0EFA3A0010BFC8 /* iOSTestbedTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 607A66312B0EFA3A0010BFC8 /* iOSTestbedTests.m */; };
607A66322B0EFA3A0010BFC8 /* TestbedTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 607A66312B0EFA3A0010BFC8 /* TestbedTests.m */; };
607A664C2B0EFC080010BFC8 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; };
607A664D2B0EFC080010BFC8 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
607A66502B0EFFE00010BFC8 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; };
607A66512B0EFFE00010BFC8 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
607A66582B0F079F0010BFC8 /* dylib-Info-template.plist in Resources */ = {isa = PBXBuildFile; fileRef = 607A66572B0F079F0010BFC8 /* dylib-Info-template.plist */; };
608619542CB77BA900F46182 /* app_packages in Resources */ = {isa = PBXBuildFile; fileRef = 608619532CB77BA900F46182 /* app_packages */; };
608619562CB7819B00F46182 /* app in Resources */ = {isa = PBXBuildFile; fileRef = 608619552CB7819B00F46182 /* app */; };
/* End PBXBuildFile section */
@ -64,9 +63,8 @@
607A66242B0EFA390010BFC8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
607A66272B0EFA390010BFC8 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
607A662D2B0EFA3A0010BFC8 /* iOSTestbedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSTestbedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
607A66312B0EFA3A0010BFC8 /* iOSTestbedTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iOSTestbedTests.m; sourceTree = "<group>"; };
607A66312B0EFA3A0010BFC8 /* TestbedTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TestbedTests.m; sourceTree = "<group>"; };
607A664A2B0EFB310010BFC8 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Python.xcframework; sourceTree = "<group>"; };
607A66572B0F079F0010BFC8 /* dylib-Info-template.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "dylib-Info-template.plist"; sourceTree = "<group>"; };
607A66592B0F08600010BFC8 /* iOSTestbed-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "iOSTestbed-Info.plist"; sourceTree = "<group>"; };
608619532CB77BA900F46182 /* app_packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = app_packages; sourceTree = "<group>"; };
608619552CB7819B00F46182 /* app */ = {isa = PBXFileReference; lastKnownFileType = folder; path = app; sourceTree = "<group>"; };
@ -99,7 +97,7 @@
60FE0EFB2E56BB6D00524F87 /* iOSTestbed.xctestplan */,
607A664A2B0EFB310010BFC8 /* Python.xcframework */,
607A66142B0EFA380010BFC8 /* iOSTestbed */,
607A66302B0EFA3A0010BFC8 /* iOSTestbedTests */,
607A66302B0EFA3A0010BFC8 /* TestbedTests */,
607A66132B0EFA380010BFC8 /* Products */,
607A664F2B0EFFE00010BFC8 /* Frameworks */,
);
@ -120,7 +118,6 @@
608619552CB7819B00F46182 /* app */,
608619532CB77BA900F46182 /* app_packages */,
607A66592B0F08600010BFC8 /* iOSTestbed-Info.plist */,
607A66572B0F079F0010BFC8 /* dylib-Info-template.plist */,
607A66152B0EFA380010BFC8 /* AppDelegate.h */,
607A66162B0EFA380010BFC8 /* AppDelegate.m */,
607A66212B0EFA390010BFC8 /* Assets.xcassets */,
@ -130,12 +127,12 @@
path = iOSTestbed;
sourceTree = "<group>";
};
607A66302B0EFA3A0010BFC8 /* iOSTestbedTests */ = {
607A66302B0EFA3A0010BFC8 /* TestbedTests */ = {
isa = PBXGroup;
children = (
607A66312B0EFA3A0010BFC8 /* iOSTestbedTests.m */,
607A66312B0EFA3A0010BFC8 /* TestbedTests.m */,
);
path = iOSTestbedTests;
path = TestbedTests;
sourceTree = "<group>";
};
607A664F2B0EFFE00010BFC8 /* Frameworks */ = {
@ -155,8 +152,7 @@
607A660E2B0EFA380010BFC8 /* Sources */,
607A660F2B0EFA380010BFC8 /* Frameworks */,
607A66102B0EFA380010BFC8 /* Resources */,
607A66552B0F061D0010BFC8 /* Install Target Specific Python Standard Library */,
607A66562B0F06200010BFC8 /* Prepare Python Binary Modules */,
607A66552B0F061D0010BFC8 /* Process Python libraries */,
607A664E2B0EFC080010BFC8 /* Embed Frameworks */,
);
buildRules = (
@ -230,7 +226,6 @@
buildActionMask = 2147483647;
files = (
607A66252B0EFA390010BFC8 /* LaunchScreen.storyboard in Resources */,
607A66582B0F079F0010BFC8 /* dylib-Info-template.plist in Resources */,
608619562CB7819B00F46182 /* app in Resources */,
607A66222B0EFA390010BFC8 /* Assets.xcassets in Resources */,
608619542CB77BA900F46182 /* app_packages in Resources */,
@ -247,7 +242,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
607A66552B0F061D0010BFC8 /* Install Target Specific Python Standard Library */ = {
607A66552B0F061D0010BFC8 /* Process Python libraries */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@ -257,34 +252,14 @@
);
inputPaths = (
);
name = "Install Target Specific Python Standard Library";
name = "Process Python libraries";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nmkdir -p \"$CODESIGNING_FOLDER_PATH/python/lib\"\nif [ \"$EFFECTIVE_PLATFORM_NAME\" = \"-iphonesimulator\" ]; then\n echo \"Installing Python modules for iOS Simulator\"\n rsync -au --delete \"$PROJECT_DIR/Python.xcframework/ios-arm64_x86_64-simulator/lib/\" \"$CODESIGNING_FOLDER_PATH/python/lib/\" \nelse\n echo \"Installing Python modules for iOS Device\"\n rsync -au --delete \"$PROJECT_DIR/Python.xcframework/ios-arm64/lib/\" \"$CODESIGNING_FOLDER_PATH/python/lib/\" \nfi\n";
showEnvVarsInLog = 0;
};
607A66562B0F06200010BFC8 /* Prepare Python Binary Modules */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Prepare Python Binary Modules";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\ninstall_dylib () {\n INSTALL_BASE=$1\n FULL_EXT=$2\n\n # The name of the extension file\n EXT=$(basename \"$FULL_EXT\")\n # The location of the extension file, relative to the bundle\n RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/} \n # The path to the extension file, relative to the install base\n PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}\n # The full dotted name of the extension module, constructed from the file path.\n FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d \".\" -f 1 | tr \"/\" \".\"); \n # A bundle identifier; not actually used, but required by Xcode framework packaging\n FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr \"_\" \"-\")\n # The name of the framework folder.\n FRAMEWORK_FOLDER=\"Frameworks/$FULL_MODULE_NAME.framework\"\n\n # If the framework folder doesn't exist, create it.\n if [ ! -d \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER\" ]; then\n echo \"Creating framework for $RELATIVE_EXT\" \n mkdir -p \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER\"\n cp \"$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n plutil -replace CFBundleExecutable -string \"$FULL_MODULE_NAME\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n plutil -replace CFBundleIdentifier -string \"$FRAMEWORK_BUNDLE_ID\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n fi\n \n echo \"Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME\" \n mv \"$FULL_EXT\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME\"\n # Create a placeholder .fwork file where the .so was\n echo \"$FRAMEWORK_FOLDER/$FULL_MODULE_NAME\" > ${FULL_EXT%.so}.fwork\n # Create a back reference to the .so file location in the framework\n echo \"${RELATIVE_EXT%.so}.fwork\" > \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin\" \n}\n\nPYTHON_VER=$(ls -1 \"$CODESIGNING_FOLDER_PATH/python/lib\")\necho \"Install Python $PYTHON_VER standard library extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/python/lib/$PYTHON_VER/lib-dynload\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib python/lib/$PYTHON_VER/lib-dynload/ \"$FULL_EXT\"\ndone\necho \"Install app package extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/app_packages\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib app_packages/ \"$FULL_EXT\"\ndone\necho \"Install app extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/app\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib app/ \"$FULL_EXT\"\ndone\n\n# Clean up dylib template \nrm -f \"$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist\"\necho \"Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)...\"\nfind \"$CODESIGNING_FOLDER_PATH/Frameworks\" -name \"*.framework\" -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der \"{}\" \\; \n";
shellScript = "set -e\nsource $PROJECT_DIR/Python.xcframework/build/utils.sh\ninstall_python Python.xcframework app app_packages\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
@ -303,7 +278,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
607A66322B0EFA3A0010BFC8 /* iOSTestbedTests.m in Sources */,
607A66322B0EFA3A0010BFC8 /* TestbedTests.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -27,7 +27,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "/Users/rkm/projects/pyspamsum/localtest/iOSTestbed.lldbinit"
customLLDBInitFile = "$(SOURCE_ROOT)/Testbed.lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference

View file

@ -1,7 +1,7 @@
This folder can contain any Python application code.
During the build, any binary modules found in this folder will be processed into
iOS Framework form.
Framework form.
When the test suite runs, this folder will be on the PYTHONPATH, and will be the
working directory for the test suite.

View file

@ -2,6 +2,6 @@ This folder can be a target for installing any Python dependencies needed by the
test suite.
During the build, any binary modules found in this folder will be processed into
iOS Framework form.
Framework form.
When the test suite runs, this folder will be on the PYTHONPATH.

View file

@ -170,7 +170,7 @@ helpful.
To add Python to an iOS Xcode project:
1. Build or obtain a Python ``XCFramework``. See the instructions in
:source:`iOS/README.rst` (in the CPython source distribution) for details on
:source:`Apple/iOS/README.md` (in the CPython source distribution) for details on
how to build a Python ``XCFramework``. At a minimum, you will need a build
that supports ``arm64-apple-ios``, plus one of either
``arm64-apple-ios-simulator`` or ``x86_64-apple-ios-simulator``.
@ -180,22 +180,19 @@ To add Python to an iOS Xcode project:
of your project; however, you can use any other location that you want by
adjusting paths as needed.
3. Drag the ``iOS/Resources/dylib-Info-template.plist`` file into your project,
and ensure it is associated with the app target.
4. Add your application code as a folder in your Xcode project. In the
3. Add your application code as a folder in your Xcode project. In the
following instructions, we'll assume that your user code is in a folder
named ``app`` in the root of your project; you can use any other location by
adjusting paths as needed. Ensure that this folder is associated with your
app target.
5. Select the app target by selecting the root node of your Xcode project, then
4. Select the app target by selecting the root node of your Xcode project, then
the target name in the sidebar that appears.
6. In the "General" settings, under "Frameworks, Libraries and Embedded
5. In the "General" settings, under "Frameworks, Libraries and Embedded
Content", add ``Python.xcframework``, with "Embed & Sign" selected.
7. In the "Build Settings" tab, modify the following:
6. In the "Build Settings" tab, modify the following:
- Build Options
@ -211,86 +208,24 @@ To add Python to an iOS Xcode project:
* Quoted Include In Framework Header: No
8. Add a build step that copies the Python standard library into your app. In
the "Build Phases" tab, add a new "Run Script" build step *before* the
"Embed Frameworks" step, but *after* the "Copy Bundle Resources" step. Name
the step "Install Target Specific Python Standard Library", disable the
"Based on dependency analysis" checkbox, and set the script content to:
7. Add a build step that processes the Python standard library, and your own
Python binary dependencies. In the "Build Phases" tab, add a new "Run
Script" build step *before* the "Embed Frameworks" step, but *after* the
"Copy Bundle Resources" step. Name the step "Process Python libraries",
disable the "Based on dependency analysis" checkbox, and set the script
content to:
.. code-block:: bash
set -e
set -e
source $PROJECT_DIR/Python.xcframework/build/build_utils.sh
install_python Python.xcframework app
mkdir -p "$CODESIGNING_FOLDER_PATH/python/lib"
if [ "$EFFECTIVE_PLATFORM_NAME" = "-iphonesimulator" ]; then
echo "Installing Python modules for iOS Simulator"
rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64_x86_64-simulator/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
else
echo "Installing Python modules for iOS Device"
rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
fi
If you have placed your XCframework somewhere other than the root of your
project, modify the path to the first argument.
Note that the name of the simulator "slice" in the XCframework may be
different, depending the CPU architectures your ``XCFramework`` supports.
9. Add a second build step that processes the binary extension modules in the
standard library into "Framework" format. Add a "Run Script" build step
*directly after* the one you added in step 8, named "Prepare Python Binary
Modules". It should also have "Based on dependency analysis" unchecked, with
the following script content:
.. code-block:: bash
set -e
install_dylib () {
INSTALL_BASE=$1
FULL_EXT=$2
# The name of the extension file
EXT=$(basename "$FULL_EXT")
# The location of the extension file, relative to the bundle
RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/}
# The path to the extension file, relative to the install base
PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}
# The full dotted name of the extension module, constructed from the file path.
FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d "." -f 1 | tr "/" ".");
# A bundle identifier; not actually used, but required by Xcode framework packaging
FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr "_" "-")
# The name of the framework folder.
FRAMEWORK_FOLDER="Frameworks/$FULL_MODULE_NAME.framework"
# If the framework folder doesn't exist, create it.
if [ ! -d "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER" ]; then
echo "Creating framework for $RELATIVE_EXT"
mkdir -p "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER"
cp "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
plutil -replace CFBundleExecutable -string "$FULL_MODULE_NAME" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
plutil -replace CFBundleIdentifier -string "$FRAMEWORK_BUNDLE_ID" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
fi
echo "Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
mv "$FULL_EXT" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
# Create a placeholder .fwork file where the .so was
echo "$FRAMEWORK_FOLDER/$FULL_MODULE_NAME" > ${FULL_EXT%.so}.fwork
# Create a back reference to the .so file location in the framework
echo "${RELATIVE_EXT%.so}.fwork" > "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin"
}
PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib")
echo "Install Python $PYTHON_VER standard library extension modules..."
find "$CODESIGNING_FOLDER_PATH/python/lib/$PYTHON_VER/lib-dynload" -name "*.so" | while read FULL_EXT; do
install_dylib python/lib/$PYTHON_VER/lib-dynload/ "$FULL_EXT"
done
# Clean up dylib template
rm -f "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist"
echo "Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)..."
find "$CODESIGNING_FOLDER_PATH/Frameworks" -name "*.framework" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der "{}" \;
10. Add Objective C code to initialize and use a Python interpreter in embedded
mode. You should ensure that:
8. Add Objective C code to initialize and use a Python interpreter in embedded
mode. You should ensure that:
* UTF-8 mode (:c:member:`PyPreConfig.utf8_mode`) is *enabled*;
* Buffered stdio (:c:member:`PyConfig.buffered_stdio`) is *disabled*;
@ -309,22 +244,19 @@ To add Python to an iOS Xcode project:
Your app's bundle location can be determined using ``[[NSBundle mainBundle]
resourcePath]``.
Steps 8, 9 and 10 of these instructions assume that you have a single folder of
Steps 7 and 8 of these instructions assume that you have a single folder of
pure Python application code, named ``app``. If you have third-party binary
modules in your app, some additional steps will be required:
* You need to ensure that any folders containing third-party binaries are
either associated with the app target, or copied in as part of step 8. Step 8
should also purge any binaries that are not appropriate for the platform a
specific build is targeting (i.e., delete any device binaries if you're
building an app targeting the simulator).
either associated with the app target, or are explicitly copied as part of
step 7. Step 7 should also purge any binaries that are not appropriate for
the platform a specific build is targeting (i.e., delete any device binaries
if you're building an app targeting the simulator).
* Any folders that contain third-party binaries must be processed into
framework form by step 9. The invocation of ``install_dylib`` that processes
the ``lib-dynload`` folder can be copied and adapted for this purpose.
* If you're using a separate folder for third-party packages, ensure that folder
is included as part of the :envvar:`PYTHONPATH` configuration in step 10.
* If you're using a separate folder for third-party packages, ensure that
folder is added to the end of the call to ``install_python`` in step 7, and
as part of the :envvar:`PYTHONPATH` configuration in step 8.
* If any of the folders that contain third-party packages will contain ``.pth``
files, you should add that folder as a *site directory* (using
@ -334,25 +266,30 @@ modules in your app, some additional steps will be required:
Testing a Python package
------------------------
The CPython source tree contains :source:`a testbed project <iOS/testbed>` that
The CPython source tree contains :source:`a testbed project <Apple/iOS/testbed>` that
is used to run the CPython test suite on the iOS simulator. This testbed can also
be used as a testbed project for running your Python library's test suite on iOS.
After building or obtaining an iOS XCFramework (See :source:`iOS/README.rst`
for details), create a clone of the Python iOS testbed project by running:
After building or obtaining an iOS XCFramework (see :source:`Apple/iOS/README.md`
for details), create a clone of the Python iOS testbed project. If you used the
``Apple`` build script to build the XCframework, you can run:
.. code-block:: bash
$ python iOS/testbed clone --framework <path/to/Python.xcframework> --app <path/to/module1> --app <path/to/module2> app-testbed
$ python cross-build/iOS/testbed clone --app <path/to/module1> --app <path/to/module2> app-testbed
You will need to modify the ``iOS/testbed`` reference to point to that
directory in the CPython source tree; any folders specified with the ``--app``
flag will be copied into the cloned testbed project. The resulting testbed will
be created in the ``app-testbed`` folder. In this example, the ``module1`` and
``module2`` would be importable modules at runtime. If your project has
additional dependencies, they can be installed into the
``app-testbed/iOSTestbed/app_packages`` folder (using ``pip install --target
app-testbed/iOSTestbed/app_packages`` or similar).
Or, if you've sourced your own XCframework, by running:
.. code-block:: bash
$ python Apple/testbed clone --platform iOS --framework <path/to/Python.xcframework> --app <path/to/module1> --app <path/to/module2> app-testbed
Any folders specified with the ``--app`` flag will be copied into the cloned
testbed project. The resulting testbed will be created in the ``app-testbed``
folder. In this example, the ``module1`` and ``module2`` would be importable
modules at runtime. If your project has additional dependencies, they can be
installed into the ``app-testbed/Testbed/app_packages`` folder (using ``pip
install --target app-testbed/Testbed/app_packages`` or similar).
You can then use the ``app-testbed`` folder to run the test suite for your app,
For example, if ``module1.tests`` was the entry point to your test suite, you
@ -381,7 +318,7 @@ tab. Modify the "Arguments Passed On Launch" value to change the testing
arguments.
The test plan also disables parallel testing, and specifies the use of the
``iOSTestbed.lldbinit`` file for providing configuration of the debugger. The
``Testbed.lldbinit`` file for providing configuration of the debugger. The
default debugger configuration disables automatic breakpoints on the
``SIGINT``, ``SIGUSR1``, ``SIGUSR2``, and ``SIGXFSZ`` signals.

View file

@ -2320,7 +2320,7 @@ testios:
fi
# Clone the testbed project into the XCFOLDER
$(PYTHON_FOR_BUILD) $(srcdir)/iOS/testbed clone --framework $(PYTHONFRAMEWORKPREFIX) "$(XCFOLDER)"
$(PYTHON_FOR_BUILD) $(srcdir)/Apple/testbed clone --framework $(PYTHONFRAMEWORKPREFIX) "$(XCFOLDER)"
# Run the testbed project
$(PYTHON_FOR_BUILD) "$(XCFOLDER)" run --verbose -- test -uall --single-process --rerun -W
@ -3248,10 +3248,10 @@ clean-retain-profile: pycremoval
-find build -type f -a ! -name '*.gc??' -exec rm -f {} ';'
-rm -f Include/pydtrace_probes.h
-rm -f profile-gen-stamp
-rm -rf iOS/testbed/Python.xcframework/ios-*/bin
-rm -rf iOS/testbed/Python.xcframework/ios-*/lib
-rm -rf iOS/testbed/Python.xcframework/ios-*/include
-rm -rf iOS/testbed/Python.xcframework/ios-*/Python.framework
-rm -rf Apple/iOS/testbed/Python.xcframework/ios-*/bin
-rm -rf Apple/iOS/testbed/Python.xcframework/ios-*/lib
-rm -rf Apple/iOS/testbed/Python.xcframework/ios-*/include
-rm -rf Apple/iOS/testbed/Python.xcframework/ios-*/Python.framework
.PHONY: profile-removal
profile-removal:
@ -3277,7 +3277,7 @@ clobber: clean
config.cache config.log pyconfig.h Modules/config.c
-rm -rf build platform
-rm -rf $(PYTHONFRAMEWORKDIR)
-rm -rf iOS/Frameworks
-rm -rf Apple/iOS/Frameworks
-rm -rf iOSTestbed.*
-rm -f python-config.py python-config
-rm -rf cross-build

View file

@ -0,0 +1,3 @@
A script for building an iOS XCframework was added. As part of this change,
the top level ``iOS`` folder has been moved to be a subdirectory of the
``Apple`` folder.

8
configure generated vendored
View file

@ -4358,7 +4358,7 @@ then :
yes)
case $ac_sys_system in
Darwin) enableval=/Library/Frameworks ;;
iOS) enableval=iOS/Frameworks/\$\(MULTIARCH\) ;;
iOS) enableval=Apple/iOS/Frameworks/\$\(MULTIARCH\) ;;
*) as_fn_error $? "Unknown platform for framework build" "$LINENO" 5
esac
esac
@ -4469,9 +4469,9 @@ then :
prefix=$PYTHONFRAMEWORKPREFIX
PYTHONFRAMEWORKINSTALLNAMEPREFIX="@rpath/$PYTHONFRAMEWORKDIR"
RESSRCDIR=iOS/Resources
RESSRCDIR=Apple/iOS/Resources
ac_config_files="$ac_config_files iOS/Resources/Info.plist"
ac_config_files="$ac_config_files Apple/iOS/Resources/Info.plist"
;;
*)
@ -35232,7 +35232,7 @@ do
"Mac/PythonLauncher/Makefile") CONFIG_FILES="$CONFIG_FILES Mac/PythonLauncher/Makefile" ;;
"Mac/Resources/framework/Info.plist") CONFIG_FILES="$CONFIG_FILES Mac/Resources/framework/Info.plist" ;;
"Mac/Resources/app/Info.plist") CONFIG_FILES="$CONFIG_FILES Mac/Resources/app/Info.plist" ;;
"iOS/Resources/Info.plist") CONFIG_FILES="$CONFIG_FILES iOS/Resources/Info.plist" ;;
"Apple/iOS/Resources/Info.plist") CONFIG_FILES="$CONFIG_FILES Apple/iOS/Resources/Info.plist" ;;
"Makefile.pre") CONFIG_FILES="$CONFIG_FILES Makefile.pre" ;;
"Misc/python.pc") CONFIG_FILES="$CONFIG_FILES Misc/python.pc" ;;
"Misc/python-embed.pc") CONFIG_FILES="$CONFIG_FILES Misc/python-embed.pc" ;;

View file

@ -559,7 +559,7 @@ AC_ARG_ENABLE([framework],
yes)
case $ac_sys_system in
Darwin) enableval=/Library/Frameworks ;;
iOS) enableval=iOS/Frameworks/\$\(MULTIARCH\) ;;
iOS) enableval=Apple/iOS/Frameworks/\$\(MULTIARCH\) ;;
*) AC_MSG_ERROR([Unknown platform for framework build])
esac
esac
@ -666,9 +666,9 @@ AC_ARG_ENABLE([framework],
prefix=$PYTHONFRAMEWORKPREFIX
PYTHONFRAMEWORKINSTALLNAMEPREFIX="@rpath/$PYTHONFRAMEWORKDIR"
RESSRCDIR=iOS/Resources
RESSRCDIR=Apple/iOS/Resources
AC_CONFIG_FILES([iOS/Resources/Info.plist])
AC_CONFIG_FILES([Apple/iOS/Resources/Info.plist])
;;
*)
AC_MSG_ERROR([Unknown platform for framework build])

View file

@ -1,352 +0,0 @@
====================
Python on iOS README
====================
:Authors:
Russell Keith-Magee (2023-11)
This document provides a quick overview of some iOS specific features in the
Python distribution.
These instructions are only needed if you're planning to compile Python for iOS
yourself. Most users should *not* need to do this. If you're looking to
experiment with writing an iOS app in Python, tools such as `BeeWare's Briefcase
<https://briefcase.readthedocs.io>`__ and `Kivy's Buildozer
<https://buildozer.readthedocs.io>`__ will provide a much more approachable
user experience.
Compilers for building on iOS
=============================
Building for iOS requires the use of Apple's Xcode tooling. It is strongly
recommended that you use the most recent stable release of Xcode. This will
require the use of the most (or second-most) recently released macOS version,
as Apple does not maintain Xcode for older macOS versions. The Xcode Command
Line Tools are not sufficient for iOS development; you need a *full* Xcode
install.
If you want to run your code on the iOS simulator, you'll also need to install
an iOS Simulator Platform. You should be prompted to select an iOS Simulator
Platform when you first run Xcode. Alternatively, you can add an iOS Simulator
Platform by selecting an open the Platforms tab of the Xcode Settings panel.
iOS specific arguments to configure
===================================
* ``--enable-framework[=DIR]``
This argument specifies the location where the Python.framework will be
installed. If ``DIR`` is not specified, the framework will be installed into
a subdirectory of the ``iOS/Frameworks`` folder.
This argument *must* be provided when configuring iOS builds. iOS does not
support non-framework builds.
* ``--with-framework-name=NAME``
Specify the name for the Python framework; defaults to ``Python``.
.. admonition:: Use this option with care!
Unless you know what you're doing, changing the name of the Python
framework on iOS is not advised. If you use this option, you won't be able
to run the ``make testios`` target without making significant manual
alterations, and you won't be able to use any binary packages unless you
compile them yourself using your own framework name.
Building Python on iOS
======================
ABIs and Architectures
----------------------
iOS apps can be deployed on physical devices, and on the iOS simulator. Although
the API used on these devices is identical, the ABI is different - you need to
link against different libraries for an iOS device build (``iphoneos``) or an
iOS simulator build (``iphonesimulator``).
Apple uses the ``XCframework`` format to allow specifying a single dependency
that supports multiple ABIs. An ``XCframework`` is a wrapper around multiple
ABI-specific frameworks that share a common API.
iOS can also support different CPU architectures within each ABI. At present,
there is only a single supported architecture on physical devices - ARM64.
However, the *simulator* supports 2 architectures - ARM64 (for running on Apple
Silicon machines), and x86_64 (for running on older Intel-based machines).
To support multiple CPU architectures on a single platform, Apple uses a "fat
binary" format - a single physical file that contains support for multiple
architectures. It is possible to compile and use a "thin" single architecture
version of a binary for testing purposes; however, the "thin" binary will not be
portable to machines using other architectures.
Building a single-architecture framework
----------------------------------------
The Python build system will create a ``Python.framework`` that supports a
*single* ABI with a *single* architecture. Unlike macOS, iOS does not allow a
framework to contain non-library content, so the iOS build will produce a
``bin`` and ``lib`` folder in the same output folder as ``Python.framework``.
The ``lib`` folder will be needed at runtime to support the Python library.
If you want to use Python in a real iOS project, you need to produce multiple
``Python.framework`` builds, one for each ABI and architecture. iOS builds of
Python *must* be constructed as framework builds. To support this, you must
provide the ``--enable-framework`` flag when configuring the build. The build
also requires the use of cross-compilation. The minimal commands for building
Python for the ARM64 iOS simulator will look something like::
$ export PATH="$(pwd)/iOS/Resources/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin"
$ ./configure \
--enable-framework \
--host=arm64-apple-ios-simulator \
--build=arm64-apple-darwin \
--with-build-python=/path/to/python.exe
$ make
$ make install
In this invocation:
* ``iOS/Resources/bin`` has been added to the path, providing some shims for the
compilers and linkers needed by the build. Xcode requires the use of ``xcrun``
to invoke compiler tooling. However, if ``xcrun`` is pre-evaluated and the
result passed to ``configure``, these results can embed user- and
version-specific paths into the sysconfig data, which limits the portability
of the compiled Python. Alternatively, if ``xcrun`` is used *as* the compiler,
it requires that compiler variables like ``CC`` include spaces, which can
cause significant problems with many C configuration systems which assume that
``CC`` will be a single executable.
To work around this problem, the ``iOS/Resources/bin`` folder contains some
wrapper scripts that present as simple compilers and linkers, but wrap
underlying calls to ``xcrun``. This allows configure to use a ``CC``
definition without spaces, and without user- or version-specific paths, while
retaining the ability to adapt to the local Xcode install. These scripts are
included in the ``bin`` directory of an iOS install.
These scripts will, by default, use the currently active Xcode installation.
If you want to use a different Xcode installation, you can use
``xcode-select`` to set a new default Xcode globally, or you can use the
``DEVELOPER_DIR`` environment variable to specify an Xcode install. The
scripts will use the default ``iphoneos``/``iphonesimulator`` SDK version for
the select Xcode install; if you want to use a different SDK, you can set the
``IOS_SDK_VERSION`` environment variable. (e.g, setting
``IOS_SDK_VERSION=17.1`` would cause the scripts to use the ``iphoneos17.1``
and ``iphonesimulator17.1`` SDKs, regardless of the Xcode default.)
The path has also been cleared of any user customizations. A common source of
bugs is for tools like Homebrew to accidentally leak macOS binaries into an iOS
build. Resetting the path to a known "bare bones" value is the easiest way to
avoid these problems.
* ``--host`` is the architecture and ABI that you want to build, in GNU compiler
triple format. This will be one of:
- ``arm64-apple-ios`` for ARM64 iOS devices.
- ``arm64-apple-ios-simulator`` for the iOS simulator running on Apple
Silicon devices.
- ``x86_64-apple-ios-simulator`` for the iOS simulator running on Intel
devices.
* ``--build`` is the GNU compiler triple for the machine that will be running
the compiler. This is one of:
- ``arm64-apple-darwin`` for Apple Silicon devices.
- ``x86_64-apple-darwin`` for Intel devices.
* ``/path/to/python.exe`` is the path to a Python binary on the machine that
will be running the compiler. This is needed because the Python compilation
process involves running some Python code. On a normal desktop build of
Python, you can compile a python interpreter and then use that interpreter to
run Python code. However, the binaries produced for iOS won't run on macOS, so
you need to provide an external Python interpreter. This interpreter must be
the same version as the Python that is being compiled. To be completely safe,
this should be the *exact* same commit hash. However, the longer a Python
release has been stable, the more likely it is that this constraint can be
relaxed - the same micro version will often be sufficient.
* The ``install`` target for iOS builds is slightly different to other
platforms. On most platforms, ``make install`` will install the build into
the final runtime location. This won't be the case for iOS, as the final
runtime location will be on a physical device.
However, you still need to run the ``install`` target for iOS builds, as it
performs some final framework assembly steps. The location specified with
``--enable-framework`` will be the location where ``make install`` will
assemble the complete iOS framework. This completed framework can then
be copied and relocated as required.
For a full CPython build, you also need to specify the paths to iOS builds of
the binary libraries that CPython depends on (XZ, BZip2, LibFFI and OpenSSL).
This can be done by defining the ``LIBLZMA_CFLAGS``, ``LIBLZMA_LIBS``,
``BZIP2_CFLAGS``, ``BZIP2_LIBS``, ``LIBFFI_CFLAGS``, and ``LIBFFI_LIBS``
environment variables, and the ``--with-openssl`` configure option. Versions of
these libraries pre-compiled for iOS can be found in `this repository
<https://github.com/beeware/cpython-apple-source-deps/releases>`__. LibFFI is
especially important, as many parts of the standard library (including the
``platform``, ``sysconfig`` and ``webbrowser`` modules) require the use of the
``ctypes`` module at runtime.
By default, Python will be compiled with an iOS deployment target (i.e., the
minimum supported iOS version) of 13.0. To specify a different deployment
target, provide the version number as part of the ``--host`` argument - for
example, ``--host=arm64-apple-ios15.4-simulator`` would compile an ARM64
simulator build with a deployment target of 15.4.
Merge thin frameworks into fat frameworks
-----------------------------------------
Once you've built a ``Python.framework`` for each ABI and architecture, you
must produce a "fat" framework for each ABI that contains all the architectures
for that ABI.
The ``iphoneos`` build only needs to support a single architecture, so it can be
used without modification.
If you only want to support a single simulator architecture, (e.g., only support
ARM64 simulators), you can use a single architecture ``Python.framework`` build.
However, if you want to create ``Python.xcframework`` that supports *all*
architectures, you'll need to merge the ``iphonesimulator`` builds for ARM64 and
x86_64 into a single "fat" framework.
The "fat" framework can be constructed by performing a directory merge of the
content of the two "thin" ``Python.framework`` directories, plus the ``bin`` and
``lib`` folders for each thin framework. When performing this merge:
* The pure Python standard library content is identical for each architecture,
except for a handful of platform-specific files (such as the ``sysconfig``
module). Ensure that the "fat" framework has the union of all standard library
files.
* Any binary files in the standard library, plus the main
``libPython3.X.dylib``, can be merged using the ``lipo`` tool, provide by
Xcode::
$ lipo -create -output module.dylib path/to/x86_64/module.dylib path/to/arm64/module.dylib
* The header files will be identical on both architectures, except for
``pyconfig.h``. Copy all the headers from one platform (say, arm64), rename
``pyconfig.h`` to ``pyconfig-arm64.h``, and copy the ``pyconfig.h`` for the
other architecture into the merged header folder as ``pyconfig-x86_64.h``.
Then copy the ``iOS/Resources/pyconfig.h`` file from the CPython sources into
the merged headers folder. This will allow the two Python architectures to
share a common ``pyconfig.h`` header file.
At this point, you should have 2 Python.framework folders - one for ``iphoneos``,
and one for ``iphonesimulator`` that is a merge of x86+64 and ARM64 content.
Merge frameworks into an XCframework
------------------------------------
Now that we have 2 (potentially fat) ABI-specific frameworks, we can merge those
frameworks into a single ``XCframework``.
The initial skeleton of an ``XCframework`` is built using::
xcodebuild -create-xcframework -output Python.xcframework -framework path/to/iphoneos/Python.framework -framework path/to/iphonesimulator/Python.framework
Then, copy the ``bin`` and ``lib`` folders into the architecture-specific slices of
the XCframework::
cp path/to/iphoneos/bin Python.xcframework/ios-arm64
cp path/to/iphoneos/lib Python.xcframework/ios-arm64
cp path/to/iphonesimulator/bin Python.xcframework/ios-arm64_x86_64-simulator
cp path/to/iphonesimulator/lib Python.xcframework/ios-arm64_x86_64-simulator
Note that the name of the architecture-specific slice for the simulator will
depend on the CPU architecture(s) that you build.
You now have a Python.xcframework that can be used in a project.
Testing Python on iOS
=====================
The ``iOS/testbed`` folder that contains an Xcode project that is able to run
the iOS test suite. This project converts the Python test suite into a single
test case in Xcode's XCTest framework. The single XCTest passes if the test
suite passes.
To run the test suite, configure a Python build for an iOS simulator (i.e.,
``--host=arm64-apple-ios-simulator`` or ``--host=x86_64-apple-ios-simulator``
), specifying a framework build (i.e. ``--enable-framework``). Ensure that your
``PATH`` has been configured to include the ``iOS/Resources/bin`` folder and
exclude any non-iOS tools, then run::
$ make all
$ make install
$ make testios
This will:
* Build an iOS framework for your chosen architecture;
* Finalize the single-platform framework;
* Make a clean copy of the testbed project;
* Install the Python iOS framework into the copy of the testbed project; and
* Run the test suite on an "iPhone SE (3rd generation)" simulator.
On success, the test suite will exit and report successful completion of the
test suite. On a 2022 M1 MacBook Pro, the test suite takes approximately 15
minutes to run; a couple of extra minutes is required to compile the testbed
project, and then boot and prepare the iOS simulator.
Debugging test failures
-----------------------
Running ``make testios`` generates a standalone version of the ``iOS/testbed``
project, and runs the full test suite. It does this using ``iOS/testbed``
itself - the folder is an executable module that can be used to create and run
a clone of the testbed project.
You can generate your own standalone testbed instance by running::
$ python iOS/testbed clone --framework iOS/Frameworks/arm64-iphonesimulator my-testbed
This invocation assumes that ``iOS/Frameworks/arm64-iphonesimulator`` is the
path to the iOS simulator framework for your platform (ARM64 in this case);
``my-testbed`` is the name of the folder for the new testbed clone.
You can then use the ``my-testbed`` folder to run the Python test suite,
passing in any command line arguments you may require. For example, if you're
trying to diagnose a failure in the ``os`` module, you might run::
$ python my-testbed run -- test -W test_os
This is the equivalent of running ``python -m test -W test_os`` on a desktop
Python build. Any arguments after the ``--`` will be passed to testbed as if
they were arguments to ``python -m`` on a desktop machine.
Testing in Xcode
^^^^^^^^^^^^^^^^
You can also open the testbed project in Xcode by running::
$ open my-testbed/iOSTestbed.xcodeproj
This will allow you to use the full Xcode suite of tools for debugging.
The arguments used to run the test suite are defined as part of the test plan.
To modify the test plan, select the test plan node of the project tree (it
should be the first child of the root node), and select the "Configurations"
tab. Modify the "Arguments Passed On Launch" value to change the testing
arguments.
The test plan also disables parallel testing, and specifies the use of the
``iOSTestbed.lldbinit`` file for providing configuration of the debugger. The
default debugger configuration disables automatic breakpoints on the
``SIGINT``, ``SIGUSR1``, ``SIGUSR2``, and ``SIGXFSZ`` signals.
Testing on an iOS device
^^^^^^^^^^^^^^^^^^^^^^^^
To test on an iOS device, the app needs to be signed with known developer
credentials. To obtain these credentials, you must have an iOS Developer
account, and your Xcode install will need to be logged into your account (see
the Accounts tab of the Preferences dialog).
Once the project is open, and you're signed into your Apple Developer account,
select the root node of the project tree (labeled "iOSTestbed"), then the
"Signing & Capabilities" tab in the details page. Select a development team
(this will likely be your own name), and plug in a physical device to your
macOS machine with a USB cable. You should then be able to select your physical
device from the list of targets in the pulldown in the Xcode titlebar.

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string></string>
<key>CFBundleIdentifier</key>
<string></string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>MinimumOSVersion</key>
<string>12.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>