From a0c7904af042b0206cf2d4f8cc26f1d45b45c8d2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Oct 2026 09:07:35 +0800 Subject: [PATCH 1/3] Modify iOS testbed to support free-threaded builds. --- .gitignore | 8 +- Makefile.pre.in | 8 +- Platforms/Apple/__main__.py | 124 ++++++++--- Platforms/Apple/iOS/README.md | 69 +++++- Platforms/Apple/iOS/Resources/Info.plist.in | 4 +- .../testbed/Python.xcframework/Info.plist | 8 +- .../testbed/Python.xcframework/build/utils.sh | 2 +- .../Apple/testbed/TestbedTests/TestbedTests.m | 2 +- Platforms/Apple/testbed/__main__.py | 202 ++++++++++++++++-- .../iOSTestbed.xcodeproj/project.pbxproj | 30 +-- 10 files changed, 367 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 00813f9d1b6b00a..2fce2ac10ae9a74 100644 --- a/.gitignore +++ b/.gitignore @@ -76,10 +76,10 @@ Lib/test/data/* 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 +Platforms/Apple/testbed/Python*.xcframework/*/bin +Platforms/Apple/testbed/Python*.xcframework/*/include +Platforms/Apple/testbed/Python*.xcframework/*/lib +Platforms/Apple/testbed/Python*.xcframework/*/Python*.framework Apple/testbed/*Testbed.xcodeproj/project.xcworkspace Apple/testbed/*Testbed.xcodeproj/xcuserdata Mac/Makefile diff --git a/Makefile.pre.in b/Makefile.pre.in index 36515dbd146a1e3..308fb964a0cd57e 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -2286,7 +2286,9 @@ testuniversal: all # Run the test suite on the iOS simulator. Must be run on a macOS machine with # a full Xcode install that has an iPhone SE (3rd edition) simulator available. # This must be run *after* a `make install` has completed the build. The -# `--with-framework-name` argument *cannot* be used when configuring the build. +# testbed is configured for the framework name used to configure the build; +# that name is expected to be `Python`, optionally followed by uppercased ABI +# flags (e.g., `PythonT`). XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s).$$PPID .PHONY: testios testios: @@ -2298,10 +2300,6 @@ testios: echo "Cannot run the iOS testbed for non-simulator builds."; \ exit 1;\ fi - @if test $(PYTHONFRAMEWORK) != "Python"; then \ - echo "Cannot run the iOS testbed with a non-default framework name."; \ - exit 1;\ - fi @if ! test -d $(PYTHONFRAMEWORKPREFIX); then \ echo "Cannot find a finalized iOS Python.framework. Have you run 'make install' to finalize the framework build?"; \ exit 1;\ diff --git a/Platforms/Apple/__main__.py b/Platforms/Apple/__main__.py index 86efbd20eac9efa..fa40f45ae2805cc 100644 --- a/Platforms/Apple/__main__.py +++ b/Platforms/Apple/__main__.py @@ -77,6 +77,45 @@ }, } +# The configure arguments needed to produce each ABI flag. Flags are listed in +# the order that configure adds them to ABIFLAGS; any `--abiflags` value must +# use the same order. +ABIFLAG_CONFIGURE_ARGS: dict[str, list[str]] = { + "t": ["--disable-gil"], + "d": ["--with-pydebug"], +} + + +def validate_abiflags(value: str) -> str: + """Validate an `--abiflags` value (for use as an argparse type).""" + known = "".join(ABIFLAG_CONFIGURE_ARGS) + for flag in value: + if flag not in ABIFLAG_CONFIGURE_ARGS: + raise argparse.ArgumentTypeError( + f"unknown ABI flag {flag!r} (known flags: {known!r})" + ) + positions = [known.index(flag) for flag in value] + if positions != sorted(set(positions)): + raise argparse.ArgumentTypeError( + f"ABI flags must be unique, and in the order {known!r}" + ) + return value + + +def framework_name(abiflags: str) -> str: + """The name of the Python framework for the given ABI flags.""" + return f"Python{abiflags.upper()}" + + +def abiflag_configure_args(abiflags: str) -> list[str]: + """The configure arguments needed to produce the given ABI flags.""" + return [arg for flag in abiflags for arg in ABIFLAG_CONFIGURE_ARGS[flag]] + + +def cross_build_dir(base: Path, abiflags: str) -> Path: + """The cross-build directory for the given base and ABI flags.""" + return base.with_name(f"{base.name}-{abiflags}") if abiflags else base + def subdir(name: str, create: bool = False) -> Path: """Ensure that a cross-build directory for the given name exists.""" @@ -258,6 +297,7 @@ def configure_build_python(context: argparse.Namespace) -> None: cwd(subdir("build", create=True)), ): command = [relpath(PYTHON_DIR / "configure")] + command.extend(abiflag_configure_args(context.abiflags)) if context.args: command.extend(context.args) run(command) @@ -420,6 +460,11 @@ def configure_host_python( f"LIBZSTD_CFLAGS=-I{prefix_dir}/include", f"LIBZSTD_LIBS=-L{prefix_dir}/lib -lzstd", ] + command.extend(abiflag_configure_args(context.abiflags)) + if context.abiflags: + command.append( + f"--with-framework-name={framework_name(context.abiflags)}" + ) if context.args: command.extend(context.args) @@ -521,11 +566,16 @@ def lib_non_platform_files(dirname, names): return set() -def create_xcframework(platform: str) -> str: +def create_xcframework(platform: str, abiflags: str) -> str: """Build an XCframework from the component parts for the platform. + :param platform: The platform being packaged (e.g., iOS). + :param abiflags: The ABI flags of the build (e.g., "t"). :return: The version number of the Python version that was packaged. """ + fw_name = framework_name(abiflags) + # The stdlib directory only carries the threading ABI flag. + abi_thread = "t" if "t" in abiflags else "" package_path = CROSS_BUILD_DIR / platform try: package_path.mkdir() @@ -547,7 +597,7 @@ def create_xcframework(platform: str) -> str: first_host_triple, first_multiarch = next(iter(slice_parts.items())) first_framework = ( framework_path(first_host_triple, first_multiarch) - / "Python.framework" + / f"{fw_name}.framework" ) if len(slice_parts) == 1: @@ -557,7 +607,7 @@ def create_xcframework(platform: str) -> str: else: print(f"Merging framework for {slice_name}...") slice_path = CROSS_BUILD_DIR / slice_name - slice_framework = slice_path / "Python.framework" + slice_framework = slice_path / f"{fw_name}.framework" slice_framework.mkdir(exist_ok=True, parents=True) # Copy the Info.plist @@ -574,11 +624,11 @@ def create_xcframework(platform: str) -> str: # Create the "fat" library binary for the slice run( - ["lipo", "-create", "-output", slice_framework / "Python"] + ["lipo", "-create", "-output", slice_framework / fw_name] + [ ( framework_path(host_triple, multiarch) - / "Python.framework/Python" + / f"{fw_name}.framework/{fw_name}" ) for host_triple, multiarch in slice_parts.items() ] @@ -593,7 +643,7 @@ def create_xcframework(platform: str) -> str: "xcodebuild", "-create-xcframework", "-output", - package_path / "Python.xcframework", + package_path / f"{fw_name}.xcframework", ] for framework in frameworks: cmd.extend(["-framework", framework]) @@ -601,7 +651,7 @@ def create_xcframework(platform: str) -> str: run(cmd) # Extract the package version from the merged framework - version = package_version(package_path / "Python.xcframework") + version = package_version(package_path / f"{fw_name}.xcframework") version_tag = ".".join(version.split(".")[:2]) # On non-macOS platforms, each framework in XCframework only contains the @@ -616,10 +666,10 @@ def create_xcframework(platform: str) -> str: # 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" + first_framework = first_path / f"{fw_name}.framework" - slice_path = package_path / f"Python.xcframework/{slice_name}" - slice_framework = slice_path / "Python.framework" + slice_path = package_path / f"{fw_name}.xcframework/{slice_name}" + slice_framework = slice_path / f"{fw_name}.framework" # Copy the binary helpers print(f" - {slice_name} binaries") @@ -641,9 +691,9 @@ def create_xcframework(platform: str) -> str: print(f" - {slice_name} shared library") # Create a simlink for the fat library - shared_lib = slice_path / f"lib/libpython{version_tag}.dylib" + shared_lib = slice_path / f"lib/libpython{version_tag}{abiflags}.dylib" shared_lib.parent.mkdir() - shared_lib.symlink_to("../Python.framework/Python") + shared_lib.symlink_to(f"../{fw_name}.framework/{fw_name}") print(f" - {slice_name} architecture-specific files") for host_triple, multiarch in slice_parts.items(): @@ -654,7 +704,7 @@ def create_xcframework(platform: str) -> str: print(" - using this architecture as the common stdlib") shutil.copytree( framework_path(host_triple, multiarch) / "lib", - package_path / "Python.xcframework/lib", + package_path / f"{fw_name}.xcframework/lib", ignore=lib_platform_files, symlinks=True, ) @@ -675,7 +725,7 @@ def create_xcframework(platform: str) -> str: / "Platforms/Apple/iOS/Frameworks" / multiarch ) - host_framework = host_path / "Python.framework" + host_framework = host_path / f"{fw_name}.framework" shutil.copy( host_framework / "Headers/pyconfig.h", slice_framework / f"Headers/pyconfig-{arch}.h", @@ -696,14 +746,17 @@ def create_xcframework(platform: str) -> str: shutil.copy( xcprivacy_file[lib], slice_path - / f"lib-{arch}/python{version_tag}" + / f"lib-{arch}/python{version_tag}{abi_thread}" / f"lib-dynload/{module}.xcprivacy", ) print(" - build tools") shutil.copytree( - PYTHON_DIR / "Platforms/Apple/testbed/Python.xcframework/build", - package_path / "Python.xcframework/build", + PYTHON_DIR + / "Platforms/Apple/testbed" + / "Python.xcframework" + / "build", + package_path / f"{fw_name}.xcframework/build", ) return version @@ -716,7 +769,8 @@ def package(context: argparse.Namespace) -> None: with group("Building package"): # Create an XCframework - version = create_xcframework(context.platform) + version = create_xcframework(context.platform, context.abiflags) + fw_name = framework_name(context.abiflags) # Clone testbed print() @@ -727,7 +781,7 @@ def package(context: argparse.Namespace) -> None: "--platform", context.platform, "--framework", - CROSS_BUILD_DIR / context.platform / "Python.xcframework", + CROSS_BUILD_DIR / context.platform / f"{fw_name}.xcframework", CROSS_BUILD_DIR / context.platform / "testbed", ]) @@ -735,7 +789,10 @@ def package(context: argparse.Namespace) -> None: archive_name = ( CROSS_BUILD_DIR / "dist" - / f"python-{version}-{context.platform}-XCframework" + / ( + f"python-{version}{context.abiflags}" + f"-{context.platform}-XCframework" + ) ) print() @@ -799,7 +856,9 @@ def test(context: argparse.Namespace, host: str | None = None) -> None: # noqa: ) if host in {"all", "hosts"}: framework_path = ( - CROSS_BUILD_DIR / context.platform / "Python.xcframework" + CROSS_BUILD_DIR + / context.platform + / f"{framework_name(context.abiflags)}.xcframework" ) else: build_arch = platform.machine() @@ -974,7 +1033,7 @@ def parse_args() -> argparse.Namespace: ), ) - # --cross-build-dir argument + # --cross-build-dir and --abiflags arguments for cmd in [ clean, configure_build, @@ -994,9 +1053,20 @@ def parse_args() -> argparse.Namespace: dest="cross_build_dir", type=Path, help=( - "Path to the cross-build directory " + "Base path of the cross-build directory " f"(default: {CROSS_BUILD_DIR}). Can also be set " - "with the CROSS_BUILD_DIR environment variable." + "with the CROSS_BUILD_DIR environment variable. If " + "--abiflags is specified, '-' is appended." + ), + ) + cmd.add_argument( + "--abiflags", + default="", + type=validate_abiflags, + help=( + "The ABI flags of the build: 't' (free-threaded; " + "--disable-gil), 'd' (debug; --with-pydebug), or 'td' (both). " + "Defaults to '' (a standard build)." ), ) @@ -1082,10 +1152,12 @@ def signal_handler(*args): # Process command line arguments context = parse_args() - # Set the CROSS_BUILD_DIR if an argument was provided + # Set the CROSS_BUILD_DIR, using any provided base directory, plus any ABI + # flags as a suffix. + global CROSS_BUILD_DIR if context.cross_build_dir: - global CROSS_BUILD_DIR CROSS_BUILD_DIR = context.cross_build_dir.resolve() + CROSS_BUILD_DIR = cross_build_dir(CROSS_BUILD_DIR, context.abiflags) dispatch: dict[str, Callable] = { "clean": clean, diff --git a/Platforms/Apple/iOS/README.md b/Platforms/Apple/iOS/README.md index 1a03664cd837683..975c3056e98e0ae 100644 --- a/Platforms/Apple/iOS/README.md +++ b/Platforms/Apple/iOS/README.md @@ -75,6 +75,39 @@ 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 with ABI flags + +By default, the `Platforms/Apple` build script produces a GIL-enabled, +non-debug build of Python. The `--abiflags` option can be passed to any +command of the build script to build a variant of Python with different +ABI flags: + +* `t` - a free-threaded build (`--disable-gil`) +* `d` - a debug build (`--with-pydebug`) +* `td` - a free-threaded debug build + +For example, to build and test a free-threaded XCframework, run: + + python Platforms/Apple ci iOS --abiflags t + +When ABI flags are specified: + +* The ABI flags are appended to the name of the cross-build directory. The + free-threaded example above uses `cross-build-t`. If you specify a custom + location with `--cross-build-dir` (or the `CROSS_BUILD_DIR` environment + variable), that location is used as the base name, so `--cross-build-dir foo + --abiflags t` uses `foo-t`. This lets builds with different ABI flags + co-exist. +* The framework is named `Python` followed by the uppercased ABI flags. For + example, a free-threaded build produces `PythonT.framework` and + `PythonT.xcframework`, and its headers are included as + `#include `. +* The release archive includes the ABI flags in the version, e.g. + `python-3.X.Yt-iOS-XCframework.tar.gz`. + +The same `--abiflags` value must be passed to every command that operates on a +given build. + ### Building a single-architecture framework If you're using the `Platforms/Apple` build script, you won't need to build @@ -102,11 +135,13 @@ Python build for a single framework, the following options are available. 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 `Platforms/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. + > Builds that use ABI flags (such as free-threaded or debug builds) should + > use a framework name of `Python`, followed by the uppercased ABI flags + > (e.g., `PythonT` for a free-threaded build). The `Platforms/Apple` build + > script and the testbed rely on this naming convention. Other than this + > convention, changing the name of the Python framework on iOS is not + > advised; you won't be able to use any binary packages unless you compile + > them yourself using your own framework name. #### Building Python for iOS @@ -339,10 +374,11 @@ project, and then boot and prepare the iOS simulator. Running `python Platforms/Apple test iOS` generates a standalone version of the `Platforms/Apple/testbed` project, and runs the full test suite. It does this using -`Platforms/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.`. +`Platforms/Apple/testbed` itself - the folder is a template for 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.`. Builds that use ABI flags use the +`cross-build-` directory instead (e.g., `cross-build-t`). You can generate your own standalone testbed instance by running: ``` @@ -358,9 +394,22 @@ you can construct a standalone testbed instance by running: python Platforms/Apple/testbed clone --platform iOS --framework my-testbed ``` -The framework path can be the path path to a `Python.xcframework`, or the +The framework path can be the path to a `Python.xcframework`, or the path to a folder that contains a single-platform `Python.framework`. +The `Platforms/Apple/testbed` folder is a template. Any reference to the +Python framework uses the placeholder `Python{{abiflags}}`. When the template +project is cloned, the placeholder is replaced with the name of the framework +being installed; the ABI flags are inferred automatically from the +`--framework` argument. For example, to clone a testbed for a free-threaded +framework: +``` +python Platforms/Apple/testbed clone --platform iOS --framework my-testbed +``` + +A cloned testbed is configured for one specific framework. It can be cloned +again, but it can't be re-targeted to a framework with different ABI flags. + 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: diff --git a/Platforms/Apple/iOS/Resources/Info.plist.in b/Platforms/Apple/iOS/Resources/Info.plist.in index c3e261ecd9eff70..032f6aa362224d3 100644 --- a/Platforms/Apple/iOS/Resources/Info.plist.in +++ b/Platforms/Apple/iOS/Resources/Info.plist.in @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleExecutable - Python + @PYTHONFRAMEWORK@ CFBundleGetInfoString Python Runtime and Library CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Python + @PYTHONFRAMEWORK@ CFBundlePackageType FMWK CFBundleShortVersionString diff --git a/Platforms/Apple/testbed/Python.xcframework/Info.plist b/Platforms/Apple/testbed/Python.xcframework/Info.plist index c6418de6e74a4e9..653a5386e4601e0 100644 --- a/Platforms/Apple/testbed/Python.xcframework/Info.plist +++ b/Platforms/Apple/testbed/Python.xcframework/Info.plist @@ -6,11 +6,11 @@ BinaryPath - Python.framework/Python + Python{{abiflags}}.framework/Python{{abiflags}} LibraryIdentifier ios-arm64 LibraryPath - Python.framework + Python{{abiflags}}.framework SupportedArchitectures arm64 @@ -20,11 +20,11 @@ BinaryPath - Python.framework/Python + Python{{abiflags}}.framework/Python{{abiflags}} LibraryIdentifier ios-arm64_x86_64-simulator LibraryPath - Python.framework + Python{{abiflags}}.framework SupportedArchitectures arm64 diff --git a/Platforms/Apple/testbed/Python.xcframework/build/utils.sh b/Platforms/Apple/testbed/Python.xcframework/build/utils.sh index e54471f68b7cb2c..ad3e62f9f53bd5a 100755 --- a/Platforms/Apple/testbed/Python.xcframework/build/utils.sh +++ b/Platforms/Apple/testbed/Python.xcframework/build/utils.sh @@ -140,7 +140,7 @@ install_python() { shift install_stdlib $PYTHON_XCFRAMEWORK_PATH - PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib" | grep -E "^python3\.\d+$") + PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib" | grep -E "^python3\.\d+[a-z]*$") echo "Install Python $PYTHON_VER standard library extension modules..." process_dylibs $PYTHON_XCFRAMEWORK_PATH python/lib/$PYTHON_VER/lib-dynload diff --git a/Platforms/Apple/testbed/TestbedTests/TestbedTests.m b/Platforms/Apple/testbed/TestbedTests/TestbedTests.m index f7788c47f2c2296..435872e5e162199 100644 --- a/Platforms/Apple/testbed/TestbedTests/TestbedTests.m +++ b/Platforms/Apple/testbed/TestbedTests/TestbedTests.m @@ -1,5 +1,5 @@ #import -#import +#import @interface TestbedTests : XCTestCase diff --git a/Platforms/Apple/testbed/__main__.py b/Platforms/Apple/testbed/__main__.py index c4c60e99ca974de..697f5d64b835b01 100644 --- a/Platforms/Apple/testbed/__main__.py +++ b/Platforms/Apple/testbed/__main__.py @@ -12,6 +12,15 @@ "iOS": "ios-arm64_x86_64-simulator", } +# The checked-in testbed is a template. Any file containing this placeholder +# is rewritten by `clone`, replacing the placeholder with the (uppercased) +# ABI flags of the framework being installed. The placeholder is constructed +# so that it doesn't appear literally in this file. +ABIFLAGS_PLACEHOLDER = "{{" + "abiflags" + "}}" + +FRAMEWORK_NAME_RE = re.compile(r"Python([A-Z]*)") + + DECODE_ARGS = ("UTF-8", "backslashreplace") # The system log prefixes each line: @@ -127,37 +136,156 @@ def copy(src, tgt): shutil.copytree(src, tgt, symlinks=True) +def framework_name(abiflags: str) -> str: + """The name of the Python framework for the given ABI flags.""" + return f"Python{abiflags.upper()}" + + +def is_template(location: Path) -> bool: + """Is the testbed at the given location an unexpanded template? + + A template's XCframework `Info.plist` still contains the literal + ABIFLAGS_PLACEHOLDER; once expanded, the placeholder is gone. + """ + info_plist = location / "Python.xcframework/Info.plist" + return ( + info_plist.is_file() + and ABIFLAGS_PLACEHOLDER.encode() in info_plist.read_bytes() + ) + + +def abiflags_from_framework(framework: Path) -> str: + """Infer the (lowercase) ABI flags from a `--framework` path. + + `framework` may be a `Python{FLAGS}.xcframework`, or a directory + containing a single simulator-slice `Python{FLAGS}.framework`. + + Raises ValueError if the ABI flags can't be determined. + """ + if framework.suffix == ".xcframework": + match = FRAMEWORK_NAME_RE.fullmatch(framework.stem) + if not match: + raise ValueError(f"{framework} is not a valid Python XCframework.") + return match.group(1).lower() + + candidates = sorted( + path + for path in framework.glob("Python*.framework") + if FRAMEWORK_NAME_RE.fullmatch(path.stem) + ) + if len(candidates) != 1: + raise ValueError( + f"Expected exactly one Python framework in {framework}; " + f"found {len(candidates)}." + ) + return FRAMEWORK_NAME_RE.fullmatch(candidates[0].stem).group(1).lower() + + +def find_xcframework(location: Path) -> Path: + """Find the Python XCframework in a testbed project. + + Raises ValueError if more than one XCframework is found. + """ + candidates = sorted( + path + for path in location.glob("Python*.xcframework") + if re.fullmatch(r"Python[A-Z]*\.xcframework", path.name) + ) + if len(candidates) != 1: + raise ValueError( + f"Expected exactly one Python XCframework in {location}; " + f"found {len(candidates)}." + ) + return candidates[0] + + +def expand_template(location: Path, abiflags: str) -> None: + """Expand the ABI flags placeholder in a cloned testbed template. + + The placeholder is replaced in the content of every template file, then + any file or directory whose name contains the placeholder is renamed. + Symlinks are neither followed nor modified. + """ + placeholder = ABIFLAGS_PLACEHOLDER + replacement = abiflags.upper() + for path in location.rglob("*"): + if ( + path.suffix in {".m", ".pbxproj", ".plist"} + and path.is_file() + and not path.is_symlink() + ): + content = path.read_text(encoding="utf-8") + if placeholder in content: + path.write_text( + content.replace(placeholder, replacement), + encoding="utf-8", + ) + + def clone_testbed( source: Path, target: Path, - framework: Path, + framework: Path | None, platform: str, apps: list[Path], ) -> None: if target.exists(): print(f"{target} already exists; aborting without creating project.") sys.exit(10) + source_is_template = is_template(source) + try: + orig_xc_framework_path = find_xcframework(source) + except ValueError as e: + print(e) + sys.exit(15) + fw_name = orig_xc_framework_path.stem if framework is None: - if not ( - source / "Python.xcframework" / TEST_SLICES[platform] / "bin" - ).is_dir(): + # No --framework; we must be cloning an an already-expanded testbed. + # Raise an error if we're not. + if source_is_template: print( - f"The testbed being cloned ({source}) does not contain " - "a framework with slices. Re-run with --framework" + "The initial testbed clone must provide a framework. " + "Re-run using --framework." ) sys.exit(11) + else: + # Look at the simulator slice in the existing framework; if + # it's a live framework, it will have a `bin` directory. + slice_path = orig_xc_framework_path / TEST_SLICES[platform] + if not (slice_path / "bin").is_dir(): + print( + f"The testbed being cloned ({source}) does not contain " + "a framework with slices. Re-run with --framework" + ) + sys.exit(11) + else: if not framework.is_dir(): print(f"{framework} does not exist.") sys.exit(12) + + if source_is_template: + # Initial testbed clone; the target framework name is + # taken directly from the provided framework, not the + # existing framework in the testbed. + try: + abiflags = abiflags_from_framework(framework) + except ValueError as e: + print(e) + sys.exit(13) + + fw_name = framework_name(abiflags) + elif not ( - framework.suffix == ".xcframework" - or (framework / "Python.framework").is_dir() + framework.name == f"{fw_name}.xcframework" + or (framework / f"{fw_name}.framework").is_dir() ): + # Re-cloning an already-expanded testbed: the ABI for the + # new framework must match the existing testbed. print( - f"{framework} is not an XCframework, " - f"or a simulator slice of a framework build." + f"{framework} is not a {fw_name}.xcframework, " + f"or a simulator slice of a {fw_name}.framework build." ) sys.exit(13) @@ -179,9 +307,23 @@ def clone_testbed( print(" done") - orig_xc_framework_path = source / "Python.xcframework" - xc_framework_path = target / "Python.xcframework" + xc_framework_path = target / f"{fw_name}.xcframework" test_framework_path = xc_framework_path / TEST_SLICES[platform] + + if source_is_template: + # Unless an XCframework is being symlinked in, the testbed uses a copy + # of the template XCframework (either as-is, or with a simulator slice + # installed into it); that copy requires expansion. + if framework.suffix != ".xcframework": + shutil.copytree( + orig_xc_framework_path, + xc_framework_path, + symlinks=True, + ) + print(f" Expanding template for {fw_name}...", end="") + expand_template(target, abiflags) + print(" done") + if framework is not None: if framework.suffix == ".xcframework": print(" Installing XCFramework...", end="") @@ -193,9 +335,18 @@ def clone_testbed( print(" Installing simulator framework...", end="") # 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(): + # symlinked content. If this is an initial template clone, + # this copy has already been done as part of rolling out + # the template. + if not source_is_template: + shutil.copytree( + orig_xc_framework_path, xc_framework_path, symlinks=True + ) + + if ( + test_framework_path.is_dir() + and not test_framework_path.is_symlink() + ): shutil.rmtree(test_framework_path) else: test_framework_path.unlink(missing_ok=True) @@ -231,7 +382,7 @@ def clone_testbed( # Simulator framework is a relative symlink. Rewrite the symlink # relative to the new location. orig_test_framework_path = ( - source / "Python.XCframework" / test_framework_path.readlink() + orig_xc_framework_path / test_framework_path.readlink() ).resolve() test_framework_path.unlink() test_framework_path.symlink_to( @@ -327,7 +478,9 @@ def main(): "--framework", help=( "The location of the XCFramework (or simulator-only slice of an " - "XCFramework) to use when running the testbed" + "XCFramework) to use when running the testbed. The framework's " + "ABI flags (e.g., free-threaded, debug) are inferred from its " + "name." ), ) clone.add_argument( @@ -408,12 +561,17 @@ def main(): ) elif context.subcommand == "run": if test_args: + try: + xc_framework_path = find_xcframework(Path(__file__).parent) + except ValueError: + xc_framework_path = None + if not ( - Path(__file__).parent - / "Python.xcframework" - / TEST_SLICES[context.platform] - / "bin" - ).is_dir(): + xc_framework_path + and ( + xc_framework_path / TEST_SLICES[context.platform] / "bin" + ).is_dir() + ): print( "Testbed does not contain a compiled Python framework. " f"Use `python {sys.argv[0]} clone ...` to create a " diff --git a/Platforms/Apple/testbed/iOSTestbed.xcodeproj/project.pbxproj b/Platforms/Apple/testbed/iOSTestbed.xcodeproj/project.pbxproj index a0dd6851dc103da..6ac40a325045bd6 100644 --- a/Platforms/Apple/testbed/iOSTestbed.xcodeproj/project.pbxproj +++ b/Platforms/Apple/testbed/iOSTestbed.xcodeproj/project.pbxproj @@ -7,10 +7,10 @@ objects = { /* Begin PBXBuildFile section */ - 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, ); }; }; + 607A664C2B0EFC080010BFC8 /* Python{{abiflags}}.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */; }; + 607A664D2B0EFC080010BFC8 /* Python{{abiflags}}.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 607A66502B0EFFE00010BFC8 /* Python{{abiflags}}.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */; }; + 607A66512B0EFFE00010BFC8 /* Python{{abiflags}}.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -30,7 +30,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 607A664D2B0EFC080010BFC8 /* Python.xcframework in Embed Frameworks */, + 607A664D2B0EFC080010BFC8 /* Python{{abiflags}}.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -41,7 +41,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 607A66512B0EFFE00010BFC8 /* Python.xcframework in Embed Frameworks */, + 607A66512B0EFFE00010BFC8 /* Python{{abiflags}}.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -51,7 +51,7 @@ /* Begin PBXFileReference section */ 607A66122B0EFA380010BFC8 /* iOSTestbed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSTestbed.app; sourceTree = BUILT_PRODUCTS_DIR; }; 607A662D2B0EFA3A0010BFC8 /* iOSTestbedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSTestbedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 607A664A2B0EFB310010BFC8 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Python.xcframework; sourceTree = ""; }; + 607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "Python{{abiflags}}.xcframework"; sourceTree = ""; }; 60FE0EFB2E56BB6D00524F87 /* iOSTestbed.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = iOSTestbed.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ @@ -90,7 +90,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 607A664C2B0EFC080010BFC8 /* Python.xcframework in Frameworks */, + 607A664C2B0EFC080010BFC8 /* Python{{abiflags}}.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -98,7 +98,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 607A66502B0EFFE00010BFC8 /* Python.xcframework in Frameworks */, + 607A66502B0EFFE00010BFC8 /* Python{{abiflags}}.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -109,7 +109,7 @@ isa = PBXGroup; children = ( 60FE0EFB2E56BB6D00524F87 /* iOSTestbed.xctestplan */, - 607A664A2B0EFB310010BFC8 /* Python.xcframework */, + 607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */, 607A66142B0EFA380010BFC8 /* iOSTestbed */, 607A66302B0EFA3A0010BFC8 /* TestbedTests */, 607A66132B0EFA380010BFC8 /* Products */, @@ -250,7 +250,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\nsource $PROJECT_DIR/Python.xcframework/build/utils.sh\ninstall_python Python.xcframework app app_packages\n"; + shellScript = "set -e\nsource $PROJECT_DIR/Python{{abiflags}}.xcframework/build/utils.sh\ninstall_python Python{{abiflags}}.xcframework app app_packages\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -407,7 +407,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; - HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; + HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python{{abiflags}}.framework/Headers\""; INFOPLIST_FILE = "iOSTestbed/iOSTestbed-Info.plist"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -438,7 +438,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; - HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; + HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python{{abiflags}}.framework/Headers\""; INFOPLIST_FILE = "iOSTestbed/iOSTestbed-Info.plist"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -467,7 +467,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 3HEZE76D99; GENERATE_INFOPLIST_FILE = YES; - HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; + HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python{{abiflags}}.framework/Headers\""; IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbedTests; @@ -487,7 +487,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 3HEZE76D99; GENERATE_INFOPLIST_FILE = YES; - HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; + HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python{{abiflags}}.framework/Headers\""; IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbedTests; From 53cd2648f7b9d780cfdff5a26a62f2f1963a4dba Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 14 Oct 2026 09:28:27 +0800 Subject: [PATCH 2/3] Add CI configuration. --- .github/workflows/build.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00d64b3df7e9654..98752414a87646d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -346,18 +346,28 @@ jobs: run: python3 Platforms/Android ci --fast-ci ${{ matrix.arch }}-linux-android build-ios: - name: iOS + name: >- + iOS + ${{ fromJSON(matrix.free-threading) && '(free-threading)' || '' }} needs: build-context if: needs.build-context.outputs.run-ios == 'true' timeout-minutes: 60 runs-on: macos-26 + strategy: + fail-fast: false + matrix: + free-threading: + - false + - true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Build and test - run: python3 Platforms/Apple ci iOS --fast-ci + run: >- + python3 Platforms/Apple ci iOS --fast-ci + ${{ fromJSON(matrix.free-threading) && '--abiflags t' || '' }} build-emscripten: name: 'Emscripten' From 3f365cd0f80c93500d76bf17563823f12f6c99eb Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 14 Oct 2026 09:48:32 +0800 Subject: [PATCH 3/3] Add blurb. --- .../Tools-Demos/2026-10-14-09-48-26.gh-issue-159385.0aWOA9.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-10-14-09-48-26.gh-issue-159385.0aWOA9.rst diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-10-14-09-48-26.gh-issue-159385.0aWOA9.rst b/Misc/NEWS.d/next/Tools-Demos/2026-10-14-09-48-26.gh-issue-159385.0aWOA9.rst new file mode 100644 index 000000000000000..e76a5e666df2364 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-10-14-09-48-26.gh-issue-159385.0aWOA9.rst @@ -0,0 +1 @@ +iOS builds can now be configured to generate free-threading or debug output.