From 1ccbf7fa4b2deb75677a16838dca906fca2679ed Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:02:19 +0900 Subject: [PATCH] Build: sample's iOS app uses the plugin's wiring; the library's duplicate stdlib-staging tasks are removed (#321) sample drops its own Python.xcframework Sync and -framework Python linker options (the plugin's preparePythonXcframework does both) and its Xcode phase runs :sample:installPythonXcodeScript and the installed script. python-multiplatform's stageIosPythonHome* tasks had no user left and are removed; its downloadPython_ios stays (the library's cinterop and iOS tests use it). install-python.sh carries a placeholder the install task bakes, and a raw run without PYTHON_HOME_TASK stops with a message instead of calling a task that no longer exists. --- docs/SPEC.md | 2 +- docs/platforms/ios-app-bundle.md | 35 ++++++----- docs/roadmap/ROADMAP.md | 2 +- .../multiplatform/gradle/IosConsumerWiring.kt | 2 +- .../main/resources/xcode/install-python.sh | 12 +++- python-multiplatform/build.gradle.kts | 62 ------------------- python-multiplatform/src/iosMain/README.md | 2 +- sample/build.gradle.kts | 33 +--------- .../src/iosMain/app.xcodeproj/project.pbxproj | 2 +- 9 files changed, 37 insertions(+), 115 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index f356d2444..a601d5815 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 `/python-multiplatform-home/` and the consumer's payload (L-6) into `/python/`, and moves every `.so` under either into `Frameworks/.framework`, leaving a `.fwork` placeholder, as CPython's `AppleFrameworkLoader` and Apple's rule against loose diff --git a/docs/platforms/ios-app-bundle.md b/docs/platforms/ios-app-bundle.md index 1ddcaf218..7d605b0f5 100644 --- a/docs/platforms/ios-app-bundle.md +++ b/docs/platforms/ios-app-bundle.md @@ -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__` (`iphoneos_arm64`, `iphonesimulator_arm64`, -`iphonesimulator_x86_64`), writes `python-multiplatform/build/python-ios-home/-/`: +**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__` (`iphoneos_arm64`, `iphonesimulator_arm64`, `iphonesimulator_x86_64`), +and writes `/build/python-ios-home/-/` (for the sample, `sample/build/python-ios-home/...`): | Path | From `Python.xcframework/` | |---|---| @@ -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=/python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home -PYTHON_DYLIB_INFO_TEMPLATE=/python-multiplatform/build/python-ios-home/iphonesimulator-arm64/dylib-Info-template.plist +PYTHON_HOME_DIR=/sample/build/python-ios-home/iphonesimulator-arm64/home +PYTHON_DYLIB_INFO_TEMPLATE=/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`): @@ -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 `: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 `/python-multiplatform-home/`. 3. The payload, if configured, to `/python/` (`--delete`, without `__pycache__`): @@ -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 @@ -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) @@ -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 diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 6805be3d4..bd328f959 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -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 `/python-multiplatform-home/`, payload to `/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 diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosConsumerWiring.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosConsumerWiring.kt index 87b15806a..ae7f178f6 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosConsumerWiring.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosConsumerWiring.kt @@ -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) { diff --git a/python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh b/python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh index 261fb5f99..7e846bffa 100755 --- a/python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh +++ b/python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh @@ -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 ` -q :python-multiplatform:stageIosPythonHomeForXcode`, which stages the stdlib +# 1. runs ` -q :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 /python-multiplatform-home/ (IosPythonHome looks there); @@ -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 /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= under -q, e.g. @@ -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 "/". diff --git a/python-multiplatform/build.gradle.kts b/python-multiplatform/build.gradle.kts index b94e30d98..01af94e99 100644 --- a/python-multiplatform/build.gradle.kts +++ b/python-multiplatform/build.gradle.kts @@ -1481,68 +1481,6 @@ tasks.withType - 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=/python-ios-home//home - * PYTHON_DYLIB_INFO_TEMPLATE=/python-ios-home//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*. // diff --git a/python-multiplatform/src/iosMain/README.md b/python-multiplatform/src/iosMain/README.md index fbc3c9853..30a71659e 100644 --- a/python-multiplatform/src/iosMain/README.md +++ b/python-multiplatform/src/iosMain/README.md @@ -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__` stages the stdlib for one slice; `python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`, +- The bindings plugin's `stageIosPythonHome__` (in the app module) stages the stdlib for one slice; the script it installs (`::installPythonXcodeScript`), an Xcode Run Script phase, copies it to `/python-multiplatform-home/`, copies the consumer's payload to `/python/`, and wraps every `.so` as `Frameworks/.framework` with a `.fwork` placeholder (Apple loads no loose binaries; CPython's `AppleFrameworkLoader` follows the diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index c80d43103..3597caa42 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -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 { @@ -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) } @@ -260,20 +247,6 @@ tasks.withType().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() diff --git a/sample/src/iosMain/app.xcodeproj/project.pbxproj b/sample/src/iosMain/app.xcodeproj/project.pbxproj index 4117bea77..0961ac687 100644 --- a/sample/src/iosMain/app.xcodeproj/project.pbxproj +++ b/sample/src/iosMain/app.xcodeproj/project.pbxproj @@ -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 */