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
45 changes: 45 additions & 0 deletions python-multiplatform-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand Down
43 changes: 43 additions & 0 deletions python-multiplatform-gradle-plugin/fixtures/ios-consumer/README.md
Original file line number Diff line number Diff line change
@@ -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<slice>
./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`).
Original file line number Diff line number Diff line change
@@ -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.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""The fixture's payload: copied to <app>/python/ by the Xcode Run Script phase."""


def status():
return "ok"
Original file line number Diff line number Diff line change
@@ -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 `<app>/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)
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>
Loading