Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ fail, then implement.
`libpython*.dylib`, or the top-level packages `test`, `idlelib`, `tkinter`, `turtledemo` and
`ensurepip`; `stageIosPythonHomeForXcode` picks the slice from Xcode's `EFFECTIVE_PLATFORM_NAME`
and `ARCHS` and prints `PYTHON_HOME_DIR=` and `PYTHON_DYLIB_INFO_TEMPLATE=`. An Xcode Run Script
phase (`python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`, after Copy Bundle Resources and before Embed Frameworks)
phase (the plugin's `install-python.sh`, installed by `installPythonXcodeScript`, after Copy Bundle Resources and before Embed Frameworks)
copies that prefix into `<app>/python-multiplatform-home/` and the consumer's payload (L-6) into
`<app>/python/`, and moves every `.so` under either into `Frameworks/<dotted.name>.framework`,
leaving a `.fwork` placeholder, as CPython's `AppleFrameworkLoader` and Apple's rule against loose
Expand Down
35 changes: 20 additions & 15 deletions docs/platforms/ios-app-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ prefix and payload, why, and how to check it.

## What happens

**Gradle** (`python-multiplatform/build.gradle.kts`, rules in the plugin's `IosPythonHomeLayout`).
One task per slice, `stageIosPythonHome_<sdk>_<arch>` (`iphoneos_arm64`, `iphonesimulator_arm64`,
`iphonesimulator_x86_64`), writes `python-multiplatform/build/python-ios-home/<sdk>-<arch>/`:
**Gradle** (the bindings plugin, `IosConsumerWiring.kt`; rules in `IosPythonHomeLayout`). The app module
applies `io.github.thisisthepy.python.multiplatform.bindings`, which registers one task per slice,
`stageIosPythonHome_<sdk>_<arch>` (`iphoneos_arm64`, `iphonesimulator_arm64`, `iphonesimulator_x86_64`),
and writes `<module>/build/python-ios-home/<sdk>-<arch>/` (for the sample, `sample/build/python-ios-home/...`):

| Path | From `Python.xcframework/` |
|---|---|
Expand All @@ -26,8 +27,8 @@ the 2,407 files of the 3.14 stdlib), `idlelib`, `tkinter`, `turtledemo` (no `_tk
and `ARCHS` from the environment Xcode gives a Run Script phase, stages only that slice and prints:

```
PYTHON_HOME_DIR=<abs>/python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home
PYTHON_DYLIB_INFO_TEMPLATE=<abs>/python-multiplatform/build/python-ios-home/iphonesimulator-arm64/dylib-Info-template.plist
PYTHON_HOME_DIR=<abs>/sample/build/python-ios-home/iphonesimulator-arm64/home
PYTHON_DYLIB_INFO_TEMPLATE=<abs>/sample/build/python-ios-home/iphonesimulator-arm64/dylib-Info-template.plist
```

A universal `ARCHS` (`arm64 x86_64`) is refused with the fix (`ONLY_ACTIVE_ARCH=YES` or `ARCHS=arm64`):
Expand All @@ -36,7 +37,7 @@ one prefix holds one `lib-dynload`. Upstream's `utils.sh` has the same limit wit
**Xcode** (`python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`, a Run Script phase after Copy Bundle Resources and before
Embed Frameworks; `ENABLE_USER_SCRIPT_SANDBOXING = NO`).

1. Runs Gradle as above. The output is captured, then parsed, then checked to be a directory, a
1. Runs `<module>:stageIosPythonHomeForXcode` through Gradle as above (`:sample:installPythonXcodeScript` copies the script to `build/python-multiplatform/xcode/install-python.sh` and bakes in that task path). The output is captured, then parsed, then checked to be a directory, a
failing Gradle stops the phase and never leaves `rsync` an empty source (issue #59's comment).
2. `rsync -a --delete` the prefix to `<app>/python-multiplatform-home/`.
3. The payload, if configured, to `<app>/python/` (`--delete`, without `__pycache__`):
Expand Down Expand Up @@ -96,8 +97,9 @@ The sample's project is `sample/src/iosMain/app.xcodeproj` (target `app`, produc
```sh
set -e
cd "$SRCROOT/../../.."
./gradlew -q :sample:installPythonXcodeScript
export PYTHON_PAYLOAD_DIR="$SRCROOT/../commonMain/python"
/bin/bash python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh
/bin/bash sample/build/python-multiplatform/xcode/install-python.sh
```

It replaced two phases: "Install Target Specific Python Standard Library", which rsynced the slice's
Expand All @@ -113,9 +115,12 @@ tasks first (`:python-multiplatform:embedAndSignAppleFrameworkForXcode`,
`:sample:embedAndSignAppleFrameworkForXcode`), then compiles, then installs Python, then embeds
`Python.xcframework`.

A consumer outside this repository cannot call `:python-multiplatform:stageIosPythonHomeForXcode`:
the iOS archive download lives in this repository's build script, not in the published plugin. That
is the next step if consumers are to get this without copying the sample.
The sample uses the same plugin path as any consumer (issue #321): `:sample:downloadPython_ios` and
`:sample:preparePythonXcframework` provide `sample/build/xcode-frameworks/Python.xcframework`, the plugin adds
`-framework Python -F...` to the `Demo` framework binaries, and `:sample:stageIosPythonHomeForXcode` /
`:sample:installPythonXcodeScript` provide the stdlib staging and the script. The library keeps only its own
iOS download for its cinterop and simulator tests. The same wiring for an app outside this repository:
`python-multiplatform-gradle-plugin/fixtures/ios-consumer/README.md`.

## Checking it (completion criterion of #59)

Expand All @@ -129,16 +134,16 @@ df -h / # the installed app (~60 MB) goes into CoreSimulator on the internal

# 0. Stage once by hand and look at it
EFFECTIVE_PLATFORM_NAME=-iphonesimulator ARCHS=arm64 \
./gradlew -q :python-multiplatform:stageIosPythonHomeForXcode > .tmp/stage.out 2>&1; echo "EXIT=$?"
./gradlew -q :sample:stageIosPythonHomeForXcode > .tmp/stage.out 2>&1; echo "EXIT=$?"
cat .tmp/stage.out # two lines: PYTHON_HOME_DIR=..., PYTHON_DYLIB_INFO_TEMPLATE=...
ls python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home/lib/python3.14/lib-dynload | head
test ! -e python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home/lib/python3.14/test && echo "test/ excluded"
ls sample/build/python-ios-home/iphonesimulator-arm64/home/lib/python3.14/lib-dynload | head
test ! -e sample/build/python-ios-home/iphonesimulator-arm64/home/lib/python3.14/test && echo "test/ excluded"
# must fail, naming ONLY_ACTIVE_ARCH:
EFFECTIVE_PLATFORM_NAME=-iphonesimulator ARCHS="arm64 x86_64" \
./gradlew -q :python-multiplatform:stageIosPythonHomeForXcode > .tmp/stage-universal.out 2>&1; echo "EXIT=$?"
./gradlew -q :sample:stageIosPythonHomeForXcode > .tmp/stage-universal.out 2>&1; echo "EXIT=$?"

# 1. Frameworks the Xcode project links against
./gradlew :sample:prepareIosFrameworks --console=plain > .tmp/prepare.log 2>&1; echo "EXIT=$?"
./gradlew :sample:preparePythonXcframework :sample:installPythonXcodeScript --console=plain > .tmp/prepare.log 2>&1; echo "EXIT=$?"

# 2. Build the app (flags from the last verified run, ROADMAP history 2026-08-13: actool cannot
# compile the app icon against this machine's simulator runtimes, and -target avoids
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ trampolines exist. GraalVM native image upcalls are verified by hand

- **13.1 The iOS app bundle carries no Python standard library, implemented, simulator proof
open.** SPEC L-11, issue #59, `docs/platforms/ios-app-bundle.md`: per-slice staging
(`stageIosPythonHome_*`, `stageIosPythonHomeForXcode`), `python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh` (stdlib to
(the plugin's `stageIosPythonHome_*`, `stageIosPythonHomeForXcode`; #321 moved them out of `python-multiplatform/build.gradle.kts`), `python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh` (stdlib to
`<app>/python-multiplatform-home/`, payload to `<app>/python/`, `.so` wrapped as frameworks with
`.fwork` placeholders) replacing the two broken phases of the old root `iosApp/` (now `sample/src/iosMain/app.xcodeproj`, #107), and `IosPythonHome` +
`Py_SetPythonHome` at run time. Left: the installed-app run on the simulator with no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ internal const val STAGE_IOS_HOME_FOR_XCODE_TASK = "stageIosPythonHomeForXcode"
internal const val INSTALL_XCODE_SCRIPT_TASK = "installPythonXcodeScript"

/** The default the shipped script carries; the install task replaces it with the consumer's task path. */
internal const val SCRIPT_LIBRARY_HOME_TASK = ":python-multiplatform:stageIosPythonHomeForXcode"
internal const val SCRIPT_LIBRARY_HOME_TASK = "@PYTHON_HOME_TASK@" // the placeholder install-python.sh carries (#321)

/** `ios_arm64` to `ios-arm64`, as the sample does; null for a konan target that is not iOS. */
internal fun xcframeworkSliceForKonanTarget(konanName: String): String? = when (konanName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
#
# Place the phase AFTER "Copy Bundle Resources" and BEFORE "Embed Frameworks", in a target whose
# ENABLE_USER_SCRIPT_SANDBOXING is NO (the phase reads the Gradle build directory). It:
# 1. runs `<gradlew> -q :python-multiplatform:stageIosPythonHomeForXcode`, which stages the stdlib
# 1. runs `<gradlew> -q <project>:stageIosPythonHomeForXcode` (baked in by the plugin's
# installPythonXcodeScript), which stages the stdlib
# for the slice Xcode is building (EFFECTIVE_PLATFORM_NAME + ARCHS) and prints
# PYTHON_HOME_DIR= and PYTHON_DYLIB_INFO_TEMPLATE=;
# 2. copies that prefix to <app>/python-multiplatform-home/ (IosPythonHome looks there);
Expand All @@ -15,7 +16,7 @@
#
# Environment (all optional except what Xcode sets):
# GRADLEW Gradle wrapper, default ./gradlew (the phase cd's to the Gradle root first)
# PYTHON_HOME_TASK default :python-multiplatform:stageIosPythonHomeForXcode
# PYTHON_HOME_TASK default: the task installPythonXcodeScript bakes in for its project
# PYTHON_PAYLOAD_DIR a directory whose *contents* become <app>/python/ (e.g. the app's own
# Python sources, or toolchain's build/pythonStaging/ios/python)
# PYTHON_PAYLOAD_TASK instead: a Gradle task printing PYTHON_PAYLOAD_DIR=<abs> under -q, e.g.
Expand All @@ -39,7 +40,12 @@ if [ "$(cd "$bundle" && pwd -P)" != "$(cd "$resources" && pwd -P)" ]; then
fi

gradlew="${GRADLEW:-./gradlew}"
home_task="${PYTHON_HOME_TASK:-:python-multiplatform:stageIosPythonHomeForXcode}"
home_task="${PYTHON_HOME_TASK:-@PYTHON_HOME_TASK@}"
# The sentinel is split so the bake (which rewrites the placeholder above) leaves this test alone.
if [ "$home_task" = "@PYTHON_HOME""_TASK@" ]; then
echo "error: run the copy installPythonXcodeScript writes to build/python-multiplatform/xcode/, or set PYTHON_HOME_TASK" >&2
exit 1
fi

# Captured first, parsed second: a failing Gradle must stop the phase here (set -e on the
# assignment), never leave an empty string for rsync to treat as "/".
Expand Down
62 changes: 0 additions & 62 deletions python-multiplatform/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -1481,68 +1481,6 @@ tasks.withType<org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimu
environment("PYTHONHOME", pythonHome)
}

// =================================================================================================
// The standard library inside an iOS *app* -- SPEC L-10, issue #59.
//
// `extractIosSimulatorStdlib` above serves the simulator *test* binary, which `simctl` points at a
// workspace path through SIMCTL_CHILD_PYTHONHOME. An installed app has no such variable, and a path on
// this workspace's external volume parks the sandboxed app at 0% CPU (iosMain/README.md). So an app
// carries its own prefix inside its bundle, staged here per slice and copied in by the Xcode Run
// Script phase `python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`; `IosPythonHome` (iosMain) finds it at run time.
// The selection rules are `IosPythonHomeLayout` in the Gradle plugin, where they are unit-tested.
// =================================================================================================

val iosPythonHomeRoot = layout.buildDirectory.dir("python-ios-home")

val stageIosPythonHomeSlices = python.multiplatform.gradle.IosPythonHomeLayout.slices.map { slice ->
tasks.register(slice.stageTaskName, python.multiplatform.gradle.StageIosPythonHomeTask::class.java) {
group = "python"
description = "Stages CPython's standard library for an iOS app built for ${slice.name} (SPEC L-10)"
dependsOn(downloadPython_ios)
xcframework.set(file("$extractedDir/ios/Python.xcframework"))
sliceName.set(slice.name)
pythonVersion.set(configuredPythonVersion)
destinationDir.set(iosPythonHomeRoot.map { it.dir(slice.name) })
}
}

/** Every slice; the Xcode phase uses [stageIosPythonHomeForXcode], which stages only the one it builds. */
val stageIosPythonHome by tasks.registering {
group = "python"
description = "Stages CPython's standard library for every iOS app slice (SPEC L-10)"
dependsOn(stageIosPythonHomeSlices)
}

/**
* The Xcode phase's entry point. Picks the slice from `EFFECTIVE_PLATFORM_NAME` and `ARCHS` -- Xcode
* exports both to a Run Script phase, and `./gradlew` passes the client's environment to the build --
* stages it, and prints two lines a `-q` run leaves alone on stdout:
*
* PYTHON_HOME_DIR=<abs>/python-ios-home/<slice>/home
* PYTHON_DYLIB_INFO_TEMPLATE=<abs>/python-ios-home/<slice>/dylib-Info-template.plist
*
* The same contract shape as toolchain's `stagePythonBundleIosForXcode` (`PYTHON_PAYLOAD_DIR=`).
* Run outside Xcode (no `EFFECTIVE_PLATFORM_NAME`), or for a universal `ARCHS`, it fails while the
* task graph is built, with the reason, rather than staging a guessed slice.
*/
val stageIosPythonHomeForXcode by tasks.registering {
group = "python"
description = "Stages the iOS app prefix for the slice Xcode is building and prints its path (SPEC L-10)"
val platformName = providers.environmentVariable("EFFECTIVE_PLATFORM_NAME")
val archs = providers.environmentVariable("ARCHS")
val selected = providers.provider {
python.multiplatform.gradle.IosPythonHomeLayout.sliceFor(platformName.orNull, archs.orNull)
}
// Only the slice being built: staging all three costs three lib-dynload trees per build.
dependsOn(selected.map { tasks.named(it.stageTaskName) })
val root = iosPythonHomeRoot.map { it.asFile.absolutePath }
doLast {
val slice = selected.get().name
println("PYTHON_HOME_DIR=${root.get()}/$slice/${python.multiplatform.gradle.StageIosPythonHomeTask.PREFIX_DIRECTORY}")
println("PYTHON_DYLIB_INFO_TEMPLATE=${root.get()}/$slice/${python.multiplatform.gradle.StageIosPythonHomeTask.TEMPLATE_NAME}")
}
}

// =================================================================================================
// androidNative -- giving the target a test *run*, not just a test *link*.
//
Expand Down
2 changes: 1 addition & 1 deletion python-multiplatform/src/iosMain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ than an installed app, and exactly the shape that hangs an app rather than start
An installed **app** carries its own prefix inside its bundle (SPEC L-11,
`docs/platforms/ios-app-bundle.md`):

- `stageIosPythonHome_<sdk>_<arch>` stages the stdlib for one slice; `python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`,
- The bindings plugin's `stageIosPythonHome_<sdk>_<arch>` (in the app module) stages the stdlib for one slice; the script it installs (`:<app>:installPythonXcodeScript`),
an Xcode Run Script phase, copies it to `<app>/python-multiplatform-home/`, copies the consumer's
payload to `<app>/python/`, and wraps every `.so` as `Frameworks/<module>.framework` with a
`.fwork` placeholder (Apple loads no loose binaries; CPython's `AppleFrameworkLoader` follows the
Expand Down
33 changes: 3 additions & 30 deletions sample/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
import org.jetbrains.kotlin.konan.target.KonanTarget.ANDROID_ARM64
import org.jetbrains.kotlin.konan.target.KonanTarget.ANDROID_X64
import org.jetbrains.kotlin.konan.target.KonanTarget.IOS_ARM64
import org.jetbrains.kotlin.konan.target.KonanTarget.IOS_SIMULATOR_ARM64
import org.jetbrains.kotlin.konan.target.KonanTarget.IOS_X64


plugins {
Expand Down Expand Up @@ -84,20 +79,12 @@ kotlin {
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
val targetABI = when(iosTarget.konanTarget) {
ANDROID_ARM64 -> "arm64-v8a"
ANDROID_X64 -> "x86_64"
IOS_ARM64 -> "ios-arm64"
IOS_X64 -> "ios-arm64_x86_64-simulator"
IOS_SIMULATOR_ARM64 -> "ios-arm64_x86_64-simulator"
else -> throw RuntimeException("Unsupported ABI: ${iosTarget.konanTarget}")
}
iosTarget.binaries.framework {
baseName = "Demo"

linkerOpts.addAll(listOf(
"-framework", "Python", "-F$projectDir/build/xcode-frameworks/Python.xcframework/$targetABI", "-Objc"
))
// `-framework Python -F...` and the link task's dependency on preparePythonXcframework come
// from the bindings plugin (issue #321); only the non-Python option stays here.
linkerOpts.add("-Objc")

//export(projects.pythonMultiplatform)
}
Expand Down Expand Up @@ -260,20 +247,6 @@ tasks.withType<org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack>().c
}
}

val prepareIosFrameworks by tasks.registering(Sync::class) {
dependsOn(":python-multiplatform:downloadPython_ios")

val samplePythonVersion = project.findProperty("pythonVersion")?.toString() ?: rootProject.version.toString()
val extractedIosDir = project(":python-multiplatform").layout.buildDirectory.dir("python-standalone/extracted/$samplePythonVersion/ios")

from(extractedIosDir)
into(layout.buildDirectory.dir("xcode-frameworks"))
}

tasks.matching { it.name.startsWith("link") && it.name.contains("Ios") }.configureEach {
dependsOn(prepareIosFrameworks)
}

android {
namespace = "org.thisisthepy.python.multiplatform.demo"
compileSdk = libs.versions.android.compileSdk.get().toInt()
Expand Down
2 changes: 1 addition & 1 deletion sample/src/iosMain/app.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n# SPEC L-10 / issue #59: the CPython stdlib and this app's python/ payload into the .app, with\n# extension modules wrapped as frameworks. See docs/platforms/ios-app-bundle.md.\ncd \"$SRCROOT/../../..\"\nexport PYTHON_PAYLOAD_DIR=\"$SRCROOT/../commonMain/python\"\n/bin/bash python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh\n";
shellScript = "set -e\n# SPEC L-10 / issue #59: the CPython stdlib and this app's python/ payload into the .app, with\n# extension modules wrapped as frameworks. See docs/platforms/ios-app-bundle.md.\ncd \"$SRCROOT/../../..\"\n./gradlew -q :sample:installPythonXcodeScript\nexport PYTHON_PAYLOAD_DIR=\"$SRCROOT/../commonMain/python\"\n/bin/bash sample/build/python-multiplatform/xcode/install-python.sh\n";
};
/* End PBXShellScriptBuildPhase section */

Expand Down