From 967b2550bef6febc6c9e9029a4dab3b1862edb94 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:12:47 +0900 Subject: [PATCH] Feat: the bindings plugin wires iOS for a consumer app: Python.xcframework and stdlib staging (#90) On a module with Kotlin/Native iOS targets the plugin registers downloadPython_ios (the pinned iOS support archive, into the consumer's build/ only, verified against a SHA-256 baked in from python-checksums.properties; a mismatch fails the build), preparePythonXcframework (Sync into build/xcode-frameworks plus -framework Python -F on every iOS framework binary and the link dependency; pythonBindings { ios { linkPythonFramework.set(false) } } turns it off), stageIosPythonHome_ / stageIosPythonHome / stageIosPythonHomeForXcode (the library's names and stdout contract), and installPythonXcodeScript (install-python.sh with the consumer's task baked in). fixtures/ios-consumer builds a simulator app from the plugin alone and probes stdlib, an extension module and the payload from the bundle. --- .../build.gradle.kts | 45 +++ .../fixtures/ios-consumer/README.md | 43 +++ .../fixtures/ios-consumer/build.gradle.kts | 42 +++ .../ios-consumer/python/probe_payload.py | 5 + .../src/iosMain/kotlin/ProbeMain.kt | 30 ++ .../fixtures/ios-consumer/xcode/App.swift | 15 + .../fixtures/ios-consumer/xcode/Info.plist | 26 ++ .../xcode/app.xcodeproj/project.pbxproj | 302 ++++++++++++++++++ .../multiplatform/gradle/IosConsumerWiring.kt | 279 ++++++++++++++++ .../gradle/PythonBindingsPlugin.kt | 15 + .../gradle/IosConsumerWiringTest.kt | 254 +++++++++++++++ settings.gradle.kts | 5 +- 12 files changed, 1060 insertions(+), 1 deletion(-) create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/README.md create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/build.gradle.kts create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/python/probe_payload.py create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/src/iosMain/kotlin/ProbeMain.kt create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/App.swift create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/Info.plist create mode 100644 python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/app.xcodeproj/project.pbxproj create mode 100644 python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosConsumerWiring.kt create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosConsumerWiringTest.kt diff --git a/python-multiplatform-gradle-plugin/build.gradle.kts b/python-multiplatform-gradle-plugin/build.gradle.kts index dc3fdefc..6950ecdf 100644 --- a/python-multiplatform-gradle-plugin/build.gradle.kts +++ b/python-multiplatform-gradle-plugin/build.gradle.kts @@ -260,12 +260,45 @@ val generateCoordinates = tasks.register("generateCoordinates") { val klibWorkerValue = klibWorkerCoordinates + // The CPython iOS support archive the library build downloads, derived with the same rule as + // `python-multiplatform/build.gradle.kts` (`downloadPython_ios`): python.org from 3.15, BeeWare + // before. The SHA-256 is read from the root `python-checksums.properties` under the library's + // own lock key and baked into the plugin, so a consumer verifies against the pin this plugin + // was built with and needs no lockfile of its own. + val iosVersionParts = pythonVersionValue.split(".") + val iosFromPythonOrgValue = iosVersionParts[0].toInt() > 3 || (iosVersionParts[0].toInt() == 3 && iosVersionParts[1].toInt() >= 15) + val iosLibVersion = "${iosVersionParts[0]}.${iosVersionParts[1]}" + val iosReleaseDir = "$iosLibVersion.${iosVersionParts.getOrNull(2)?.takeWhile { it.isDigit() } ?: "0"}" + val appleSupportBuild = rootProperty("pythonAppleSupportBuild", "b11") + val iosArchiveNameValue = if (iosFromPythonOrgValue) { + "python-$pythonVersionValue-iOS-XCframework.tar.gz" + } else { + "Python-$iosLibVersion-iOS-support.$appleSupportBuild.tar.gz" + } + val iosUrlValue = if (iosFromPythonOrgValue) { + "https://www.python.org/ftp/python/$iosReleaseDir/$iosArchiveNameValue" + } else { + "https://github.com/beeware/Python-Apple-support/releases/download/$iosLibVersion-$appleSupportBuild/$iosArchiveNameValue" + } + val iosLockKeyValue = if (iosFromPythonOrgValue) "ios-$pythonVersionValue-pythonorg" else "ios-$iosLibVersion-$appleSupportBuild" + val checksumsFile = project.rootDir.resolve("../python-checksums.properties") + val iosSha256Value = Properties().apply { + if (checksumsFile.isFile) checksumsFile.inputStream().use { load(it) } + }.getProperty(iosLockKeyValue) + ?: throw GradleException( + "python-checksums.properties (${checksumsFile.path}) has no '$iosLockKeyValue'; the plugin bakes the iOS support archive's SHA-256 from it.", + ) + inputs.property("coordinates", coordinates) inputs.property("klibWorkerCoordinates", klibWorkerValue) inputs.property("pythonVersion", pythonVersionValue) inputs.property("pbsRelease", pbsReleaseValue) inputs.property("pythonFreeThreaded", freeThreadedValue) inputs.property("wasmRuntimeCoordinates", wasmRuntimeCoordinatesValue) + inputs.property("iosUrl", iosUrlValue) + inputs.property("iosArchiveName", iosArchiveNameValue) + inputs.property("iosLockKey", iosLockKeyValue) + inputs.property("iosSha256", iosSha256Value) outputs.dir(outputDir) doLast { val file = outputDir.get().file("python/multiplatform/gradle/ProcessorCoordinates.kt").asFile @@ -283,6 +316,18 @@ val generateCoordinates = tasks.register("generateCoordinates") { /** The python-build-standalone release tag that published it. */ internal const val DEFAULT_PBS_RELEASE: String = "$pbsReleaseValue" + /** The pinned CPython iOS support archive: where it is downloaded from. */ + internal const val DEFAULT_IOS_ARCHIVE_URL: String = "$iosUrlValue" + + /** Its file name, which is also the file's name under the consumer's `build/python-standalone/`. */ + internal const val DEFAULT_IOS_ARCHIVE_NAME: String = "$iosArchiveNameValue" + + /** The key it has in the root `python-checksums.properties`, kept for messages. */ + internal const val DEFAULT_IOS_LOCK_KEY: String = "$iosLockKeyValue" + + /** Its SHA-256, read from `python-checksums.properties` when this plugin was built. */ + internal const val DEFAULT_IOS_ARCHIVE_SHA256: String = "$iosSha256Value" + internal const val DEFAULT_PYTHON_FREE_THREADED: Boolean = $freeThreadedValue /** diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/README.md b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/README.md new file mode 100644 index 00000000..d1168091 --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/README.md @@ -0,0 +1,43 @@ +# ios-consumer fixture (issue #90) + +A Kotlin Multiplatform module that builds an iOS simulator app using only what the bindings plugin +provides. Nothing here defines a Python download, a linker option or an Xcode script. + +Layout: `build.gradle.kts` (two `Probe` framework binaries), `src/iosMain/kotlin/ProbeMain.kt` (the +probe), `python/probe_payload.py` (the payload), `xcode/` (the Xcode project). + +Run everything from the repository root, with JDK 21 in `JAVA_HOME`. `FX` is the fixture's project path. + +```bash +FX=:python-multiplatform-gradle-plugin-fixtures:ios-consumer +mkdir -p .tmp + +# 1. Link the framework: downloads and verifies the archive into the fixture's build/python-standalone, +# stages build/xcode-frameworks/Python.xcframework, links with -framework Python -F +./gradlew $FX:linkDebugFrameworkIosSimulatorArm64 --console=plain > .tmp/link.log 2>&1; echo "EXIT=$?" + +# 2. Stage the stdlib and install the script +EFFECTIVE_PLATFORM_NAME=-iphonesimulator ARCHS=arm64 ./gradlew -q $FX:stageIosPythonHomeForXcode # PYTHON_HOME_DIR=, PYTHON_DYLIB_INFO_TEMPLATE= +./gradlew $FX:installPythonXcodeScript + +# 3. Build the app +xcodebuild -project python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/app.xcodeproj \ + -target app -configuration Debug -sdk iphonesimulator ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \ + SYMROOT="$PWD/python-multiplatform-gradle-plugin/fixtures/ios-consumer/build/xcode-app" \ + CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" build > .tmp/xcodebuild.log 2>&1; echo "EXIT=$?" + +# 4. Install and launch with no SIMCTL_CHILD_* variable; the probe prints one line +APP=python-multiplatform-gradle-plugin/fixtures/ios-consumer/build/xcode-app/Debug-iphonesimulator/IosConsumer.app +UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -n 1) +xcrun simctl install "$UDID" "$APP" +xcrun simctl launch --console-pty --terminate-running-process "$UDID" \ + org.thisisthepy.python.multiplatform.iosconsumer > .tmp/launch.log 2>&1 & +# once the line appears: kill %1; grep IOS_CONSUMER_PROBE .tmp/launch.log +``` + +Pass: `IOS_CONSUMER_PROBE stdlib=ok ext=ok payload=ok`. The Xcode project was adapted from +`sample/src/iosMain/app.xcodeproj` (not opened in Xcode when written; if `xcodebuild` rejects it, the +phases to recreate are: Compile Kotlin Framework running `$FX:embedAndSignAppleFrameworkForXcode`; and, after +Resources and before Embed Frameworks, a phase running `$FX:installPythonXcodeScript` and then +`build/python-multiplatform/xcode/install-python.sh` with `PYTHON_PAYLOAD_DIR` set to `python/`, with +`ENABLE_USER_SCRIPT_SANDBOXING = NO`). diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/build.gradle.kts b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/build.gradle.kts new file mode 100644 index 00000000..5bd004df --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/build.gradle.kts @@ -0,0 +1,42 @@ +/** + * Issue #90: an iOS app built **only** from the published plugin and artifacts, from outside + * `:python-multiplatform`. + * + * This module declares no `downloadPython_ios`, no linker option and no Xcode script of its own. All + * of it comes from `io.github.thisisthepy.python.multiplatform.bindings`: + * + * - `downloadPython_ios` and `preparePythonXcframework` (into this module's `build/`), + * - `-framework Python -F...` on the two `Probe` framework binaries below, + * - `stageIosPythonHome*` and `installPythonXcodeScript`. + * + * It does depend on `:python-multiplatform` for the Kotlin API, as any consumer depends on the + * library. Build and run steps: `README.md`. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + applyDefaultHierarchyTemplate() + + listOf(iosSimulatorArm64(), iosArm64()).forEach { target -> + target.binaries.framework { + baseName = "Probe" + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + } +} + +pythonBindings { + role.set("app") + processor.set(projects.pythonMultiplatformKsp) + // ios { linkPythonFramework.set(false) } would switch the automatic -framework Python off. +} diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/python/probe_payload.py b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/python/probe_payload.py new file mode 100644 index 00000000..ffa4ac55 --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/python/probe_payload.py @@ -0,0 +1,5 @@ +"""The fixture's payload: copied to /python/ by the Xcode Run Script phase.""" + + +def status(): + return "ok" diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/src/iosMain/kotlin/ProbeMain.kt b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/src/iosMain/kotlin/ProbeMain.kt new file mode 100644 index 00000000..6ee06d61 --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/src/iosMain/kotlin/ProbeMain.kt @@ -0,0 +1,30 @@ +package fixture.iosconsumer + +import python.multiplatform.ffi.Python3 + +private const val PY_EVAL_INPUT: Int = 258 + +/** + * Called from the Xcode app's `App.init`. Prints one line a launcher can grep: + * + * IOS_CONSUMER_PROBE stdlib=ok ext=ok payload=ok + * + * `stdlib` is a pure-Python stdlib module out of the app's `python-multiplatform-home/`, `ext` is an + * extension module (`_json`, a `.fwork` placeholder that must resolve to a framework) and `payload` + * is `probe_payload` out of `/python/`. The expression names every import so that a failure + * reports which one. + */ +fun runProbe() { + val line = try { + Python3.initialize() + val globals = Python3.import("__main__").dict + val expression = + "'IOS_CONSUMER_PROBE stdlib=' + ('ok' if __import__('json').dumps([1]) == '[1]' else 'bad')" + + " + ' ext=' + ('ok' if __import__('_json').__file__.endswith('.fwork') else 'bad')" + + " + ' payload=' + __import__('probe_payload').status()" + "${Python3.eval(expression, PY_EVAL_INPUT, globals, globals)}" + } catch (t: Throwable) { + "IOS_CONSUMER_PROBE THREW ${t::class.simpleName}: ${t.message}" + } + println(line) +} diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/App.swift b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/App.swift new file mode 100644 index 00000000..6e87ec1d --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/App.swift @@ -0,0 +1,15 @@ +import SwiftUI +import Probe + +@main +struct ConsumerApp: App { + init() { + ProbeMainKt.runProbe() + } + + var body: some Scene { + WindowGroup { + Text("ios-consumer probe: see the console") + } + } +} diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/Info.plist b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/Info.plist new file mode 100644 index 00000000..8f91e273 --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + + diff --git a/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/app.xcodeproj/project.pbxproj b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/app.xcodeproj/project.pbxproj new file mode 100644 index 00000000..923f3f80 --- /dev/null +++ b/python-multiplatform-gradle-plugin/fixtures/ios-consumer/xcode/app.xcodeproj/project.pbxproj @@ -0,0 +1,302 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 9A0000000000000000000002 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000003 /* App.swift */; }; + 9A0000000000000000000006 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000005 /* Python.xcframework */; }; + 9A0000000000000000000007 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000005 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 9A000000000000000000001E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000014 /* libz.tbd */; }; + 9A000000000000000000001F /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000015 /* libsqlite3.tbd */; }; + 9A0000000000000000000020 /* libbz2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000016 /* libbz2.tbd */; }; + 9A0000000000000000000021 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000017 /* libc++.tbd */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9A0000000000000000000037 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9A0000000000000000000007 /* Python.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 9A0000000000000000000001 /* IosConsumer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IosConsumer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9A0000000000000000000003 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 9A0000000000000000000004 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9A0000000000000000000005 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Python.xcframework; path = "../build/xcode-frameworks/Python.xcframework"; sourceTree = ""; }; + 9A0000000000000000000014 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libz.tbd"; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; + 9A0000000000000000000015 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libsqlite3.tbd"; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 9A0000000000000000000016 /* libbz2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libbz2.tbd"; path = usr/lib/libbz2.tbd; sourceTree = SDKROOT; }; + 9A0000000000000000000017 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9A0000000000000000000034 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A0000000000000000000006 /* Python.xcframework in Frameworks */, + 9A000000000000000000001E /* libz.tbd in Frameworks */, + 9A000000000000000000001F /* libsqlite3.tbd in Frameworks */, + 9A0000000000000000000020 /* libbz2.tbd in Frameworks */, + 9A0000000000000000000021 /* libc++.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9A0000000000000000000028 = { + isa = PBXGroup; + children = ( + 9A0000000000000000000029 /* Sources */, + 9A000000000000000000002A /* Products */, + 9A000000000000000000002B /* Frameworks */, + ); + sourceTree = ""; + }; + 9A0000000000000000000029 /* Sources */ = { + isa = PBXGroup; + children = ( + 9A0000000000000000000003 /* App.swift */, + 9A0000000000000000000004 /* Info.plist */, + ); + name = Sources; + sourceTree = ""; + }; + 9A000000000000000000002A /* Products */ = { + isa = PBXGroup; + children = ( + 9A0000000000000000000001 /* IosConsumer.app */, + ); + name = Products; + sourceTree = ""; + }; + 9A000000000000000000002B /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9A0000000000000000000005 /* Python.xcframework */, + 9A0000000000000000000014 /* libz.tbd */, + 9A0000000000000000000015 /* libsqlite3.tbd */, + 9A0000000000000000000016 /* libbz2.tbd */, + 9A0000000000000000000017 /* libc++.tbd */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 9A000000000000000000003C /* app */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9A000000000000000000003F /* Build configuration list for PBXNativeTarget "app" */; + buildPhases = ( + 9A0000000000000000000032 /* Compile Kotlin Framework */, + 9A0000000000000000000033 /* Sources */, + 9A0000000000000000000034 /* Frameworks */, + 9A0000000000000000000035 /* Resources */, + 9A0000000000000000000036 /* Install Python Standard Library and Payload */, + 9A0000000000000000000037 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = app; + productName = app; + productReference = 9A0000000000000000000001 /* IosConsumer.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9A000000000000000000003D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1520; + }; + buildConfigurationList = 9A000000000000000000003E /* Build configuration list for PBXProject "app" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 9A0000000000000000000028; + productRefGroup = 9A000000000000000000002A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9A000000000000000000003C /* app */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 9A0000000000000000000035 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 9A0000000000000000000032 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + name = "Compile Kotlin Framework"; + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n exit 0\nfi\ncd \"$SRCROOT/../../../..\"\n./gradlew :python-multiplatform-gradle-plugin-fixtures:ios-consumer:embedAndSignAppleFrameworkForXcode\n"; + }; + 9A0000000000000000000036 /* Install Python Standard Library and Payload */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + name = "Install Python Standard Library and Payload"; + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n# Issue #90: the stdlib and this app's python/ payload into the .app, using only the script the plugin installs.\ncd \"$SRCROOT/../../../..\"\n./gradlew -q :python-multiplatform-gradle-plugin-fixtures:ios-consumer:installPythonXcodeScript\nexport PYTHON_PAYLOAD_DIR=\"$SRCROOT/../python\"\n/bin/bash python-multiplatform-gradle-plugin/fixtures/ios-consumer/build/python-multiplatform/xcode/install-python.sh\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 9A0000000000000000000033 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A0000000000000000000002 /* App.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 9A0000000000000000000046 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + SDKROOT = iphoneos; + GCC_OPTIMIZATION_LEVEL = 0; + ONLY_ACTIVE_ARCH = YES; + }; + name = Debug; + }; + 9A0000000000000000000047 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 9A0000000000000000000048 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Probe, + ); + PRODUCT_BUNDLE_IDENTIFIER = org.thisisthepy.python.multiplatform.iosconsumer; + PRODUCT_NAME = IosConsumer; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 9A0000000000000000000049 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Probe, + ); + PRODUCT_BUNDLE_IDENTIFIER = org.thisisthepy.python.multiplatform.iosconsumer; + PRODUCT_NAME = IosConsumer; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 9A000000000000000000003E /* Build configuration list for PBXProject "app" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A0000000000000000000046 /* Debug */, + 9A0000000000000000000047 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9A000000000000000000003F /* Build configuration list for PBXNativeTarget "app" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A0000000000000000000048 /* Debug */, + 9A0000000000000000000049 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9A000000000000000000003D /* Project object */; +} 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 new file mode 100644 index 00000000..87b15806 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosConsumerWiring.kt @@ -0,0 +1,279 @@ +package python.multiplatform.gradle + +import org.gradle.api.Action +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.net.URI +import java.security.MessageDigest + +/** + * Consumer-side iOS wiring (issue #90): what an app outside this repository needs to put CPython + * into an iOS app, provided by the published plugin instead of the library's own build scripts. + * + * Everything lives in the **consumer's** `build/`: the archive in `build/python-standalone/`, the + * framework in `build/xcode-frameworks/`, the per-slice standard library in `build/python-ios-home/` + * and the Xcode script in `build/python-multiplatform/xcode/`. No home-directory or shared cache. + */ + +/** Nested `ios { }` block of `pythonBindings { }`. */ +interface PythonBindingsIosExtension { + /** + * Whether every Kotlin/Native iOS framework binary of this module gets + * `-framework Python -F>` and its link task + * depends on [PREPARE_XCFRAMEWORK_TASK]. Defaults to true. Turn it off for an app that links + * `Python.framework` some other way. + */ + val linkPythonFramework: Property +} + +internal const val DOWNLOAD_IOS_TASK = "downloadPython_ios" +internal const val PREPARE_XCFRAMEWORK_TASK = "preparePythonXcframework" +internal const val STAGE_IOS_HOME_TASK = "stageIosPythonHome" +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" + +/** `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) { + "ios_arm64" -> "ios-arm64" + "ios_simulator_arm64", "ios_x64" -> "ios-arm64_x86_64-simulator" + else -> null +} + +internal fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val n = input.read(buffer) + if (n < 0) break + digest.update(buffer, 0, n) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +/** `:app:stageIosPythonHomeForXcode`, and `:stageIosPythonHomeForXcode` for the root project. */ +internal fun xcodeHomeTaskPath(projectPath: String): String = + (if (projectPath == ":") "" else projectPath) + ":" + STAGE_IOS_HOME_FOR_XCODE_TASK + +/** + * Downloads the pinned CPython iOS support archive into `build/python-standalone/` of the consumer + * and extracts it beside it. + * + * ### Trust + * + * The pinned SHA-256 ([expectedSha256], baked into the plugin at plugin build time from the root + * `python-checksums.properties`) is the only trust anchor here. The library build additionally + * checks python.org's sigstore bundle; that needs a sigstore client and network services a consumer + * build should not need, so it is deliberately absent from this path. A mismatch fails the build. + * A cached archive that does not match is deleted and fetched once more, and the second copy has to + * verify too. + */ +abstract class DownloadPythonIosTask : DefaultTask() { + + @get:Input + abstract val archiveUrl: Property + + @get:Input + abstract val archiveName: Property + + @get:Input + abstract val expectedSha256: Property + + /** `build/python-standalone`: holds the archive and `extracted/ios/Python.xcframework`. */ + @get:OutputDirectory + abstract val workDirectory: DirectoryProperty + + /** `/extracted/ios`. */ + @get:org.gradle.api.tasks.Internal + val extractDirectory: File get() = File(workDirectory.get().asFile, "extracted/ios") + + @TaskAction + fun provision() { + val work = workDirectory.get().asFile + work.mkdirs() + val archive = File(work, archiveName.get()) + val expected = expectedSha256.get().lowercase() + + if (archive.isFile && sha256Hex(archive) != expected) { + logger.lifecycle("The cached ${archive.name} does not match its pinned SHA-256; deleting it and downloading once more.") + archive.delete() + } + if (!archive.isFile) { + fetch(archiveUrl.get(), archive) + val actual = sha256Hex(archive) + if (actual != expected) { + archive.delete() + throw GradleException( + "SHA-256 mismatch for ${archiveUrl.get()}: expected $expected, got $actual. The downloaded file was " + + "deleted. The expected value is pinned in this plugin; do not work around it, report it if the " + + "upstream archive was replaced.", + ) + } + } + + val extract = extractDirectory + val stamp = File(extract, ".archive-stamp") + val wanted = "$expected ${archive.name}" + if (!(stamp.isFile && stamp.readText().trim() == wanted && File(extract, "Python.xcframework").isDirectory)) { + deleteWithoutFollowingLinks(extract) + extract.mkdirs() + extractTarGzMaterialisingLinks(archive, extract) + if (!File(extract, "Python.xcframework").isDirectory) { + throw GradleException("${archive.name} was extracted into $extract but contains no Python.xcframework/.") + } + stamp.writeText(wanted + "\n") + } + } + + private fun fetch(url: String, target: File) { + logger.lifecycle("Downloading $url") + val part = File(target.parentFile, target.name + ".part") + try { + URI(url).toURL().openStream().use { input -> part.outputStream().use { input.copyTo(it) } } + } catch (e: java.io.IOException) { + part.delete() + throw GradleException("could not download $url: ${e.message}", e) + } + if (!part.renameTo(target)) throw GradleException("could not move $part to $target") + } +} + +/** + * Copies the Xcode build phase script out of the plugin into the consumer's build directory and + * points its default `PYTHON_HOME_TASK` at the consumer's own task. + */ +abstract class InstallPythonXcodeScriptTask : DefaultTask() { + + /** The task path baked in as the script's default, e.g. `:app:stageIosPythonHomeForXcode`. */ + @get:Input + abstract val homeTaskPath: Property + + @get:OutputFile + abstract val script: org.gradle.api.file.RegularFileProperty + + @TaskAction + fun install() { + val text = InstallPythonXcodeScriptTask::class.java.getResourceAsStream("/xcode/install-python.sh") + ?.use { it.readBytes().toString(Charsets.UTF_8) } + ?: throw GradleException("the plugin jar carries no /xcode/install-python.sh") + if (SCRIPT_LIBRARY_HOME_TASK !in text) { + throw GradleException("install-python.sh no longer contains its default home task '$SCRIPT_LIBRARY_HOME_TASK'; update InstallPythonXcodeScriptTask.") + } + val target = script.get().asFile + target.parentFile.mkdirs() + target.writeText(text.replace(SCRIPT_LIBRARY_HOME_TASK, homeTaskPath.get())) + target.setExecutable(true, false) + } +} + +private fun classChain(type: Class<*>): Sequence> = generateSequence(type) { it.superclass } + +private fun invokeNoArg(target: Any, getter: String): Any? = + target.javaClass.methods.firstOrNull { it.name == getter && it.parameterCount == 0 } + ?.also { it.isAccessible = true } + ?.invoke(target) + +/** (target, konan target name) for every Kotlin/Native target of [project]; empty without the KMP plugin. */ +internal fun nativeTargetsOf(project: Project): List> { + val kotlin = project.extensions.findByName("kotlin") ?: return emptyList() + val targets = invokeNoArg(kotlin, "getTargets") as? Iterable<*> ?: return emptyList() + return targets.filterNotNull().mapNotNull { target -> + val konan = invokeNoArg(target, "getKonanTarget") ?: return@mapNotNull null + val name = invokeNoArg(konan, "getName") as? String ?: return@mapNotNull null + target to name + } +} + +internal fun PythonBindingsPlugin.configureIosConsumer(project: Project, extension: PythonBindingsExtension) { + val iosTargets = nativeTargetsOf(project).filter { xcframeworkSliceForKonanTarget(it.second) != null } + if (iosTargets.isEmpty()) return + + val build = project.layout.buildDirectory + + val download = project.tasks.register(DOWNLOAD_IOS_TASK, DownloadPythonIosTask::class.java) { + group = "python" + description = "Downloads and verifies the pinned CPython iOS support archive into build/python-standalone (issue #90)" + archiveUrl.convention(DEFAULT_IOS_ARCHIVE_URL) + archiveName.convention(DEFAULT_IOS_ARCHIVE_NAME) + expectedSha256.convention(DEFAULT_IOS_ARCHIVE_SHA256) + workDirectory.convention(build.dir("python-standalone")) + } + val xcframeworkSource = download.flatMap { task -> task.workDirectory.dir("extracted/ios/Python.xcframework") } + + val prepare = project.tasks.register(PREPARE_XCFRAMEWORK_TASK, Sync::class.java) { + group = "python" + description = "Stages Python.xcframework into build/xcode-frameworks for the Xcode project and the linker (issue #90)" + from(xcframeworkSource) + into(build.dir("xcode-frameworks/Python.xcframework")) + } + + if (extension.ios.linkPythonFramework.getOrElse(true)) { + for ((target, konanName) in iosTargets) { + val slice = xcframeworkSliceForKonanTarget(konanName)!! + val frameworkDir = File(build.get().asFile, "xcode-frameworks/Python.xcframework/$slice").absolutePath + val binaries = invokeNoArg(target, "getBinaries") as? Iterable<*> ?: continue + for (binary in binaries.filterNotNull()) { + if (classChain(binary.javaClass).none { it.simpleName == "Framework" }) continue + @Suppress("UNCHECKED_CAST") + val opts = invokeNoArg(binary, "getLinkerOpts") as? MutableList ?: continue + opts.addAll(listOf("-framework", "Python", "-F$frameworkDir")) + } + } + project.tasks.matching { + it.name.startsWith("link") && it.name.contains("Framework") && it.name.contains("Ios") + }.configureEach { dependsOn(prepare) } + } + + val homeRoot = build.dir("python-ios-home") + val slices = IosPythonHomeLayout.slices.map { slice -> + project.tasks.register(slice.stageTaskName, StageIosPythonHomeTask::class.java) { + group = "python" + description = "Stages CPython's standard library for an iOS app built for ${slice.name} (issue #90)" + xcframework.set(xcframeworkSource) + sliceName.set(slice.name) + pythonVersion.set(DEFAULT_PYTHON_VERSION) + destinationDir.set(homeRoot.map { it.dir(slice.name) }) + } + } + + project.tasks.register(STAGE_IOS_HOME_TASK) { + group = "python" + description = "Stages CPython's standard library for every iOS app slice (issue #90)" + dependsOn(slices) + } + + project.tasks.register(STAGE_IOS_HOME_FOR_XCODE_TASK) { + group = "python" + description = "Stages the iOS app prefix for the slice Xcode is building and prints its path (issue #90)" + val platformName = project.providers.environmentVariable("EFFECTIVE_PLATFORM_NAME") + val archs = project.providers.environmentVariable("ARCHS") + val selected = project.providers.provider { IosPythonHomeLayout.sliceFor(platformName.orNull, archs.orNull) } + dependsOn(selected.map { project.tasks.named(it.stageTaskName) }) + val root = homeRoot.map { it.asFile.absolutePath } + doLast { + val slice = selected.get().name + println("PYTHON_HOME_DIR=${root.get()}/$slice/${StageIosPythonHomeTask.PREFIX_DIRECTORY}") + println("PYTHON_DYLIB_INFO_TEMPLATE=${root.get()}/$slice/${StageIosPythonHomeTask.TEMPLATE_NAME}") + } + } + + project.tasks.register(INSTALL_XCODE_SCRIPT_TASK, InstallPythonXcodeScriptTask::class.java) { + group = "python" + description = "Copies install-python.sh to build/python-multiplatform/xcode for the Xcode Run Script phase (issue #90)" + homeTaskPath.set(xcodeHomeTaskPath(project.path)) + script.set(build.file("python-multiplatform/xcode/install-python.sh")) + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt index 38bcb9fe..8c6e962d 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt @@ -231,6 +231,16 @@ interface PythonBindingsExtension { * needs per environment. */ val generateStubs: Property + + /** + * iOS settings (issue #90): `pythonBindings { ios { linkPythonFramework.set(false) } }`. The + * iOS tasks exist only in a module that declares Kotlin/Native iOS targets. + */ + @get:org.gradle.api.tasks.Nested + val ios: PythonBindingsIosExtension + + /** The `ios { }` block itself: without it the nested property is reachable only as `ios.x`. */ + fun ios(action: org.gradle.api.Action) = action.execute(ios) } /** @@ -287,6 +297,11 @@ class PythonBindingsPlugin : Plugin { } configurePythonHomeStaging(project, extension) + // After evaluation, because the targets and their framework binaries are declared in the + // consumer's own `kotlin { }` block. A module without iOS targets gets no task. + project.pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + project.afterEvaluate { configureIosConsumer(this, extension) } + } configureWasmBrowserRuntimeStaging(project, extension) project.afterEvaluate { diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosConsumerWiringTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosConsumerWiringTest.kt new file mode 100644 index 00000000..645ee524 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosConsumerWiringTest.kt @@ -0,0 +1,254 @@ +package python.multiplatform.gradle + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Stand-ins for the Kotlin Gradle Plugin types the plugin reaches by reflection (see `TEST_WORD`). */ +object IosDummies { + class KonanTarget(private val n: String) { + fun getName(): String = n + } + + class Framework { + private val opts = mutableListOf() + fun getLinkerOpts(): MutableList = opts + } + + class Executable { + private val opts = mutableListOf() + fun getLinkerOpts(): MutableList = opts + } + + class Target(private val konan: String, private val bins: List) { + fun getKonanTarget(): KonanTarget = KonanTarget(konan) + fun getBinaries(): Iterable = bins + } + + class Kotlin(private val targets: List) { + fun getTargets(): Iterable = targets + } +} + +class IosConsumerWiringTest { + + private val arm64 = IosDummies.Framework() + private val sim = IosDummies.Framework() + private val x64 = IosDummies.Framework() + private val exe = IosDummies.Executable() + + private fun project(withIos: Boolean = true): Project { + val project = ProjectBuilder.builder().build() + val targets = if (withIos) { + listOf( + IosDummies.Target("ios_arm64", listOf(arm64, exe)), + IosDummies.Target("ios_simulator_arm64", listOf(sim)), + IosDummies.Target("ios_x64", listOf(x64)), + IosDummies.Target("linux_x64", listOf(IosDummies.Framework())), + ) + } else { + listOf(IosDummies.Target("linux_x64", emptyList())) + } + project.extensions.add("kotlin", IosDummies.Kotlin(targets)) + return project + } + + private fun extension(project: Project) = + project.extensions.create("pythonBindingsUnderTest", PythonBindingsExtension::class.java) + + private fun wire(project: Project, configure: PythonBindingsExtension.() -> Unit = {}): PythonBindingsExtension { + val ext = extension(project) + ext.configure() + PythonBindingsPlugin().configureIosConsumer(project, ext) + return ext + } + + @Test + fun noIosTargetMeansNoTask() { + val project = project(withIos = false) + wire(project) + val iosTasks = project.tasks.names.filter { + it == DOWNLOAD_IOS_TASK || it == PREPARE_XCFRAMEWORK_TASK || it.startsWith("stageIosPythonHome") || + it == INSTALL_XCODE_SCRIPT_TASK + } + assertEquals(emptyList(), iosTasks, "a module without iOS targets must get none: ${project.tasks.names}") + } + + @Test + fun iosTargetsRegisterEveryTask() { + val project = project() + wire(project) + val names = project.tasks.names + for (n in listOf(DOWNLOAD_IOS_TASK, PREPARE_XCFRAMEWORK_TASK, STAGE_IOS_HOME_TASK, STAGE_IOS_HOME_FOR_XCODE_TASK, INSTALL_XCODE_SCRIPT_TASK)) { + assertTrue(n in names, "$n missing: $names") + } + for (slice in IosPythonHomeLayout.slices) assertTrue(slice.stageTaskName in names, "${slice.stageTaskName} missing") + } + + @Test + fun linkerOptsAreAddedPerSliceToFrameworksOnly() { + val project = project() + wire(project) + val base = File(project.layout.buildDirectory.get().asFile, "xcode-frameworks/Python.xcframework") + assertEquals(listOf("-framework", "Python", "-F${File(base, "ios-arm64").absolutePath}"), arm64.getLinkerOpts()) + assertEquals(listOf("-framework", "Python", "-F${File(base, "ios-arm64_x86_64-simulator").absolutePath}"), sim.getLinkerOpts()) + assertEquals(sim.getLinkerOpts(), x64.getLinkerOpts()) + assertEquals(emptyList(), exe.getLinkerOpts(), "only framework binaries are touched") + } + + @Test + fun linkPythonFrameworkFalseAddsNothingAndNoDependency() { + val project = project() + project.tasks.register("linkDebugFrameworkIosArm64") + wire(project) { ios.linkPythonFramework.set(false) } + assertEquals(emptyList(), arm64.getLinkerOpts()) + assertEquals(emptyList(), sim.getLinkerOpts()) + val link = project.tasks.getByName("linkDebugFrameworkIosArm64") + assertFalse(link.taskDependencies.getDependencies(link).any { it.name == PREPARE_XCFRAMEWORK_TASK }) + } + + /** The documented spelling, `ios { linkPythonFramework.set(false) }`, goes through the block function. */ + @Test + fun theIosBlockFunctionConfiguresTheSameSetting() { + val project = project() + wire(project) { ios { linkPythonFramework.set(false) } } + assertEquals(emptyList(), arm64.getLinkerOpts()) + assertEquals(emptyList(), sim.getLinkerOpts()) + } + + @Test + fun frameworkLinkTasksDependOnPrepare() { + val project = project() + project.tasks.register("linkDebugFrameworkIosSimulatorArm64") + wire(project) + project.tasks.register("linkReleaseFrameworkIosArm64") + for (n in listOf("linkDebugFrameworkIosSimulatorArm64", "linkReleaseFrameworkIosArm64")) { + val t = project.tasks.getByName(n) + assertTrue(t.taskDependencies.getDependencies(t).any { it.name == PREPARE_XCFRAMEWORK_TASK }, n) + } + } + + @Test + fun downloadsGoUnderTheProjectsBuildDirectory() { + val project = project() + wire(project) + val task = project.tasks.getByName(DOWNLOAD_IOS_TASK) as DownloadPythonIosTask + val build = project.layout.buildDirectory.get().asFile + assertEquals(File(build, "python-standalone"), task.workDirectory.get().asFile) + assertTrue(task.extractDirectory.startsWith(File(build, "python-standalone"))) + assertEquals(DEFAULT_IOS_ARCHIVE_SHA256, task.expectedSha256.get()) + assertEquals(64, DEFAULT_IOS_ARCHIVE_SHA256.length) + } + + @Test + fun theStageTasksWriteUnderTheProjectsBuildDirectory() { + val project = project() + wire(project) + val build = project.layout.buildDirectory.get().asFile + val stage = project.tasks.getByName(IosPythonHomeLayout.slices[0].stageTaskName) as StageIosPythonHomeTask + assertEquals(File(build, "python-ios-home/${IosPythonHomeLayout.slices[0].name}"), stage.destinationDir.get().asFile) + } + + // ---- download task ------------------------------------------------------------------------ + + private fun tarGz(entries: Map): ByteArray { + val tar = ByteArrayOutputStream() + for ((name, content) in entries) { + val data = content.toByteArray() + val h = ByteArray(512) + fun put(off: Int, s: String) = s.toByteArray().copyInto(h, off) + put(0, name) + put(100, "0000644\u0000") + put(108, "0000000\u0000") + put(116, "0000000\u0000") + put(124, "%011o\u0000".format(data.size)) + put(136, "00000000000\u0000") + put(148, " ") + h[156] = '0'.code.toByte() + put(257, "ustar\u000000") + put(148, "%06o\u0000 ".format(h.sumOf { it.toInt() and 0xff })) + tar.write(h) + tar.write(data) + tar.write(ByteArray((512 - data.size % 512) % 512)) + } + tar.write(ByteArray(1024)) + val out = ByteArrayOutputStream() + GZIPOutputStream(out).use { it.write(tar.toByteArray()) } + return out.toByteArray() + } + + private fun downloadTask(archiveBytes: ByteArray, sha: String): Pair { + val project = project() + wire(project) + val src = Files.createTempFile(project.layout.buildDirectory.get().asFile.also { it.mkdirs() }.toPath(), "src", ".tar.gz").toFile() + src.writeBytes(archiveBytes) + val task = project.tasks.getByName(DOWNLOAD_IOS_TASK) as DownloadPythonIosTask + task.archiveUrl.set(src.toURI().toString()) + task.archiveName.set("fake-ios.tar.gz") + task.expectedSha256.set(sha) + return task to src + } + + @Test + fun aMatchingArchiveIsExtractedUnderTheBuildDirectory() { + val bytes = tarGz(mapOf("Python.xcframework/Info.plist" to "plist")) + val src = Files.createTempFile("ios", ".tar.gz").toFile().also { it.writeBytes(bytes) } + val (task, _) = downloadTask(bytes, sha256Hex(src)) + task.provision() + assertEquals("plist", File(task.extractDirectory, "Python.xcframework/Info.plist").readText()) + assertTrue(File(task.workDirectory.get().asFile, "fake-ios.tar.gz").isFile) + src.delete() + } + + @Test + fun aChecksumMismatchFailsAndLeavesNoArchive() { + val bytes = tarGz(mapOf("Python.xcframework/Info.plist" to "plist")) + val (task, _) = downloadTask(bytes, "0".repeat(64)) + val e = assertFailsWith { task.provision() } + assertTrue("SHA-256 mismatch" in e.message.orEmpty(), e.message) + assertFalse(File(task.workDirectory.get().asFile, "fake-ios.tar.gz").exists()) + assertFalse(File(task.extractDirectory, "Python.xcframework").exists()) + } + + // ---- script ------------------------------------------------------------------------------- + + @Test + fun theCopiedScriptIsExecutableAndBakesTheConsumersTask() { + val project = project() + wire(project) + val task = project.tasks.getByName(INSTALL_XCODE_SCRIPT_TASK) as InstallPythonXcodeScriptTask + task.install() + val script = task.script.get().asFile + assertEquals(File(project.layout.buildDirectory.get().asFile, "python-multiplatform/xcode/install-python.sh"), script) + assertTrue(script.canExecute()) + val text = script.readText() + assertTrue("\${PYTHON_HOME_TASK:-:stageIosPythonHomeForXcode}" in text, "root project path: ${text.lines().filter { "PYTHON_HOME_TASK" in it }}") + assertFalse(SCRIPT_LIBRARY_HOME_TASK in text) + } + + @Test + fun theBakedTaskPathNamesTheProject() { + assertEquals(":app:stageIosPythonHomeForXcode", xcodeHomeTaskPath(":app")) + assertEquals(":stageIosPythonHomeForXcode", xcodeHomeTaskPath(":")) + } + + @Test + fun theSliceMapping() { + assertEquals("ios-arm64", xcframeworkSliceForKonanTarget("ios_arm64")) + assertEquals("ios-arm64_x86_64-simulator", xcframeworkSliceForKonanTarget("ios_simulator_arm64")) + assertEquals("ios-arm64_x86_64-simulator", xcframeworkSliceForKonanTarget("ios_x64")) + assertNull(xcframeworkSliceForKonanTarget("linux_x64")) + assertNotNull(DEFAULT_IOS_ARCHIVE_URL) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d7fe99d5..c7696ebe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -88,10 +88,13 @@ include(":python-multiplatform-gradle-plugin-fixtures:artifact-valueclass") // ROADMAP ยง16e's "investigated, not implemented" half. `KlibScanner` reads `.klib` metadata with // `LibraryAbiReader` instead of ASM. include(":python-multiplatform-gradle-plugin-fixtures:klib-artifact") +// Issue #90: an iOS app from outside `:python-multiplatform`, using only the bindings plugin's own +// iOS tasks (Python.xcframework, linker options, stdlib staging, the Xcode script). See its README. +include(":python-multiplatform-gradle-plugin-fixtures:ios-consumer") // The container project for the three above (no build file, no plugins), then each fixture. project(":python-multiplatform-gradle-plugin-fixtures").projectDir = file("python-multiplatform-gradle-plugin/fixtures") -for (name in listOf("artifact", "artifact-valueclass", "klib-artifact")) { +for (name in listOf("artifact", "artifact-valueclass", "klib-artifact", "ios-consumer")) { project(":python-multiplatform-gradle-plugin-fixtures:$name").projectDir = file("python-multiplatform-gradle-plugin/fixtures/$name") }