https://github.com/python/cpython/commit/d6f2129abdf14c2d653b8fc03a40d4a61674ec54
commit: d6f2129abdf14c2d653b8fc03a40d4a61674ec54
branch: main
author: Russell Keith-Magee <[email protected]>
committer: freakboy3742 <[email protected]>
date: 2026-10-14T14:48:50+08:00
summary:

gh-159385: Add support for free-threaded and debug builds for iOS (#159386)

Modify the iOS build tools to support the building free threaded, debug,
and potentiall other abiflag variants of Python for iOS.

files:
A Misc/NEWS.d/next/Tools-Demos/2026-10-14-09-48-26.gh-issue-159385.0aWOA9.rst
M .github/workflows/build.yml
M .gitignore
M Makefile.pre.in
M Platforms/Apple/__main__.py
M Platforms/Apple/iOS/README.md
M Platforms/Apple/iOS/Resources/Info.plist.in
M Platforms/Apple/testbed/Python.xcframework/Info.plist
M Platforms/Apple/testbed/Python.xcframework/build/utils.sh
M Platforms/Apple/testbed/TestbedTests/TestbedTests.m
M Platforms/Apple/testbed/__main__.py
M Platforms/Apple/testbed/iOSTestbed.xcodeproj/project.pbxproj

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'
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/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.
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, '-<abiflags>' is appended."
+            ),
+        )
+        cmd.add_argument(
+            "--abiflags",
+            default="",
+            type=validate_abiflags,
+            help=(
+                "The ABI flags of the build: 't' (free-threaded; "
+                "--disable-gil), 'd' (debug; --with-pydebug), or 'td' (both). "
+                "Defaults to '' (a standard build)."
             ),
         )
 
@@ -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 <PythonT/Python.h>`.
+* 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.<timestamp>`.
+`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.<timestamp>`. Builds that use ABI flags use the
+`cross-build-<abiflags>` 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 
<path/to/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 
<path/to/PythonT.xcframework> 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 @@
        <key>CFBundleDevelopmentRegion</key>
        <string>en</string>
        <key>CFBundleExecutable</key>
-       <string>Python</string>
+       <string>@PYTHONFRAMEWORK@</string>
        <key>CFBundleGetInfoString</key>
        <string>Python Runtime and Library</string>
        <key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
        <key>CFBundleInfoDictionaryVersion</key>
        <string>6.0</string>
        <key>CFBundleName</key>
-       <string>Python</string>
+       <string>@PYTHONFRAMEWORK@</string>
        <key>CFBundlePackageType</key>
        <string>FMWK</string>
        <key>CFBundleShortVersionString</key>
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 @@
        <array>
                <dict>
                        <key>BinaryPath</key>
-                       <string>Python.framework/Python</string>
+                       
<string>Python{{abiflags}}.framework/Python{{abiflags}}</string>
                        <key>LibraryIdentifier</key>
                        <string>ios-arm64</string>
                        <key>LibraryPath</key>
-                       <string>Python.framework</string>
+                       <string>Python{{abiflags}}.framework</string>
                        <key>SupportedArchitectures</key>
                        <array>
                                <string>arm64</string>
@@ -20,11 +20,11 @@
                </dict>
                <dict>
                        <key>BinaryPath</key>
-                       <string>Python.framework/Python</string>
+                       
<string>Python{{abiflags}}.framework/Python{{abiflags}}</string>
                        <key>LibraryIdentifier</key>
                        <string>ios-arm64_x86_64-simulator</string>
                        <key>LibraryPath</key>
-                       <string>Python.framework</string>
+                       <string>Python{{abiflags}}.framework</string>
                        <key>SupportedArchitectures</key>
                        <array>
                                <string>arm64</string>
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 <XCTest/XCTest.h>
-#import <Python/Python.h>
+#import <Python{{abiflags}}/Python.h>
 
 @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 = "<group>"; };
+               607A664A2B0EFB310010BFC8 /* Python{{abiflags}}.xcframework */ = 
{isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = 
"Python{{abiflags}}.xcframework"; sourceTree = "<group>"; };
                60FE0EFB2E56BB6D00524F87 /* iOSTestbed.xctestplan */ = {isa = 
PBXFileReference; lastKnownFileType = text; path = iOSTestbed.xctestplan; 
sourceTree = "<group>"; };
 /* 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;

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to