diff --git a/.github/scripts/maven/check_central_bundle.py b/.github/scripts/maven/check_central_bundle.py new file mode 100755 index 0000000..352ffc8 --- /dev/null +++ b/.github/scripts/maven/check_central_bundle.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Check a Maven staging directory against Maven Central's requirements (pypackpack#99, toolchain#97). + + check_central_bundle.py [--plugin-id ] [--unsigned] + +Central (https://central.sonatype.org/publish/requirements/) requires, for every artifact: +the jar, `-sources.jar` and `-javadoc.jar` beside the `.pom`; an `.asc` signature and `.md5`/`.sha1` +checksums for each file; and a POM with name, description, url, a license, a developer and SCM. +`--plugin-id` also requires the Gradle plugin marker (`:.gradle.plugin`). `--unsigned` skips the +`.asc` check, for a rehearsal without a key. Exit 0 when everything is there; otherwise every problem is +printed and the exit is 1. +""" +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +NS = {"m": "http://maven.apache.org/POM/4.0.0"} + + +def artifact_dir(root: Path, group: str, artifact: str, version: str) -> Path: + return root.joinpath(*group.split("."), artifact, version) + + +def check_files(directory: Path, artifact: str, version: str, kinds, signed: bool, problems: list): + base = f"{artifact}-{version}" + for kind in kinds: + name = base + kind + f = directory / name + if not f.is_file(): + problems.append(f"missing {f}") + continue + suffixes = [".md5", ".sha1"] + ([".asc"] if signed else []) + for suffix in suffixes: + if not (directory / (name + suffix)).is_file(): + problems.append(f"missing {name}{suffix}") + + +def text(pom, path): + node = pom.find(path, NS) + return (node.text or "").strip() if node is not None else "" + + +def check_pom(pom_file: Path, problems: list): + if not pom_file.is_file(): + return + pom = ET.parse(pom_file).getroot() + for path, what in [ + ("m:name", "name"), + ("m:description", "description"), + ("m:url", "url"), + ("m:licenses/m:license/m:name", "license name"), + ("m:licenses/m:license/m:url", "license url"), + ("m:developers/m:developer/m:name", "developer name"), + ("m:scm/m:url", "scm url"), + ("m:scm/m:connection", "scm connection"), + ]: + if not text(pom, path): + problems.append(f"{pom_file.name}: no {what}") + + +def main(argv): + rest = list(argv[1:]) + signed = "--unsigned" not in rest + plugin_id = None + if "--plugin-id" in rest: + i = rest.index("--plugin-id") + plugin_id = rest[i + 1] + del rest[i : i + 2] + args = [a for a in rest if not a.startswith("--")] + if len(args) != 4: + print(__doc__) + return 2 + root, group, artifact, version = Path(args[0]), args[1], args[2], args[3] + problems: list = [] + + main_dir = artifact_dir(root, group, artifact, version) + check_files(main_dir, artifact, version, [".jar", "-sources.jar", "-javadoc.jar", ".pom"], signed, problems) + check_pom(main_dir / f"{artifact}-{version}.pom", problems) + + if plugin_id: + marker = f"{plugin_id}.gradle.plugin" + marker_dir = artifact_dir(root, plugin_id, marker, version) + check_files(marker_dir, marker, version, [".pom"], signed, problems) + check_pom(marker_dir / f"{marker}-{version}.pom", problems) + + for p in problems: + print(p) + print("OK" if not problems else f"{len(problems)} problem(s)") + return 0 if not problems else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/publish-gradle-plugin.yml b/.github/workflows/publish-gradle-plugin.yml new file mode 100644 index 0000000..57b4635 --- /dev/null +++ b/.github/workflows/publish-gradle-plugin.yml @@ -0,0 +1,120 @@ +name: Publish the Gradle plugin + +# Publishing a GitHub Release tagged `v` (the plugin's tags, #71) stages and checks the plugin +# and its marker (.github/scripts/maven/check_central_bundle.py --plugin-id) and publishes them to the +# Gradle Plugin Portal (`publishPlugins`). A manual run does everything except the upload (#99). +# +# Nothing can upload until the user has created the `gradle-plugin-portal` environment (restricted to +# `v*` tags) with its secrets and published such a release; without a required secret the job stops +# and names it. The plugin depends on packpack, which the Portal resolves from Maven Central, so the +# job also stops while gradle.properties' packpackVersion is not on Central. docs/SPEC.md §1.19. +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-gradle-plugin-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + # Releases tagged toolchain-lite-v* are tcl's (publish-pypi.yml). + if: github.event_name != 'release' || (startsWith(github.event.release.tag_name, 'v')) + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ${{ github.event_name == 'release' && 'gradle-plugin-portal' || '' }} + env: + IS_RELEASE: ${{ github.event_name == 'release' }} + TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v4 + + - name: The tag names the version; packpack is on Central; the version is new on the Portal + run: | + prop() { sed -n "s/^$1=//p" gradle.properties; } + group=$(prop mavenGroup); version=$(prop toolchainVersion); plugin=$(prop pluginId) + packpack=$(prop packpackModule); packpack_version=$(prop packpackVersion) + echo "plugin $plugin $version ($group:toolchain) packpack $packpack:$packpack_version tag: ${TAG:-none (dry run)}" + { echo "GROUP=$group"; echo "VERSION=$version"; echo "PLUGIN_ID=$plugin"; } >> "$GITHUB_ENV" + fail() { if [ "$IS_RELEASE" = true ]; then echo "::error::$1"; exit 1; else echo "::warning::$1"; fi; } + if [ "$IS_RELEASE" = true ] && [ "$TAG" != "v$version" ]; then + echo "::error::release tag '$TAG' is not 'v$version' (gradle.properties toolchainVersion)"; exit 1 + fi + pp_group=${packpack%%:*}; pp_artifact=${packpack##*:} + curl -fsS -o /dev/null "https://repo1.maven.org/maven2/${pp_group//.//}/$pp_artifact/$packpack_version/$pp_artifact-$packpack_version.pom" \ + || fail "$packpack:$packpack_version is not on Maven Central; publish packpack first (pypackpack publish-maven.yml)" + marker="https://plugins.gradle.org/m2/${plugin//.//}/$plugin.gradle.plugin/$version/$plugin.gradle.plugin-$version.pom" + if curl -fsS -o /dev/null "$marker"; then fail "$plugin $version is already on the Plugin Portal; bump toolchainVersion"; fi + + - name: The publishing secrets are there + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + run: | + missing="" + for name in GRADLE_PUBLISH_KEY GRADLE_PUBLISH_SECRET; do + [ -n "${!name}" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + if [ "$IS_RELEASE" = true ]; then + echo "::error::missing secrets in the gradle-plugin-portal environment:$missing (docs/SPEC.md §1.19)"; exit 1 + fi + echo "::notice::dry run without:$missing; nothing is uploaded" + fi + # The Portal does not require signatures; with SIGNING_KEY (and SIGNING_KEY_PASSWORD) they are added. + [ -n "$SIGNING_KEY" ] || echo "::notice::no SIGNING_KEY: the plugin is published unsigned" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: gradle/actions/setup-gradle@v4 + + # The plugin builds against packpack; until packpack is on Maven Central (a dry run before its + # first release), build it from pypackpack's develop into mavenLocal, as test.yml does. + - name: packpack, from pypackpack, when Central does not have it yet + if: github.event_name != 'release' + run: | + prop() { sed -n "s/^$1=//p" gradle.properties; } + packpack=$(prop packpackModule); v=$(prop packpackVersion); g=${packpack%%:*}; a=${packpack##*:} + if ! curl -fsS -o /dev/null "https://repo1.maven.org/maven2/${g//.//}/$a/$v/$a-$v.pom"; then + git clone --depth 1 --branch develop https://github.com/thisisthepy/pypackpack.git "$RUNNER_TEMP/pypackpack" + (cd "$RUNNER_TEMP/pypackpack" && ./gradlew :packpack:publishToMavenLocal --console=plain) + fi + + - name: Stage the plugin and its marker (signed when a key is there) + env: + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + run: | + if [ -z "$ORG_GRADLE_PROJECT_signingInMemoryKey" ]; then + unset ORG_GRADLE_PROJECT_signingInMemoryKey ORG_GRADLE_PROJECT_signingInMemoryKeyPassword + fi + ./gradlew :toolchain:publishAllPublicationsToStagingRepository --console=plain + + - name: The plugin and its marker meet the requirements + env: + SIGNED: ${{ secrets.SIGNING_KEY != '' }} + run: | + flag="" + [ "$SIGNED" = true ] || flag="--unsigned" + python3 .github/scripts/maven/check_central_bundle.py toolchain/build/staging-deploy "$GROUP" toolchain "$VERSION" --plugin-id "$PLUGIN_ID" $flag + + - name: Publish to the Gradle Plugin Portal + if: github.event_name == 'release' + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + run: | + if [ -z "$ORG_GRADLE_PROJECT_signingInMemoryKey" ]; then + unset ORG_GRADLE_PROJECT_signingInMemoryKey ORG_GRADLE_PROJECT_signingInMemoryKeyPassword + fi + ./gradlew :toolchain:publishPlugins --console=plain diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 82a9ca6..68081b6 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -1,19 +1,14 @@ name: iOS e2e -# toolchain#22, iOS: sample's app on an arm64 iOS simulator runs Python with the stdlib and +# toolchain#22, iOS: usage-example's app on an arm64 iOS simulator runs Python with the stdlib and # Python.framework python-multiplatform puts into the .app, plus this plugin's python/ payload. # macOS minutes cost ~10x, so this runs only for iOS wiring changes, on develop and by hand, and is # informational (not a required check). Lead decision, 2026-10-03. on: + # Checked on develop after merging, not on every pull request (user decision, + # 2026-10-17). A red develop is fixed forward by its own pull request. push: branches: [develop] - pull_request: - paths: - - "sample/src/iosMain/**" - - "sample/build.gradle.kts" - - "sample/gradle/**" - - "toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/**" - - ".github/workflows/test-ios.yml" workflow_dispatch: permissions: @@ -21,7 +16,7 @@ permissions: concurrency: group: ios-e2e-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: ios-e2e: @@ -48,7 +43,7 @@ jobs: - uses: actions/cache@v4 with: path: ~/.konan - key: konan-${{ runner.os }}-${{ hashFiles('sample/gradle/libs.versions.toml') }} + key: konan-${{ runner.os }}-${{ hashFiles('usage-example/gradle/libs.versions.toml') }} - name: Publish packpack, the toolchain plugin and python-multiplatform (iOS simulator) run: | @@ -65,10 +60,10 @@ jobs: run: | set -euo pipefail pmd="$GITHUB_WORKSPACE/.ci/python-multiplatform" - slice="$pmd/python-multiplatform/build/python-standalone/extracted/3.14.7/ios/Python.xcframework/ios-arm64_x86_64-simulator" + slice="$pmd/python-multiplatform/build/python-standalone/extracted/3.15.0/ios/Python.xcframework/ios-arm64_x86_64-simulator" # TODO(python-multiplatform#90): PYTHON_MULTIPLATFORM_DIR and the -framework Python link flags # are interim; the published plugin will stage Python.xcframework and its link options. - xcodebuild -project sample/src/iosMain/app.xcodeproj -target iosApp \ + xcodebuild -project usage-example/src/iosMain/app.xcodeproj -target iosApp \ -sdk iphonesimulator -configuration Debug \ ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO \ SYMROOT="$GITHUB_WORKSPACE/build-ios" \ @@ -90,17 +85,39 @@ jobs: test -d "$app/Frameworks/Python.framework" || { echo "no Python.framework in the app"; exit 1; } - name: Run the app's Python on a simulator + # The app writes its report to Documents/python-smoke.txt (PythonSmoke.ios.kt): reading the console + # with `launch --console-pty` lost it three times when the app had exited before the pty attached + # (toolchain#92). Wait until SpringBoard runs, launch without a pty, poll for the file, and relaunch + # once if it never appears. run: | set -euo pipefail udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith("iPhone")))') xcrun simctl boot "$udid" || true xcrun simctl bootstatus "$udid" -b + for i in $(seq 1 60); do + xcrun simctl spawn "$udid" launchctl list 2>/dev/null | grep -q com.apple.SpringBoard && break + sleep 5 + done xcrun simctl install "$udid" "$APP" bundle=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist") - out=$(xcrun simctl launch --console-pty --terminate-running-process "$udid" "$bundle" --python-smoke 2>&1) || true - echo "$out" - printf '%s\n' "$out" | grep -q 'PYTHON_SMOKE_OK ' || { echo "no PYTHON_SMOKE_OK line"; exit 1; } - stdlib=$(printf '%s\n' "$out" | sed -n 's/.*PYTHON_SMOKE_STDLIB //p' | tr -d '\r') + report="" + for attempt in 1 2; do + data=$(xcrun simctl get_app_container "$udid" "$bundle" data) + rm -f "$data/Documents/python-smoke.txt" + xcrun simctl launch --terminate-running-process "$udid" "$bundle" --python-smoke || true + for i in $(seq 1 60); do + [ -s "$data/Documents/python-smoke.txt" ] && break + sleep 5 + done + if [ -s "$data/Documents/python-smoke.txt" ]; then + report=$(cat "$data/Documents/python-smoke.txt") + break + fi + echo "attempt $attempt: no report after 300 s" + done + echo "$report" + printf '%s\n' "$report" | grep -q 'PYTHON_SMOKE_OK ' || { echo "no PYTHON_SMOKE_OK line"; exit 1; } + stdlib=$(printf '%s\n' "$report" | sed -n 's/.*PYTHON_SMOKE_STDLIB //p' | tr -d '\r') case "$stdlib" in *.app/python-multiplatform-home/*) echo "stdlib from the app: $stdlib" ;; *) echo "stdlib not from the app: '$stdlib'"; exit 1 ;; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33cd036..600e583 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,19 +6,20 @@ name: Test # # Both modules depend on org.thisisthepy.python.multiplatform:packpack:0.1.0 from # mavenLocal, so each job first publishes it from pypackpack's develop branch. -# sample/ is its own build (toolchain#22, #62; python-multiplatform's Kotlin), run by the -# consumer and e2e jobs with this repository's wrapper (../gradlew from sample/ = ./gradlew -p sample). +# usage-example/ is its own build (toolchain#22, #62; python-multiplatform's Kotlin), run by the +# consumer and e2e jobs with this repository's wrapper (../gradlew from usage-example/ = ./gradlew -p usage-example). on: - pull_request: + # Checked on develop after merging, not on every pull request (user decision, + # 2026-10-17). A red develop is fixed forward by its own pull request. push: branches: [develop] - + workflow_dispatch: permissions: contents: read concurrency: group: test-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: test: @@ -94,7 +95,7 @@ jobs: # Unit tests run on Kotlin 2.x's stdlib, but a consumer runs the plugin on the kotlin-stdlib Gradle # embeds. pypackpack#31 broke every suspend call that way and no unit test could see it. This job is - # a real consumer: sample (its own build) with a real pip dependency installs, bundles, zips. + # a real consumer: usage-example (its own build) with a real pip dependency installs, bundles, zips. consumer: runs-on: ubuntu-latest timeout-minutes: 60 @@ -117,13 +118,12 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - uses: actions/setup-python@v5 with: - python-version: "3.14" + python-version: "3.15" - uses: astral-sh/setup-uv@v6 - # sample applies three things from mavenLocal: packpack (through the plugin), the + # usage-example applies three things from mavenLocal: packpack (through the plugin), the # toolchain plugin, and python-multiplatform's library and bindings plugin. Versions pinned in - # sample/gradle/libs.versions.toml (library 3.14.7-alpha01, plugin 3.13.0 until - # python-multiplatform#70). + # usage-example/gradle/libs.versions.toml (3.15.0-alpha01). - name: Publish packpack, the toolchain plugin and python-multiplatform to mavenLocal run: | (cd .ci/pypackpack && ./gradlew :packpack:publishToMavenLocal --console=plain) @@ -137,7 +137,7 @@ jobs: (cd .ci/python-multiplatform && ./gradlew -p python-multiplatform-gradle-plugin publishToMavenLocal --console=plain) - name: Install, bundle and zip as a consumer - working-directory: sample + working-directory: usage-example run: | ../gradlew installPythonDependencies buildPython packagePython \ --console=plain > consumer.log 2>&1 || { cat consumer.log; exit 1; } @@ -178,7 +178,7 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - uses: actions/setup-python@v5 with: - python-version: "3.14" + python-version: "3.15" - uses: astral-sh/setup-uv@v6 - name: Publish packpack, the toolchain plugin and python-multiplatform to mavenLocal @@ -194,11 +194,11 @@ jobs: (cd .ci/python-multiplatform && ./gradlew -p python-multiplatform-gradle-plugin publishToMavenLocal --console=plain) - name: Package the desktop app - working-directory: sample + working-directory: usage-example run: ../gradlew createDistributable --console=plain - name: Run the packaged app's Python, headless and without PYTHONHOME - working-directory: sample + working-directory: usage-example run: | set -euo pipefail app=$(find build/compose/binaries/main/app -maxdepth 3 -type f -path '*/bin/*' | head -n 1) @@ -219,7 +219,7 @@ jobs: # python-multiplatform ships and unpacks (PythonBootstrap) plus this plugin's assets/python/ # (example_py and iniconfig installed for x86_64-linux-android). The smoke result goes to logcat. # Green since python-multiplatform#72 (PR #75): the emulator logs PYTHON_SMOKE_OK with the stdlib - # unpacked under /data/data//files/lib/python3.14/. + # unpacked under /data/data//files/lib/python3.15/. android-e2e: runs-on: ubuntu-latest timeout-minutes: 75 @@ -253,7 +253,7 @@ jobs: (cd .ci/python-multiplatform && ./gradlew -p python-multiplatform-gradle-plugin publishToMavenLocal --console=plain) - name: Build the debug APK - working-directory: sample + working-directory: usage-example run: ../gradlew assembleDebug --console=plain - name: Enable KVM for the emulator @@ -270,7 +270,7 @@ jobs: target: google_apis disable-animations: true script: | - set -eu; apk=$(find sample/build/outputs/apk/debug -name '*.apk' | head -n 1); [ -n "$apk" ]; adb install -r "$apk"; adb logcat -c; adb shell am start -W -n org.thisisthepy.python.multiplatform.toolchain.example/.MainActivity --ez python-smoke true; sleep 20; adb logcat -d -s PythonSmoke:V > smoke.log; cat smoke.log; grep -q 'PYTHON_SMOKE_OK ' smoke.log; grep 'PYTHON_SMOKE_OK ' smoke.log | grep -q '/data/' + set -eu; apk=$(find usage-example/build/outputs/apk/debug -name '*.apk' | head -n 1); [ -n "$apk" ]; adb install -r "$apk"; adb logcat -c; adb shell am start -W -n org.thisisthepy.python.multiplatform.toolchain.example/.MainActivity --ez python-smoke true; sleep 20; adb logcat -d -s PythonSmoke:V > smoke.log; cat smoke.log; grep -q 'PYTHON_SMOKE_OK ' smoke.log; grep 'PYTHON_SMOKE_OK ' smoke.log | grep -q '/data/' guide: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d6829ba..846061c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ captures # IDE (JetBrains Fleet) .fleet/ +.cache/ diff --git a/README.md b/README.md index 72139f8..ec45437 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ owns the Gradle vocabulary in between. | PyPI `toolchain-lite` | `tcl`, the command line for Python users (`uv tool install toolchain-lite`) | Apply the plugin next to Kotlin Multiplatform and point it at a `pypackpack` package. This is -`sample/build.gradle.kts`, trimmed: +`usage-example/build.gradle.kts`, trimmed: ```kotlin plugins { @@ -76,17 +76,17 @@ python { compileSdk = "3.13" localLibraryPath = "src/commonMain/python" // a pypackpack package: pyproject.toml + src/main/ packaging { - fileName = "sample" + fileName = "usage-example" } } ``` ```shell -./gradlew -p sample packagePython # install → bundle → zip (sample/ is its own build) -./gradlew -p sample stagePythonBundle # stage python/ for android, ios and desktop +./gradlew -p usage-example packagePython # install → bundle → zip (usage-example/ is its own build) +./gradlew -p usage-example stagePythonBundle # stage python/ for android, ios and desktop ``` -> **iOS, interim:** sample's Xcode phase (`sample/src/iosMain/install-python-phase.sh`) needs +> **iOS, interim:** usage-example's Xcode phase (`usage-example/src/iosMain/install-python-phase.sh`) needs > `PYTHON_MULTIPLATFORM_DIR` pointing at a python-multiplatform checkout for `Python.xcframework` and the > stdlib, until python-multiplatform#90 ships that wiring in its published plugin. @@ -141,7 +141,7 @@ An honest summary: the full contract, item by item, is on the guide's | `bytecode` compile level (`.pyc`; needs a `.venv` matching `compileSdk`) | ✅ implemented | | `native` / `mixed` compile levels | ⏳ planned: rejected loudly today | | Staging into the desktop jar and the APK | 🟡 partial: iOS is staged but not attached | -| Hot reload | 🟡 partial: Android only, over `adb` | +| Hot reload | ⏳ planned: not working yet on any platform (#103); a live connection to the running app replaces it | | Code push | 🟡 partial: validation only, no upload | | `buildFeatures { metaclass, compose }` | ✅ implemented: compose locations come from two Gradle properties | | `embedLevel`, `projectFlavors`, `pip { }` | ⏳ planned | diff --git a/build.gradle.kts b/build.gradle.kts index 701fe10..a82e1d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,9 +4,11 @@ plugins { alias(libs.plugins.android.application).apply(false) alias(libs.plugins.android.library).apply(false) alias(libs.plugins.compose.multiplatform).apply(false) + alias(libs.plugins.kotlin.native.desktop).apply(false) alias(libs.plugins.compose.compiler).apply(false) alias(libs.plugins.kotlin.multiplatform).apply(false) } -group = "org.thisisthepy.python.multiplatform" -version = "1.0.0-alpha" +// Both come from gradle.properties, the one place the coordinates are written (#99). +group = providers.gradleProperty("mavenGroup").get() +version = providers.gradleProperty("toolchainVersion").get() diff --git a/docs/design/live-connection.md b/docs/design/live-connection.md new file mode 100644 index 0000000..6630841 --- /dev/null +++ b/docs/design/live-connection.md @@ -0,0 +1,148 @@ +# Live connection: a notebook attached to the running app, and hot reload on every platform + +Design investigation, 2026-10-17 (the lead's instruction; the ecosystem survey's third gap). No code +changes. Everything below "Facts" was checked in the repositories named; the rest is a proposal. + +## 1. What the user's specification expects + +The specification is `pythonx-compose/UI.ipynb` (the maintainer's 2024 lecture notebook) and +`python-multiplatform/docs/design/ecosystem.md` §3. + +**The notebook's kernel is the running app's own interpreter.** Its cells do not describe a UI that is +later built; they act on the app that is on screen: + +| Cell | What it does to the running app | +|---|---| +| `import main` then `main.App` | reaches the live root object of the app's UI | +| `main.App.messages.getValue()` / `.setValue("...")` | reads and writes UI state; the screen follows | +| `@Composable def NewUI(): ...` then `main.App.update(NewUI)` | replaces what the app draws, without a restart | +| `TextField(text_state=main.App.messages)`, then `main.App.messages.getValue()` | text typed on the device is read back in the notebook | +| `for chunk in token_streamer(...): print_messages(chunk)` | the app's on-device model streams tokens into the UI, driven from a cell | + +So the experience is **cell runs, the running app changes, at once, both ways**: the notebook sees state +the user changed on the device, and the device shows what the cell set. It is not "edit a file, rebuild, +redeploy". + +**How 2024 did it** (PyREPL, `app/src/androidMain/python/main.py` and `repl/kernel/kernel_android.py`, +Chaquopy): the Android app bundled `jupyterlab`, `jupyter`, `pyzmq` and `ipykernel`; a button started a +JupyterLab server inside the app and opened the device browser on it (IP, port and password shown on the +app's screen); `UIThreadKernelManager` launched `IPKernelApp` inside the app's own process ("UI thread +sync mode", one kernel at a time). Cells therefore executed in the app's interpreter, which is why +`import main` reached the live `App`. + +**What exists now**: + +- pythonx-compose has the UI half, rebuilt on python-multiplatform: `pythonx.compose.runtime.app(fn)` + writes the root into a Compose `MutableState` (`app_root`) that the host draws, so redeclaring the root + in a cell replaces the screen with no `update` call; `remember_saveable` returns state with + `getValue`/`setValue`. python-multiplatform's notebook E2E (#26, CI job `notebook-e2e`) runs the + notebook's scenarios **in-process in a desktop test host**: it proves the UI semantics, not a live + connection from a notebook to a separately running app. +- toolchain's hot reload (SPEC §1.14) is Android only: `adb push` of every `.py` to `/data/local/tmp/...` + and an `org.thisisthepy.python.RELOAD_PYTHON` broadcast. Its `serverHost` and `cert` settings are + validated but carry nothing. python-multiplatform has no handler for that broadcast (no `RELOAD_PYTHON` in its runtime, plugin or compose sources), so today's hot reload pushes files nothing reads. +- toolchain's code push (SPEC §1.15) validates its DSL and uploads nothing. +- python-multiplatform's development-mode import gate (SPEC N-7b, #183) already defines what happens when a + notebook cell or a hot reload imports a project module: it is type-checked first. + +So the gap is exactly the **channel**: nothing connects a notebook (or a file save) on the laptop to the +interpreter inside a running desktop, Android or iOS app. + +## 2. Getting changed Python into the running app, per platform + +Two things travel over the same channel, so it is worth having one: + +1. **Code to execute** (a notebook cell): run in the app's interpreter, in a namespace that persists across + cells, with stdout, stderr, results and errors sent back. +2. **Files that changed** (hot reload on save): written into the app's payload directory, then the + affected modules re-imported. + +### Transport + +| Platform | How the laptop reaches the app | Notes | +|---|---|---| +| Desktop | `127.0.0.1:` | same machine | +| Android emulator / device | the app listens on its loopback; `adb forward tcp: tcp:` | needs `INTERNET` permission, in the debug manifest only; no network exposure | +| iOS simulator | `127.0.0.1:` | the simulator shares the Mac's network stack | +| iOS device | `iproxy` / usbmuxd port forwarding over USB, or the local network with Bonjour discovery | the only case without a loopback path; local network needs `NSLocalNetworkUsageDescription` | + +A socket for both, not file synchronisation: file sync (today's `adb push`) cannot run code, has no +equivalent on iOS, and cannot carry results back. Files go over the socket as content and are written by +the app itself into its payload directory, which is where python-multiplatform already loads from. + +### Re-import + +- A cell's code is `exec`'d into one persistent namespace (`__main__`, so `import main` and module state + behave as in 2024). +- A changed file replaces its payload copy, then `importlib.reload` runs on the modules whose files changed, + children first. Functions and objects already held by the UI keep their old code (plain CPython + semantics), so the UI is refreshed by re-declaring the root: after a reload, the runtime calls the root + module's root function again through pythonx-compose's `app(...)` if the root's module was among them. +- The N-7b import gate runs on every re-import (it is already defined for this case): a type error stops + the reload and is sent back to the notebook or the build output, and the app keeps running the old code. +- Execution thread: by default on the channel's own thread (Compose snapshot state may be written from any + thread; the interpreter is free-threaded 3.15t). A cell that must touch main-thread-only Kotlin API can ask + for the main thread (PyREPL's "UI thread sync mode"); python-multiplatform dispatches it to the platform's + main dispatcher. + +## 3. Attaching a Jupyter kernel to the app's interpreter + +| | A. `ipykernel` inside the app (2024's approach) | B. Proxy kernel on the laptop + a small agent in the app (proposed) | +|---|---|---| +| In the app | ipykernel, jupyter_client, pyzmq + libzmq, tornado, traitlets... | a stdlib-only agent: one TCP connection, JSON messages | +| Native dependencies in the app | pyzmq/libzmq. **Checked 2026-10-17: pyzmq 27.2.0 has `cp315t` wheels for desktop only; Android has `cp315` (GIL) only, iOS and WASM none.** pypackpack would have to build pyzmq and libzmq for every mobile target | none | +| Ports to forward | five ZMQ sockets (shell, iopub, stdin, control, heartbeat) per app | one | +| Jupyter fidelity | complete: completion, inspection, comms, ipywidgets, interrupts as far as ipykernel manages them in a non-main thread | what the proxy implements: execute, stdout/stderr, results and errors, completion and inspection forwarded; comms and widgets later or never | +| Free-threaded 3.15t, in-process | ipykernel's threading and signal assumptions on a non-main thread, untested on 3.15t | the agent owns its threading | +| App size and attack surface | large, debug builds only | small, debug builds only | +| Works for hot reload too | no (a kernel, not a file channel) | yes, same connection | + +**Proposed: B.** The laptop runs an ordinary Jupyter kernel (a subclass of `ipykernel.kernelbase.Kernel`, +installed as a kernelspec such as "usage-example (desktop debug)") that forwards `execute_request`, +`complete_request` and `inspect_request` over one socket to an agent inside the app, and relays the agent's +streams, results and errors back as Jupyter messages. The agent speaks a small subset of Jupyter's own +message shapes (`execute_request` / `stream` / `execute_result` / `error` / `complete_reply`), so moving to A +later stays possible. Jupyter itself (JupyterLab, VS Code, PyCharm) needs nothing special: it talks to a +normal kernel on the laptop. + +Also considered: **JupyterLab served from inside the app** (the 2024 setup in full): heavier still, and +the notebook ends up in the device's browser. **debugpy**: a debugger, not a REPL; complementary +(breakpoints), not a replacement, and also a C extension. + +Security, for any option: the agent exists in debug builds only (toolchain already gates hot reload on +`enableHotReload` per build type); it listens on loopback only (iOS device: USB or explicit opt-in to the +local network); every connection presents a token generated at build or launch and given to the laptop +side (adb, the simulator's container, or the desktop launcher), never typed by hand. The `serverHost` and +`cert` settings toolchain validates today map onto the iOS-device and remote cases. + +## 4. Which repository owns what + +| Repository | Owns | +|---|---| +| **python-multiplatform** (runtime) | The in-app agent: started in debug builds, its socket, the token, `exec` into a persistent `__main__`, stream capture, main-thread dispatch, writing pushed files into the payload directory and re-importing them (with N-7b's gate), and the platform pieces (Android debug-manifest `INTERNET`, iOS local-network string for the device case). toolchain's `AGENTS.md` §13 already says reacting to the hot-reload broadcast belongs here. | +| **toolchain** (developer side) | Finding and reaching the running app per platform (`adb forward`, the simulator's loopback, the desktop launcher's port, `iproxy` for a device); the `hotReloadPython` task rebuilt on the channel (replacing `adb push` and the broadcast), with a watch mode; installing the kernelspec for a variant; the proxy kernel itself, shipped in `toolchain_lite` (the PyPI package `tcl` already lives in) as `tcl kernel install` / `tcl connect`, so a notebook user needs only `uv tool install toolchain-lite`. | +| **pythonx-compose** (UI semantics) | What "replace the screen" and "UI state" mean: `app(...)`, `remember_saveable`, and re-declaring the root after a module reload. Already implemented for the notebook's semantics; the reload hook is the only addition. | +| **pypackpack** | Nothing on B's critical path. Under A it would build pyzmq and libzmq for Android and iOS 3.15t. | + +## 5. First milestone + +**M1, desktop, end to end**: `usage-example` (desktop, debug) runs; its agent listens on `127.0.0.1` with a +token; `tcl kernel install` registers the "usage-example (desktop debug)" kernelspec; a notebook running the +`UI.ipynb` cells (`import main`, state `getValue`/`setValue`, re-declaring the root) changes the open window +and reads back what was typed in it. Tests: the agent's protocol (execute, streams, errors, persistent +namespace, a reload under the import gate) without a UI; and a CI job that starts the packaged desktop app +and keeps it running (under Xvfb: today's `desktop-e2e` runs the app headless only for a `--python-smoke` +check that exits before any UI), connects the proxy kernel, runs the scenarios through Jupyter's client API +and fails if any regresses. Disabling the agent must make it fail. + +Then: + +- **M2, Android**: `adb forward`; `hotReloadPython` moves onto the channel (the `adb push` + broadcast path is + removed, as it has no receiver); the `android-e2e` job runs the same scenarios on the emulator. +- **M3, iOS simulator**: loopback; `ios-e2e` runs them. +- **M4, iOS device**: `iproxy` or Bonjour; manual check only (no device in CI). +- **M5, code push** (SPEC §1.15) reuses the file half of the channel against a server instead of a laptop; + separate from FastTrack's deploy (pypackpack), which waits for the server-ownership decision. + +Open for the maintainer: whether the 2024 "JupyterLab on the device" mode (a notebook with no laptop) is a +goal at all; if it is, A (or an in-app JupyterLab) comes back for that mode only. diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index fcd9817..e608a5c 100644 --- a/docs/guide/getting-started.html +++ b/docs/guide/getting-started.html @@ -40,7 +40,7 @@

1. Prerequisites1. 준비

2. A Python package2. Python 패키지

The plugin bundles a pypackpack package: a directory with a pyproject.toml and its code under src/main/. Platform-specific code can sit in src/android, src/ios and so on; the bundler overlays it.

플러그인이 번들링하는 것은 pypackpack 패키지입니다. pyproject.toml 이 있고 코드가 src/main/ 아래에 있는 디렉터리입니다. 플랫폼 전용 코드는 src/android, src/ios 등에 두면 번들러가 덮어씌웁니다.

-
sample/src/commonMain/python/
+
usage-example/src/commonMain/python/
 ├── pyproject.toml
 └── src/main/example_py/
     ├── __init__.py
@@ -59,17 +59,17 @@ 

3. Apply the plugin3. 플 compileSdk = "3.13" localLibraryPath = "src/commonMain/python" packaging { - fileName = "sample" + fileName = "usage-example" } }

-

This is the repository's own sample/build.gradle.kts. Instead of localLibraryPath you can point sourceSets { commonMain { srcDirs(...) } } at the package.

이 저장소의 sample/build.gradle.kts 그대로입니다. localLibraryPath 대신 sourceSets { commonMain { srcDirs(...) } } 로 패키지를 가리켜도 됩니다.

+

This is the repository's own usage-example/build.gradle.kts. Instead of localLibraryPath you can point sourceSets { commonMain { srcDirs(...) } } at the package.

이 저장소의 usage-example/build.gradle.kts 그대로입니다. localLibraryPath 대신 sourceSets { commonMain { srcDirs(...) } } 로 패키지를 가리켜도 됩니다.

4. Run it4. 실행

-
./gradlew -p sample packagePython            # installPythonDependencies → buildPython → zip
-./gradlew -p sample stagePythonBundle        # copy python/ where each platform packages from
-./gradlew -p sample tasks --group python     # everything the plugin registered
+
./gradlew -p usage-example packagePython            # installPythonDependencies → buildPython → zip
+./gradlew -p usage-example stagePythonBundle        # copy python/ where each platform packages from
+./gradlew -p usage-example tasks --group python     # everything the plugin registered
diff --git a/docs/guide/guide-staging.html b/docs/guide/guide-staging.html index c9203e7..8ec2862 100644 --- a/docs/guide/guide-staging.html +++ b/docs/guide/guide-staging.html @@ -52,7 +52,7 @@

iOS: the Xcode build phasei dir=$(printf '%s\n' "$out" | sed -n 's/^PYTHON_PAYLOAD_DIR=//p') [ -n "$dir" ] && [ -d "$dir" ] || { echo "error: no Python payload dir from Gradle" >&2; exit 1; } rsync -a --delete "$dir/" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/python/" -

With python-multiplatform, its tools/xcode/install-python.sh is the one phase that copies python/ (and the stdlib): give it the directory this task prints as PYTHON_PAYLOAD_DIR, as the sample's sample/src/iosMain/install-python-phase.sh does, and do not add the snippet above as well. Only one Xcode phase may copy python/, because each rsync --deletes the same directory. The snippet above is for wiring by hand without python-multiplatform.

python-multiplatform 을 쓰면 그쪽 tools/xcode/install-python.sh 가 python/(와 stdlib)을 복사하는 유일한 단계입니다. 이 태스크가 출력한 디렉터리를 PYTHON_PAYLOAD_DIR 로 넘기세요(샘플의 sample/src/iosMain/install-python-phase.sh 처럼). 위 스니펫을 함께 넣지 마세요. 둘 다 같은 디렉터리를 rsync --delete 하므로 python/ 을 복사하는 Xcode 단계는 하나여야 합니다. 위 스니펫은 python-multiplatform 없이 손으로 배선할 때를 위한 것입니다.

+

With python-multiplatform, its tools/xcode/install-python.sh is the one phase that copies python/ (and the stdlib): give it the directory this task prints as PYTHON_PAYLOAD_DIR, as usage-example's usage-example/src/iosMain/install-python-phase.sh does, and do not add the snippet above as well. Only one Xcode phase may copy python/, because each rsync --deletes the same directory. The snippet above is for wiring by hand without python-multiplatform.

python-multiplatform 을 쓰면 그쪽 tools/xcode/install-python.sh 가 python/(와 stdlib)을 복사하는 유일한 단계입니다. 이 태스크가 출력한 디렉터리를 PYTHON_PAYLOAD_DIR 로 넘기세요(샘플의 usage-example/src/iosMain/install-python-phase.sh 처럼). 위 스니펫을 함께 넣지 마세요. 둘 다 같은 디렉터리를 rsync --delete 하므로 python/ 을 복사하는 Xcode 단계는 하나여야 합니다. 위 스니펫은 python-multiplatform 없이 손으로 배선할 때를 위한 것입니다.

Do not shorten it to dir=$(./gradlew … | sed …) && rsync …: the pipeline's status is sed's, so a failed Gradle leaves dir empty and rsync --delete "/" runs.

dir=$(./gradlew … | sed …) && rsync … 처럼 줄이면 안 됩니다. 파이프라인의 종료 상태는 sed 의 것이라, Gradle 이 실패하면 dir 이 비어 rsync --delete "/" 가 실행됩니다.

diff --git a/docs/guide/guide-variants.html b/docs/guide/guide-variants.html index 0a84b83..99b3ba1 100644 --- a/docs/guide/guide-variants.html +++ b/docs/guide/guide-variants.html @@ -57,7 +57,7 @@

Build types빌드 타입

Only debug and release exist. useCodeMinifier, excludeMetaclass and enableCodePush are release-only: setting them on debug throws.

debug 와 release 만 있습니다. useCodeMinifier, excludeMetaclass, enableCodePush 는 release 전용이며 debug 에 설정하면 예외가 납니다.

compileLevelcompileLevelMeaning의미Today지금
instant (or blank)(또는 빈 값).py only.py 만implemented구현
bytecodedebug: .py + .pyc; release: .pyc onlydebug: .py + .pyc, release: .pyc 만implemented구현
nativenative code only네이티브 코드만planned (pypackpack#19)계획 (pypackpack#19)
mixedyour code as bytecode, libraries native내 코드는 바이트코드, 라이브러리는 네이티브planned (pypackpack#19)계획 (pypackpack#19)
-

bytecode compiles with the Python in a .venv at or above your package directory. installPythonDependencies leaves one there (uv add creates <package>/.venv) once you declare a dependency; otherwise run uv venv --python 3.14 in the package directory. Its minor version must match compileSdk, because a .pyc only loads on the CPython minor version that wrote it; a mismatch fails the variant. Without a declared compileSdk, or a pyvenv.cfg to read, the versions are not compared.

bytecode 는 패키지 디렉터리나 그 위의 .venv 에 있는 Python 으로 컴파일합니다. 의존성을 하나라도 선언하면 installPythonDependencies 가 그것을 만들어 둡니다(uv add 가 <package>/.venv 를 만듭니다). 그렇지 않으면 패키지 디렉터리에서 uv venv --python 3.14 를 실행하세요. 그 마이너 버전은 compileSdk 와 같아야 합니다. .pyc 는 그것을 쓴 CPython 마이너 버전에서만 로드되기 때문이며, 다르면 그 변형이 실패합니다. compileSdk 를 선언하지 않았거나 읽을 pyvenv.cfg 가 없으면 버전을 비교하지 않습니다.

+

bytecode compiles with the Python in a .venv at or above your package directory. installPythonDependencies leaves one there (uv add creates <package>/.venv) once you declare a dependency; otherwise run uv venv --python 3.15 in the package directory. Its minor version must match compileSdk, because a .pyc only loads on the CPython minor version that wrote it; a mismatch fails the variant. Without a declared compileSdk, or a pyvenv.cfg to read, the versions are not compared.

bytecode 는 패키지 디렉터리나 그 위의 .venv 에 있는 Python 으로 컴파일합니다. 의존성을 하나라도 선언하면 installPythonDependencies 가 그것을 만들어 둡니다(uv add 가 <package>/.venv 를 만듭니다). 그렇지 않으면 패키지 디렉터리에서 uv venv --python 3.15 를 실행하세요. 그 마이너 버전은 compileSdk 와 같아야 합니다. .pyc 는 그것을 쓴 CPython 마이너 버전에서만 로드되기 때문이며, 다르면 그 변형이 실패합니다. compileSdk 를 선언하지 않았거나 읽을 pyvenv.cfg 가 없으면 버전을 비교하지 않습니다.

A planned level fails only its own variant's buildPython… task, so ./gradlew packagePython --continue still builds every other variant.

계획 단계의 레벨은 그 변형의 buildPython… 태스크만 실패시키므로, ./gradlew packagePython --continue 는 나머지 변형을 모두 빌드합니다.

diff --git a/docs/guide/status.html b/docs/guide/status.html index b6223cc..d6dc6b2 100644 --- a/docs/guide/status.html +++ b/docs/guide/status.html @@ -28,7 +28,7 @@

Status & FAQ상태와 F

Feature status기능 상태

-
+
Feature기능Status상태Evidence근거
Applying the plugin: python extension and tasks플러그인 적용: python 확장과 태스크implemented구현PythonPluginApplyTest
compileSdk parsing: alpha · rc · normalcompileSdk 파싱: alpha · rc · normalimplemented구현PythonVersionTest
Platform → triple mapping, Kotlin target check, min SDK플랫폼 → 트리플 매핑, Kotlin 타깃 확인, 최소 SDKimplemented구현PythonExtensionPlatformDslTest, PlatformTargetMappingTest, PythonPluginPlatformsTest
debug / release, -Ppython.buildTypedebug / release, -Ppython.buildTypeimplemented구현PythonPluginBuildTypeTest
Per-variant task graph변형별 태스크 그래프implemented구현PythonPluginVariantGraphTest
compileLevel = instant · bytecode (.venv and compileSdk minor checked); others rejected per variantcompileLevel = instant · bytecode (.venv 와 compileSdk 마이너 버전 확인); 나머지는 변형별 거부implemented구현PythonPluginBuildLevelTest, BytecodeLevelTest
srcDirs / metaDirs / libDirssrcDirs / metaDirs / libDirsimplemented구현PythonPluginSourceSetTest
implementation / integration → uv addimplementation / integration → uv addimplemented구현PythonPluginDependencyTest, InstallDependenciesTaskTest
Bundling via pypackpack's resource bundlerpypackpack 리소스 번들러로 번들링implemented구현BuildPythonArtifactTaskTest
packagePython zippackagePython zipimplemented구현AssemblePythonPackageTaskTest
Staging copy rule and variant selection스테이징 복사 규칙과 변형 선택implemented구현StagePythonBundleTaskTest, PythonPluginStagingTest
tcl installtcl installimplemented구현CliArgsTest, InstallerTest
tcl --help, tcl --versiontcl --help, tcl --versionimplemented구현CliArgsTest
toolchain-lite on PyPI (uv tool install toolchain-lite)PyPI 의 toolchain-lite (uv tool install toolchain-lite)partial부분CI builds and smoke-tests the wheels; not uploaded yetCI 가 휠을 빌드하고 스모크 테스트함. 아직 업로드 안 됨
Staging into the desktop jar데스크톱 jar 로의 스테이징implemented구현PythonPluginAttachmentTest
Staging into the APKAPK 로의 스테이징partial부분asset root tested (PythonPluginAttachmentTest); task dependency untested에셋 루트는 테스트됨 (PythonPluginAttachmentTest), 태스크 의존성은 테스트 없음
Hot reload핫 리로드partial부분Android, adb only; serverHost unusedAndroid, adb 만; serverHost 미사용
Code push코드 푸시partial부분validation only검증만
Dependencies per source set, installed for each variant's own triple into its bundle소스셋별 의존성을 변형마다 그 트리플용으로 설치해 번들에 포함implemented구현TargetDependenciesTest, PythonPluginTargetDependenciesTest, InstallTargetDependenciesTaskTest
integration(): warn when the wheel has no KLIBDEPENSintegration(): 휠에 KLIBDEPENS 가 없으면 경고implemented구현KlibDepensTest, PythonPluginIntegrationListTest
native / mixednative / mixedplanned계획needs pypackpack#19pypackpack#19 선행 필요
iOS: attaching the staged payload (stagePythonBundleIosForXcode prints the path; the Xcode phase is python-multiplatform's, #59)iOS: 스테이징된 페이로드 연결 (stagePythonBundleIosForXcode 가 경로 출력, Xcode 단계는 python-multiplatform #59 소유)partial부분StagePythonBundleIosForXcodeTaskTest, StagePythonPayloadScriptTest
buildFeatures { metaclass } and excludeMetaclass: metaDirs forwarded or droppedbuildFeatures { metaclass } 와 excludeMetaclass: metaDirs 전달 또는 제외implemented구현BuildFeaturesTest, PythonPluginBuildFeaturesTest
buildFeatures { compose }: installs pythonx-compose, adds python-multiplatform-compose to commonMainbuildFeatures { compose }: pythonx-compose 설치, commonMain 에 python-multiplatform-compose 추가implemented구현BuildFeaturesTest, PythonPluginBuildFeaturesTest; locations from two Gradle propertiesBuildFeaturesTest, PythonPluginBuildFeaturesTest; 위치는 Gradle 속성 두 개로 지정
embedLevel: 0/1/2 resolved per platform (Android and iOS raise to 2 with a warning), -Ppython.embedLevel override, recorded in embed.json with the expected interpreter version; level 2 means python-multiplatform embeds the interpreter, and the bundle stays python/ onlyembedLevel: 플랫폼별 0/1/2 결정 (Android, iOS 는 경고와 함께 2 로 상향), -Ppython.embedLevel 재정의, 기대 인터프리터 버전과 함께 embed.json 에 기록; 레벨 2 는 python-multiplatform 이 인터프리터를 내장한다는 뜻이고, 번들은 python/ 만 담음implemented구현EmbedLevelTest, PythonPluginEmbedLevelTest, PythonPluginPythonOnlyTest
useCodeMinifieruseCodeMinifierplanned계획declared, unread선언만, 읽히지 않음
defaultConfig { pip }: central · local · autoUpdatedefaultConfig { pip }: central · local · autoUpdateimplemented구현PipRepositoriesTest
pip { jit }pip { jit }planned계획-
compileSdk constants (PY3_14_7); unprovided versions refused per taskcompileSdk 상수(PY3_14_7); 제공되지 않는 버전은 태스크에서 거부implemented구현PythonSdkTest, BuildPythonSdkRejectionTest
projectFlavors in the variant graph; -Ppython.flavor변형 그래프의 projectFlavors; -Ppython.flavorimplemented구현PythonPluginFlavorsTest
defaultConfig { versionCode, versionName } → bundle manifestdefaultConfig { versionCode, versionName } → 번들 manifestimplemented구현PythonPluginPayloadVersionTest, BuildPythonArtifactTaskTest
toolchain ships only python/: compileSdk selects the wheels' Python version and fetches no interpreter; the interpreter and stdlib come from python-multiplatformtoolchain 은 python/ 만 실음: compileSdk 는 wheel 의 Python 버전을 고를 뿐 인터프리터를 받지 않음; 인터프리터와 stdlib 는 python-multiplatform 이 제공implemented구현PythonPluginTargetDependenciesTest, PythonPluginPythonOnlyTest
Feature기능Status상태Evidence근거
Applying the plugin: python extension and tasks플러그인 적용: python 확장과 태스크implemented구현PythonPluginApplyTest
compileSdk parsing: alpha · rc · normalcompileSdk 파싱: alpha · rc · normalimplemented구현PythonVersionTest
Platform → triple mapping, Kotlin target check, min SDK플랫폼 → 트리플 매핑, Kotlin 타깃 확인, 최소 SDKimplemented구현PythonExtensionPlatformDslTest, PlatformTargetMappingTest, PythonPluginPlatformsTest
debug / release, -Ppython.buildTypedebug / release, -Ppython.buildTypeimplemented구현PythonPluginBuildTypeTest
Per-variant task graph변형별 태스크 그래프implemented구현PythonPluginVariantGraphTest
compileLevel = instant · bytecode (.venv and compileSdk minor checked); others rejected per variantcompileLevel = instant · bytecode (.venv 와 compileSdk 마이너 버전 확인); 나머지는 변형별 거부implemented구현PythonPluginBuildLevelTest, BytecodeLevelTest
srcDirs / metaDirs / libDirssrcDirs / metaDirs / libDirsimplemented구현PythonPluginSourceSetTest
implementation / integration → uv addimplementation / integration → uv addimplemented구현PythonPluginDependencyTest, InstallDependenciesTaskTest
Bundling via pypackpack's resource bundlerpypackpack 리소스 번들러로 번들링implemented구현BuildPythonArtifactTaskTest
packagePython zippackagePython zipimplemented구현AssemblePythonPackageTaskTest
Staging copy rule and variant selection스테이징 복사 규칙과 변형 선택implemented구현StagePythonBundleTaskTest, PythonPluginStagingTest
tcl installtcl installimplemented구현CliArgsTest, InstallerTest
tcl --help, tcl --versiontcl --help, tcl --versionimplemented구현CliArgsTest
toolchain-lite on PyPI (uv tool install toolchain-lite)PyPI 의 toolchain-lite (uv tool install toolchain-lite)implemented구현0.1.0 is on PyPI (2026-10-03), four platform wheels, each smoke-tested before upload0.1.0 이 PyPI 에 있음 (2026-10-03). 플랫폼 휠 네 개, 업로드 전에 각각 스모크 테스트
Staging into the desktop jar데스크톱 jar 로의 스테이징implemented구현PythonPluginAttachmentTest
Staging into the APKAPK 로의 스테이징partial부분asset root tested (PythonPluginAttachmentTest); task dependency untested에셋 루트는 테스트됨 (PythonPluginAttachmentTest), 태스크 의존성은 테스트 없음
Hot reload핫 리로드planned계획Does not work yet: hotReloadPython fails saying so (#103). A live connection to the running app replaces it아직 동작하지 않습니다. hotReloadPython 은 그렇게 알리며 실패합니다 (#103). 실행 중인 앱으로의 라이브 연결이 대신합니다
Dev agent launch: enableHotReload stages python-multiplatform's agent marker; desktop run gets a port, a 32-byte token and build/pythonDevAgent/<task>.json개발 에이전트 실행: enableHotReload 가 python-multiplatform 에이전트 마커를 스테이징하고, 데스크톱 run 이 포트, 32바이트 토큰, build/pythonDevAgent/<task>.json 을 받음partial부분DevAgentLaunchTest, StagePythonBundleTaskTest, PythonPluginDevAgentTest; nothing on the laptop connects yet (#105)DevAgentLaunchTest, StagePythonBundleTaskTest, PythonPluginDevAgentTest; 아직 노트북 쪽에서 연결하는 것은 없음 (#105)
Code push코드 푸시partial부분validation only검증만
Dependencies per source set, installed for each variant's own triple into its bundle소스셋별 의존성을 변형마다 그 트리플용으로 설치해 번들에 포함implemented구현TargetDependenciesTest, PythonPluginTargetDependenciesTest, InstallTargetDependenciesTaskTest
integration(): warn when the wheel has no KLIBDEPENSintegration(): 휠에 KLIBDEPENS 가 없으면 경고implemented구현KlibDepensTest, PythonPluginIntegrationListTest
native / mixednative / mixedplanned계획needs pypackpack#19pypackpack#19 선행 필요
iOS: attaching the staged payload (stagePythonBundleIosForXcode prints the path; the Xcode phase is python-multiplatform's, #59)iOS: 스테이징된 페이로드 연결 (stagePythonBundleIosForXcode 가 경로 출력, Xcode 단계는 python-multiplatform #59 소유)partial부분StagePythonBundleIosForXcodeTaskTest, StagePythonPayloadScriptTest
buildFeatures { metaclass } and excludeMetaclass: metaDirs forwarded or droppedbuildFeatures { metaclass } 와 excludeMetaclass: metaDirs 전달 또는 제외implemented구현BuildFeaturesTest, PythonPluginBuildFeaturesTest
buildFeatures { compose }: installs pythonx-compose, adds python-multiplatform-compose to commonMainbuildFeatures { compose }: pythonx-compose 설치, commonMain 에 python-multiplatform-compose 추가implemented구현BuildFeaturesTest, PythonPluginBuildFeaturesTest; locations from two Gradle propertiesBuildFeaturesTest, PythonPluginBuildFeaturesTest; 위치는 Gradle 속성 두 개로 지정
embedLevel: 0/1/2 resolved per platform (Android and iOS raise to 2 with a warning), -Ppython.embedLevel override, recorded in embed.json with the expected interpreter version; level 2 means python-multiplatform embeds the interpreter, and the bundle stays python/ onlyembedLevel: 플랫폼별 0/1/2 결정 (Android, iOS 는 경고와 함께 2 로 상향), -Ppython.embedLevel 재정의, 기대 인터프리터 버전과 함께 embed.json 에 기록; 레벨 2 는 python-multiplatform 이 인터프리터를 내장한다는 뜻이고, 번들은 python/ 만 담음implemented구현EmbedLevelTest, PythonPluginEmbedLevelTest, PythonPluginPythonOnlyTest
useCodeMinifieruseCodeMinifierplanned계획declared, unread선언만, 읽히지 않음
defaultConfig { pip }: central · local · autoUpdatedefaultConfig { pip }: central · local · autoUpdateimplemented구현PipRepositoriesTest
pip { jit }pip { jit }planned계획-
compileSdk constants (PY3_15_0); unprovided versions and GIL-only CPython before 3.15 refused per taskcompileSdk 상수(PY3_15_0); 제공되지 않는 버전과 3.15 이전 GIL 전용 CPython 은 태스크에서 거부implemented구현PythonSdkTest, BuildPythonSdkRejectionTest
projectFlavors in the variant graph; -Ppython.flavor변형 그래프의 projectFlavors; -Ppython.flavorimplemented구현PythonPluginFlavorsTest
defaultConfig { versionCode, versionName } → bundle manifestdefaultConfig { versionCode, versionName } → 번들 manifestimplemented구현PythonPluginPayloadVersionTest, BuildPythonArtifactTaskTest
toolchain ships only python/: compileSdk selects the wheels' Python version and fetches no interpreter; the interpreter and stdlib come from python-multiplatformtoolchain 은 python/ 만 실음: compileSdk 는 wheel 의 Python 버전을 고를 뿐 인터프리터를 받지 않음; 인터프리터와 stdlib 는 python-multiplatform 이 제공implemented구현PythonPluginTargetDependenciesTest, PythonPluginPythonOnlyTest
compileSdk checked against python-multiplatform's pythonVersion at level 2 (a mismatch fails only that variant's buildPython…); read from -Ppython.multiplatform.pythonVersion, the :python-multiplatform project's extension, or the published dependency's metadata or jar레벨 2 에서 compileSdk 를 python-multiplatform 의 pythonVersion 과 대조 (다르면 그 변형의 buildPython… 만 실패); -Ppython.multiplatform.pythonVersion, :python-multiplatform 프로젝트의 확장, 또는 배포된 의존의 메타데이터나 jar 에서 읽음implemented구현PythonVersionAgreementTest, PythonPluginPythonOnlyTest, PublishedPythonVersionTest
@@ -38,8 +38,8 @@

Why does my compileLevel = "bytecode" fail

It needs a Python to compile with: a .venv at or above the package directory, whose minor version matches compileSdk. If there is none, or the versions differ, that variant's task fails and its message names the directory and the fix. native and mixed fail the same way until pypackpack's compile slot (pypackpack#19) exists.

컴파일할 Python 이 필요합니다. 패키지 디렉터리나 그 위에 있는 .venv 이고, 그 마이너 버전이 compileSdk 와 같아야 합니다. 없거나 버전이 다르면 그 변형의 태스크가 실패하고, 메시지에 디렉터리와 해결 방법이 나옵니다. native 와 mixed 는 pypackpack 의 컴파일 슬롯(pypackpack#19)이 생길 때까지 같은 방식으로 실패합니다.

Why does androidArm32() not compile?왜 androidArm32() 가 컴파일되지 않나요?

Android support follows CPython's official Android support (PEP 738): aarch64-linux-android and x86_64-linux-android. The example build file lists 32-bit and x86 variants because it was written before that was settled.

안드로이드 지원은 CPython 공식 안드로이드 지원(PEP 738)인 aarch64-linux-android 와 x86_64-linux-android 를 따릅니다. 예시 빌드 파일에 32비트와 x86 변형이 있는 것은 그 결정 전에 쓰였기 때문입니다.

-

Does hot reload work on iOS or desktop?핫 리로드가 iOS 나 데스크톱에서 되나요?

-

Not yet. hotReloadPython pushes .py files with adb and sends the org.thisisthepy.python.RELOAD_PYTHON broadcast; reloading the modules is the runtime's job.

아직입니다. hotReloadPython 은 adb 로 .py 파일을 밀어 넣고 org.thisisthepy.python.RELOAD_PYTHON 브로드캐스트를 보냅니다. 모듈을 다시 불러오는 것은 런타임의 일입니다.

+

Does hot reload work?핫 리로드가 되나요?

+

Not on any platform yet, Android included: the app has no receiver for the broadcast hotReloadPython used to send, so the task now fails and says so (#103). A live connection to the running app (desktop first, then Android and iOS) replaces it.

아직 어느 플랫폼에서도 안 됩니다. 안드로이드도 마찬가지입니다. hotReloadPython 이 보내던 브로드캐스트를 받는 쪽이 앱에 없어서, 지금은 태스크가 그렇게 알리며 실패합니다 (#103). 실행 중인 앱으로의 라이브 연결(데스크톱부터, 그다음 안드로이드와 iOS)이 이를 대신합니다.

Is the DSL final?DSL 은 확정인가요?

The example build file is the contract, and the plugin is being brought to it. Platforms already follow it: they are declared directly inside python { }. What still differs is listed on this page and in the specification.

예시 빌드 파일이 계약이고, 플러그인을 그쪽에 맞추는 중입니다. 플랫폼은 이미 맞췄습니다. python { } 바로 안에 선언합니다. 아직 다른 부분은 이 페이지와 스펙에 적혀 있습니다.

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index 8086994..bf52e1d 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -62,7 +62,7 @@ Python 을 내장한 Kotlin Multiplatform 앱에는 맞물려 돌아가야 하 | PyPI `toolchain-lite` | Python 사용자를 위한 명령줄 도구 `tcl` (`uv tool install toolchain-lite`) | Kotlin Multiplatform 옆에 플러그인을 적용하고 `pypackpack` 패키지를 가리키게 합니다. 아래는 -`sample/build.gradle.kts` 를 줄인 것입니다. +`usage-example/build.gradle.kts` 를 줄인 것입니다. ```kotlin plugins { @@ -75,17 +75,17 @@ python { compileSdk = "3.13" localLibraryPath = "src/commonMain/python" // pypackpack 패키지: pyproject.toml + src/main/ packaging { - fileName = "sample" + fileName = "usage-example" } } ``` ```shell -./gradlew -p sample packagePython # 설치 → 번들 → zip (sample/ 은 별도 빌드) -./gradlew -p sample stagePythonBundle # android, ios, desktop 용 python/ 스테이징 +./gradlew -p usage-example packagePython # 설치 → 번들 → zip (usage-example/ 은 별도 빌드) +./gradlew -p usage-example stagePythonBundle # android, ios, desktop 용 python/ 스테이징 ``` -> **iOS, 임시:** sample 의 Xcode 단계(`sample/src/iosMain/install-python-phase.sh`)는 +> **iOS, 임시:** usage-example 의 Xcode 단계(`usage-example/src/iosMain/install-python-phase.sh`)는 > python-multiplatform#90 이 published 플러그인으로 그 배선을 제공할 때까지 `Python.xcframework` 와 stdlib 을 위해 > python-multiplatform 체크아웃을 가리키는 `PYTHON_MULTIPLATFORM_DIR` 가 필요합니다. @@ -139,7 +139,7 @@ flowchart LR | `bytecode` 컴파일 레벨 (`.pyc`, `compileSdk` 와 맞는 `.venv` 필요) | ✅ 구현 | | `native` / `mixed` 컴파일 레벨 | ⏳ 계획: 현재는 명시적으로 거부 | | 데스크톱 jar 와 APK 로의 스테이징 | 🟡 부분: iOS 는 스테이징만 되고 연결되지 않음 | -| 핫 리로드 | 🟡 부분: Android 전용, `adb` 경유 | +| 핫 리로드 | ⏳ 계획: 아직 어느 플랫폼에서도 동작하지 않음 (#103). 실행 중인 앱으로의 라이브 연결이 대신함 | | 코드 푸시 | 🟡 부분: 검증만, 업로드 없음 | | `buildFeatures { metaclass, compose }` | ✅ 구현: compose 위치는 Gradle 속성 두 개로 지정 | | `embedLevel`, `projectFlavors`, `pip { }` | ⏳ 계획 | diff --git a/gradle.properties b/gradle.properties index 964007c..1d96e95 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,4 +7,16 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +# org.thisisthepy.compose:compose-gradle-plugin:1.11.1-ext.3's Maven Central POM marks +# hot-reload-gradle-plugin as optional (shadow configuration), so Gradle does not place it on the +# plugin classpath unless disabled or declared separately. +org.jetbrains.compose.hot.reload.disable=true +# Coordinates: the one place they are written (#99). A group unification (python-multiplatform #95) changes +# these lines only. A plugin release bumps toolchainVersion and is tagged v; packpackVersion must +# already be on Maven Central then (publish-gradle-plugin.yml checks it). +mavenGroup=org.thisisthepy.python.multiplatform +toolchainVersion=1.0.0-alpha +pluginId=org.thisisthepy.python.multiplatform +packpackModule=org.thisisthepy.python.multiplatform:packpack +packpackVersion=0.1.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5898058..810df42 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,8 @@ androidx-espresso-core = "3.6.1" androidx-lifecycle = "2.8.4" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" -compose-multiplatform = "1.7.0" +compose-multiplatform = "1.11.1-ext.2" +compose-plugin = "1.11.1-ext.3" junit = "4.13.2" kotlin = "2.1.0" kotlinx-coroutines = "1.10.1" @@ -35,6 +36,7 @@ kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-co [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +compose-multiplatform = { id = "org.thisisthepy.compose", version.ref = "compose-plugin" } +kotlin-native-desktop = { id = "org.thisisthepy.kotlin.native.desktop", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } \ No newline at end of file diff --git a/sample/gradle.properties b/sample/gradle.properties deleted file mode 100644 index 816b4c2..0000000 --- a/sample/gradle.properties +++ /dev/null @@ -1,5 +0,0 @@ -kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx2048M -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -android.nonTransitiveRClass=true -android.useAndroidX=true diff --git a/sample/settings.gradle.kts b/sample/settings.gradle.kts deleted file mode 100644 index 0a3439c..0000000 --- a/sample/settings.gradle.kts +++ /dev/null @@ -1,35 +0,0 @@ -// sample/ is its own Gradle build (toolchain#22, #62), run with the repository's wrapper: -// ./gradlew -p sample …. It consumes python-multiplatform, which is built with Kotlin 2.4, so it uses -// that toolchain (gradle/libs.versions.toml here); the plugin in ../toolchain stays on -// its own Kotlin for its consumers. Both plugins and the -// python-multiplatform library come from mavenLocal: publish them first (see AGENTS.md §16). -pluginManagement { - repositories { - mavenLocal() - google { - mavenContent { - includeGroupAndSubgroups("androidx") - includeGroupAndSubgroups("com.android") - includeGroupAndSubgroups("com.google") - } - } - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositories { - mavenLocal() - google { - mavenContent { - includeGroupAndSubgroups("androidx") - includeGroupAndSubgroups("com.android") - includeGroupAndSubgroups("com.google") - } - } - mavenCentral() - } -} - -rootProject.name = "sample" diff --git a/sample/src/commonMain/python/pyproject.toml b/sample/src/commonMain/python/pyproject.toml deleted file mode 100644 index 16ef0c6..0000000 --- a/sample/src/commonMain/python/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "sample-python" -version = "0.1.0" -description = "The Python half of the sample app, bundled by pypackpack and staged by the toolchain plugin." -requires-python = ">=3.13" diff --git a/sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt b/sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt deleted file mode 100644 index 16442f0..0000000 --- a/sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.thisisthepy.python.multiplatform.toolchain.example - -import platform.Foundation.NSProcessInfo -import platform.posix.exit -import python.multiplatform.ffi.Python3 - -/** - * `xcrun simctl launch --console-pty --python-smoke` runs [runPythonSmokeIfRequested] - * and exits before any UI, so CI can check the installed app's Python on a simulator (toolchain#22). - * - * It checks both halves of the .app: python-multiplatform's (the stdlib it copies into - * `python-multiplatform-home/` and Python.framework) and toolchain's (`python/`: the app's `example_py` - * and the dependency `iniconfig`). The report is printed to stdout, which `--console-pty` shows. - */ -const val PYTHON_SMOKE_ARGUMENT = "--python-smoke" - -private const val PY_EVAL_INPUT = 258 - -fun runPythonSmokeIfRequested() { - if (PYTHON_SMOKE_ARGUMENT !in NSProcessInfo.processInfo.arguments.map { it.toString() }) return - val code = - try { - Python3.initialize() - Python3.exec("import os, sys, example_py, iniconfig") - val globals = Python3.import("__main__").dict - val report = - Python3.eval( - "f'{example_py.VERSION} {iniconfig.__name__} {sys.version.split()[0]}\\nPYTHON_SMOKE_STDLIB {os.path.realpath(os.__file__)}'", - PY_EVAL_INPUT, - globals, - globals, - ) - println("PYTHON_SMOKE_OK $report") - 0 - } catch (t: Throwable) { - println("PYTHON_SMOKE_FAILED: $t") - 1 - } - exit(code) -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 50894b3..1ad88e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,9 +9,23 @@ pluginManagement { includeGroupAndSubgroups("com.google") } } - mavenLocal() - mavenCentral() + mavenCentral { + // `org.thisisthepy.compose.*:1.11.1-ext.2` POMs on Maven Central omit Gradle's + // `published-with-gradle-metadata` marker comment, so Gradle must be told to read `.module` + // metadata alongside `.pom` to resolve KMP platform variants and plugin runtime elements. + metadataSources { + gradleMetadata() + mavenPom() + } + } gradlePluginPortal() + mavenLocal { + content { + includeGroupAndSubgroups("org.thisisthepy") + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } } } @@ -24,13 +38,31 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } - mavenLocal() - mavenCentral() + mavenCentral { + // `org.thisisthepy.compose.*:1.11.1-ext.2` POMs on Maven Central omit Gradle's + // `published-with-gradle-metadata` marker comment, so Gradle must be told to read `.module` + // metadata alongside `.pom` to resolve KMP platform variants and plugin runtime elements. + metadataSources { + gradleMetadata() + mavenPom() + } + } + // Last, and only for our own group (toolchain#97): packpack comes from Maven Central once it is + // published there, and from mavenLocal only while a version is not (an unreleased pypackpack, built + // with `./gradlew :packpack:publishToMavenLocal`). Bump packpack's version when developing against an + // unreleased one, or Central's copy of the same version wins. + mavenLocal { + content { + includeGroupAndSubgroups("org.thisisthepy") + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } } } rootProject.name = "ToolchainProject" include(":toolchain") -// sample/ is its own build, run with this wrapper: ./gradlew -p sample …. It uses python-multiplatform's +// usage-example/ is its own build, run with this wrapper: ./gradlew -p usage-example …. It uses python-multiplatform's // Kotlin; the plugin here stays on its own for its consumers (toolchain#22, #62). diff --git a/toolchain/build.gradle.kts b/toolchain/build.gradle.kts index d299eca..acb5968 100644 --- a/toolchain/build.gradle.kts +++ b/toolchain/build.gradle.kts @@ -6,10 +6,17 @@ plugins { `kotlin-dsl` `maven-publish` `java-gradle-plugin` + signing + // The Gradle Plugin Portal's publishing (`publishPlugins`); it also adds the sources and javadoc + // jars the portal and Maven Central require (toolchain#97). Nothing runs it here. + id("com.gradle.plugin-publish") version "1.3.1" // The native `tcl` (toolchain-lite) that the PyPI wheels carry; see the cli source sets below. id("org.graalvm.buildtools.native") version "0.10.6" } +// packpack's coordinate, from gradle.properties (#99). +val packpack = "${providers.gradleProperty("packpackModule").get()}:${providers.gradleProperty("packpackVersion").get()}" + dependencies { //implementation(gradleApi()) //implementation(localGroovy()) @@ -20,8 +27,8 @@ dependencies { // not left as an independent literal. A skewed pin here (previously `2.0.0` against a `2.1.0` // root) put two different `kotlin-gradle-plugin` versions on the plugin classpath any consumer // sees once it applies both this plugin and `org.jetbrains.kotlin.multiplatform` in the same - // `plugins {}` block (as `sample/build.gradle.kts` does). Gradle does not merge/conflict - // -resolve those into one: `sample/build.gradle.kts` compiled its `KotlinWebpackConfig + // `plugins {}` block (as `usage-example/build.gradle.kts` does). Gradle does not merge/conflict + // -resolve those into one: `usage-example/build.gradle.kts` compiled its `KotlinWebpackConfig // .DevServer()` call (browser { commonWebpackConfig { ... } }) against 2.0.0's shape (`proxy: // Map`), while the actually-applied multiplatform plugin (2.1.0, `proxy: // List` as of that release) drove `wasmJsBrowserTest` task creation at runtime -- @@ -44,23 +51,31 @@ dependencies { // resolved through `mavenLocal()` (declared in the root `settings.gradle.kts` // `pluginManagement`/`dependencyResolutionManagement` repositories), which requires // `pypackpack`'s `packpack/build.gradle.kts` to have run `publishToMavenLocal` at least once. - implementation("org.thisisthepy.python.multiplatform:packpack:0.1.0") + implementation(packpack) testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test.junit) // Test-only: `PythonPluginAttachmentTest` applies `com.android.application` to a ProjectBuilder // project to check that the staged Android root really lands in `android.sourceSets.main.assets` // (the plugin reaches that object reflectively, so only a real AGP proves the method chain). - // Same version as `sample` (`gradle/libs.versions.toml` `agp`); never on the plugin's + // Same version as `usage-example` (`gradle/libs.versions.toml` `agp`); never on the plugin's // own runtime classpath. testImplementation("com.android.tools.build:gradle:${libs.versions.agp.get()}") } gradlePlugin { + website.set("https://thisisthepy.github.io/toolchain/") + vcsUrl.set("https://github.com/thisisthepy/toolchain") plugins { create("toolchain") { - id = group as String - implementationClass = "$id.toolchain.PythonPlugin" + // The id comes from gradle.properties (#99); the class is a Kotlin package, not a coordinate. + id = providers.gradleProperty("pluginId").get() + implementationClass = "org.thisisthepy.python.multiplatform.toolchain.PythonPlugin" + displayName = "thisisthepy toolchain" + description = + "Python in Kotlin Multiplatform apps: dependencies per target, bundling and staging of the " + + "app's Python, through pypackpack." + tags.set(listOf("python", "kotlin-multiplatform", "android", "ios", "packaging")) } } } @@ -99,9 +114,54 @@ tasks.withType().configureEach // plugin id (resolved via the marker) and get the jar through `pluginMaven` -- so it was pure // duplication, not an intentional second artifact. Removed rather than re-coordinated: there was // no second artifact to keep. +// Maven Central and the Plugin Portal read the same POM fields (toolchain#97); both publications, the +// plugin's jar (`pluginMaven`) and its marker, carry them. publishing { + publications.withType().configureEach { + pom { + name.set("thisisthepy toolchain") + description.set("The Gradle plugin that puts an app's Python into Kotlin Multiplatform builds, through pypackpack.") + url.set("https://github.com/thisisthepy/toolchain") + licenses { + license { + name.set("Apache-2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("thisisthepy") + name.set("thisisthepy") + url.set("https://github.com/thisisthepy") + } + } + scm { + url.set("https://github.com/thisisthepy/toolchain") + connection.set("scm:git:https://github.com/thisisthepy/toolchain.git") + developerConnection.set("scm:git:ssh://git@github.com/thisisthepy/toolchain.git") + } + } + } repositories { mavenLocal() + // A rehearsal of a release: `publishAllPublicationsToStagingRepository` writes build/staging-deploy, + // which `.github/scripts/maven/check_central_bundle.py --plugin-id ...` checks. Nothing uploads. + maven { + name = "staging" + url = uri(layout.buildDirectory.dir("staging-deploy")) + } + } +} + +// Signs only when a key is given (`-PsigningInMemoryKey`, `-PsigningInMemoryKeyPassword`, or the +// ORG_GRADLE_PROJECT_ environment variables of the same names), as pypackpack does. +signing { + val key = providers.gradleProperty("signingInMemoryKey").orNull + // plugin-publish signs every publication once `signing` is applied; without a key, skip instead of failing. + isRequired = key != null + if (key != null) { + useInMemoryPgpKeys(key, providers.gradleProperty("signingInMemoryKeyPassword").orNull) + sign(publishing.publications) } } @@ -132,7 +192,7 @@ dependencies { // A thin CLI over pypackpack's dependency backend: the same packpack artifact `main` resolves // from mavenLocal (see this module's dependencies above). "cliMainImplementation"(libs.kotlinx.coroutines.core) - "cliMainImplementation"("org.thisisthepy.python.multiplatform:packpack:0.1.0") + "cliMainImplementation"(packpack) "cliTestImplementation"(libs.kotlin.test) "cliTestImplementation"(libs.kotlin.test.junit) } diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTree.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTree.kt new file mode 100644 index 0000000..07ad38a --- /dev/null +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTree.kt @@ -0,0 +1,53 @@ +package org.thisisthepy.python.multiplatform.toolchain + +import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Deletes this file or directory tree without following symbolic links: a link is removed, never what it + * points at. Kotlin's `deleteRecursively` walks into a link to a directory and empties its target (the defect + * pypackpack#96 fixed there; pypackpack's `utils/FileTree.kt` is the same function). A staged payload, a + * target's install directory or the typedpython gate directory can hold a link a user or a tool put there. + * + * Returns `true` when nothing is left (a missing path counts), `false` when something could not be deleted. + */ +fun File.deleteWithoutFollowingLinks(): Boolean { + val root = toPath() + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) return true + return try { + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + Files.delete(root) + } else { + // walkFileTree does not follow links unless asked to: a link, to a directory or not, is visited as a file. + Files.walkFileTree( + root, + object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) throw exc + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } + true + } catch (e: IOException) { + false + } +} diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersion.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersion.kt index 8322f7f..470b6d5 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersion.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersion.kt @@ -10,8 +10,8 @@ import java.util.Properties import java.util.zip.ZipFile import java.util.zip.ZipInputStream -/** python-multiplatform's Maven group (thisisthepy/python-multiplatform#61). */ -const val PYTHON_MULTIPLATFORM_GROUP = "io.github.thisisthepy" +/** python-multiplatform's Maven group (thisisthepy/python-multiplatform#95). */ +const val PYTHON_MULTIPLATFORM_GROUP = "org.thisisthepy" /** Every python-multiplatform runtime module's name starts with this. */ const val PYTHON_MULTIPLATFORM_MODULE_PREFIX = "python-multiplatform" diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPlugin.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPlugin.kt index fab36ba..5b1980b 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPlugin.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPlugin.kt @@ -12,6 +12,7 @@ import org.thisisthepy.python.multiplatform.toolchain.dependency.lang.python.Ins import org.thisisthepy.python.multiplatform.toolchain.dependency.lang.python.resolvePipSettings import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.register import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @@ -21,16 +22,23 @@ import org.thisisthepy.python.multiplatform.toolchain.bundle.AssemblePythonPacka import org.thisisthepy.python.multiplatform.toolchain.bundle.BuildPythonArtifactTask import org.thisisthepy.python.multiplatform.toolchain.bundle.PythonStagingPlatform import org.thisisthepy.python.multiplatform.toolchain.bundle.StagePythonBundleTask +import org.thisisthepy.python.multiplatform.toolchain.devagent.DEV_AGENT_CONNECTION_DIRECTORY +import org.thisisthepy.python.multiplatform.toolchain.devagent.DEV_AGENT_LAUNCH_ACTION +import org.thisisthepy.python.multiplatform.toolchain.devagent.DevAgentLaunchAction +import org.thisisthepy.python.multiplatform.toolchain.devagent.devAgentEnabled import org.thisisthepy.python.multiplatform.toolchain.hotreload.CodePushPendingTask import org.thisisthepy.python.multiplatform.toolchain.hotreload.HotReloadPushTask import org.thisisthepy.python.multiplatform.toolchain.hotreload.validateCodePushConfig import org.thisisthepy.python.multiplatform.toolchain.hotreload.validateHotReloadConfig import org.thisisthepy.python.multiplatform.toolchain.typedpython.DEFAULT_PYREFLY_VERSION import org.thisisthepy.python.multiplatform.toolchain.typedpython.DEFAULT_TYPEDPYTHON_MODE +import org.thisisthepy.python.multiplatform.toolchain.typedpython.DEFAULT_TYPEDPYTHON_GATE import org.thisisthepy.python.multiplatform.toolchain.typedpython.DEFAULT_TYPEDPYTHON_VERSION +import org.thisisthepy.python.multiplatform.toolchain.typedpython.TYPEDPYTHON_GATE_PROPERTY import org.thisisthepy.python.multiplatform.toolchain.typedpython.TYPEDPYTHON_WHEEL_DIR_PROPERTY import org.thisisthepy.python.multiplatform.toolchain.typedpython.TypedpythonCheckTask import org.thisisthepy.python.multiplatform.toolchain.typedpython.typedpythonCheckedDirs +import org.thisisthepy.python.multiplatform.toolchain.typedpython.typedpythonGateNotation import org.thisisthepy.python.multiplatform.toolchain.typedpython.typedpythonSourceExcluded import java.io.File @@ -95,6 +103,23 @@ class PythonPlugin : Plugin { project.providers.gradleProperty(TYPEDPYTHON_WHEEL_DIR_PROPERTY).map { project.file(it).absolutePath }, ) stubDirs.from(typedpythonStubs) + // The gate zip (#90), resolved leniently: a project whose repositories lack it gets the + // skip warning, not a failed configuration. -Ptypedpython.wheelDir wins over it. + val gate = project.providers.gradleProperty(TYPEDPYTHON_GATE_PROPERTY).orElse(DEFAULT_TYPEDPYTHON_GATE) + gateCoordinates.set(gate) + gateArchive.from( + project.provider { + if (project.providers.gradleProperty(TYPEDPYTHON_WHEEL_DIR_PROPERTY).isPresent) { + project.files() + } else { + project.configurations + .detachedConfiguration(project.dependencies.create(typedpythonGateNotation(gate.get()))) + .apply { isTransitive = false } + .incoming.artifactView { isLenient = true }.files + } + }, + ) + gateDir.set(project.layout.buildDirectory.dir("typedpython/gate")) venvDir.set(project.layout.buildDirectory.dir("typedpython/venv")) report.set(project.layout.buildDirectory.file("typedpython/report.txt")) } @@ -107,7 +132,7 @@ class PythonPlugin : Plugin { // destination roots are derived from the build directory alone (`PythonStagingPlatform. // rootIn`): an AGP asset source root has to be declared while the consumer's build script is // still being evaluated, because AGP reads its source sets in its own `afterEvaluate` and - // this plugin -- applied last in a `plugins { }` block, as `sample` applies it -- + // this plugin -- applied last in a `plugins { }` block, as `usage-example` applies it -- // registers its `afterEvaluate` callback after AGP's and therefore runs after it. A // `srcDir` added at that point is added to a source set nothing will look at again. // @@ -266,8 +291,8 @@ class PythonPlugin : Plugin { // second code path doing the same job -- `buildTask` (`BuildPythonArtifactTask`) already // delegates the real work to `pypackpack`'s `ResourceBundler` into `build/pythonBundle` // (this task's own report; `docs/ecosystem.md` §1, §5). Confirmed nothing depended on - // it before deleting: no source under `sample/` reads `src/main/assets/python` or - // `build/pythonLibraries`, `sample` itself never sets `python.localLibraryPath` + // it before deleting: no source under `usage-example/` reads `src/main/assets/python` or + // `build/pythonLibraries`, `usage-example` itself never sets `python.localLibraryPath` // today, and `copyPythonLibrariesToAssets`/`preBuild` wiring existed only inside this // now-removed block, so nothing outside it could have depended on that task by name // either. Wiring the real bundle output into Android assets (or any other platform's @@ -416,6 +441,7 @@ class PythonPlugin : Plugin { val activeBuildTypeName = resolveActiveBuildType(extension.buildTypes, requestedBuildType) val activeFlavorName = resolveActiveFlavor(extension.projectFlavors, project.findProperty("python.flavor") as? String) buildTask.configure { flavorRejection = flavorsWithoutVariantsRejection(extension.projectFlavors, variants) } + configureDevAgent(project, stageTasks, devAgentEnabled(extension.buildTypes, activeBuildTypeName)) if (variants.isEmpty()) { // No declared platform variant: exactly the pre-graph behavior, unchanged. One host @@ -620,7 +646,7 @@ private fun registerStagingTasks(project: Project): MapRun` are the run tasks; a packaged distribution is launched by the OS, not + * by Gradle, so it gets the marker (it was built from this build type) but no variable from here. + */ +private fun configureDevAgent( + project: Project, + stageTasks: Map>, + enabled: Boolean, +) { + stageTasks.values.forEach { it.configure { devAgentMarker = enabled } } + if (!enabled) return + val runTaskNames = mutableSetOf("run") + project.plugins.withId("org.jetbrains.kotlin.multiplatform") { + project.extensions.findByType(KotlinMultiplatformExtension::class.java)?.targets + ?.matching { it.platformType == KotlinPlatformType.jvm } + ?.all { runTaskNames += "${name}Run" } + } + val connectionDir = File(project.layout.buildDirectory.get().asFile, DEV_AGENT_CONNECTION_DIRECTORY) + project.tasks.withType(JavaExec::class.java).matching { it.name in runTaskNames }.configureEach { + doFirst(DEV_AGENT_LAUNCH_ACTION, DevAgentLaunchAction(File(connectionDir, "$name.json"))) + } +} + /** * Picks the one variant each destination is staged from. * @@ -824,7 +875,7 @@ data class PythonVariant( * `PythonPluginVariantGraphTest` for the design record, and `PythonPlugin.apply` for the wiring. * * Empty when no platform variant is declared, which is deliberately the case for every consumer - * that exists today (`sample` declares no platform variant). The graph is opt-in: the + * that exists today (`usage-example` declares no platform variant). The graph is opt-in: the * platform block is what creates more than one target, and without it there is exactly one -- the * host -- built by the single `buildPython` task exactly as before. * @@ -937,7 +988,7 @@ private fun String.capitalizeFirst(): String = replaceFirstChar { it.uppercaseCh * [PythonPlugin.apply] so it can be exercised without a Gradle [org.gradle.api.Project] -- see * `PythonPluginBuildTypeTest`. * - * No declared build types at all (the common case today -- `sample` does not use the + * No declared build types at all (the common case today -- `usage-example` does not use the * `buildTypes { }` block) passes [requestedName] straight through, so `buildType` keeps behaving * exactly as it did before this DSL block was wired up. Once at least one build type is declared, * [requestedName] must name one of them; an unmatched name throws rather than silently defaulting, @@ -967,7 +1018,7 @@ fun resolveActiveBuildType( * `PythonPluginSourceSetTest`. * * `localLibraryPath` is the explicit override and wins whenever it is declared, matching its - * existing pre-graph behavior exactly (`sample` sets only `localLibraryPath`, no + * existing pre-graph behavior exactly (`usage-example` sets only `localLibraryPath`, no * `sourceSets` block, and keeps resolving the same way). `commonMain.srcDirs` -- `DSLBuild.kt`'s * `SourceSetConfig.srcDirs`, declared but read by nothing until now -- is the fallback: its first * entry, resolved against [projectDir]. A [SourceSetConfig] carries a list because a Kotlin source @@ -1067,7 +1118,7 @@ fun collectIntegrationDependencies(sourceSets: List): List = emptyList(), + libDirs: List = emptyList(), + versionName: String? = null, + versionCode: Int? = null, + pythonVersion: String? = null, +): BundleRequest = + BundleRequest( + packageDir = packageDir, + target = target, + buildType = buildType, + buildLevel = buildLevel, + outputDir = outputDir, + overwrite = true, + minSdk = minSdk, + metaDirs = metaDirs, + libDirs = libDirs, + versionName = versionName, + versionCode = versionCode, + pythonVersion = pythonVersion, + ) + /** * The actual delegation to `pypackpack`, factored out of [BuildPythonArtifactTask.buildPython] so * it can be exercised without a Gradle [org.gradle.api.Project] -- see @@ -217,6 +250,9 @@ open class BuildPythonArtifactTask : DefaultTask() { * caller that declares no `python { sourceSets { commonMain { metaDirs(...) } } }`. * @param libDirs forwarded to [BundleRequest.libDirs] verbatim. Empty by default, matching every * caller that declares no `python { sourceSets { commonMain { libDirs(...) } } }`. + * @param pythonVersion forwarded to [BundleRequest.pythonVersion] verbatim (`3.15.0`, `3.15.0t`): the + * app's interpreter, which fixes the wheel tags of the wheel bundlers. `null` (no `compileSdk`) + * leaves pypackpack's own default. */ fun bundleWithPackpack( packageDir: File, @@ -229,20 +265,12 @@ fun bundleWithPackpack( libDirs: List = emptyList(), versionName: String? = null, versionCode: Int? = null, + pythonVersion: String? = null, ): BundleResult { val request = - BundleRequest( - packageDir = packageDir, - target = target, - buildType = buildType, - buildLevel = buildLevel, - outputDir = outputDir, - overwrite = true, - minSdk = minSdk, - metaDirs = metaDirs, - libDirs = libDirs, - versionName = versionName, - versionCode = versionCode, + bundleRequestFor( + packageDir, target, buildType, outputDir, buildLevel, minSdk, metaDirs, libDirs, + versionName, versionCode, pythonVersion, ) return BundlerInterface.create(BundleType.RESOURCE) .bundle(request) @@ -330,3 +358,6 @@ fun bytecodeInterpreterRejection( "Recreate that .venv with Python ${compileSdk.major}.${compileSdk.minor} " + "(`uv venv --python ${compileSdk.major}.${compileSdk.minor}`)." } + +/** [PythonVersion.toWheelVersion] of a resolved `compileSdk` label (`PythonVersion.toString()`). */ +fun wheelVersionOf(compileSdkLabel: String): String = PythonVersion.parse(compileSdkLabel).toWheelVersion() diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTask.kt index 194143c..8e7a10e 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTask.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTask.kt @@ -1,5 +1,7 @@ package org.thisisthepy.python.multiplatform.toolchain.bundle +import org.thisisthepy.python.multiplatform.toolchain.deleteWithoutFollowingLinks +import org.thisisthepy.python.multiplatform.toolchain.devagent.DEV_AGENT_ENABLE_MARKER import org.gradle.api.DefaultTask import org.gradle.api.file.FileTree import org.gradle.api.tasks.Input @@ -91,6 +93,13 @@ open class StagePythonBundleTask : DefaultTask() { @get:Input var payloadPath: String = PythonStagingLayout.PAYLOAD_ROOT + /** + * Whether the payload carries python-multiplatform's dev-agent marker ([DEV_AGENT_ENABLE_MARKER]): true for + * a build type with `enableHotReload` (toolchain#105). An `@Input`, so switching build types re-stages. + */ + @get:Input + var devAgentMarker: Boolean = false + /** Only for the log line, so it says which variant landed where. */ @get:Internal var variantName: String? = null @@ -102,11 +111,12 @@ open class StagePythonBundleTask : DefaultTask() { @TaskAction fun stage() { val source = resolvedBundleDir() - val result = stagePythonPayload(source, destinationDir, payloadPath) + val result = stagePythonPayload(source, destinationDir, payloadPath, devAgentMarker) logger.lifecycle( "Staged ${result.fileCount} Python file(s)" + (variantName?.let { " for $it" } ?: "") + - " from $source into ${result.payloadDir}", + " from $source into ${result.payloadDir}" + + (if (devAgentMarker) ", with the dev-agent marker" else ""), ) } } @@ -140,18 +150,24 @@ data class PythonStagingResult( * this is the normal state of a consumer that has not pointed the DSL at a ppp package yet. The * destination is still created, because by then it is already registered as somebody's resource * or asset source root. + * - **[devAgentMarker] writes [DEV_AGENT_ENABLE_MARKER] at the payload root** (toolchain#105), the switch + * python-multiplatform's runtime requires before it starts a dev agent. It is not counted in + * [PythonStagingResult.fileCount]. The clean sync above is what removes it from a later staging without it. */ fun stagePythonPayload( bundleDir: File, destinationRoot: File, payloadPath: String = PythonStagingLayout.PAYLOAD_ROOT, + devAgentMarker: Boolean = false, ): PythonStagingResult { val payloadDir = File(destinationRoot, payloadPath) - payloadDir.deleteRecursively() + payloadDir.deleteWithoutFollowingLinks() if (!payloadDir.mkdirs() && !payloadDir.isDirectory) { throw java.io.IOException("could not create $payloadDir to stage the Python payload into") } + if (devAgentMarker) File(payloadDir, DEV_AGENT_ENABLE_MARKER).writeText("") + val source = File(bundleDir, PythonStagingLayout.PAYLOAD_ROOT) if (!source.isDirectory) return PythonStagingResult(payloadDir, 0) diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallDependenciesTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallDependenciesTask.kt index 3b194fa..affc795 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallDependenciesTask.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallDependenciesTask.kt @@ -17,7 +17,7 @@ import java.io.File * instead of hand-rolling a requirements file and shelling out to a `uv install` subcommand that * does not exist (`uv` has no `install` verb; the previous implementation was never exercised end to * end -- see `BuildPythonArtifactTask`'s kdoc and this module's report for why nothing reached this - * task's execution before `sample` applied the plugin). + * task's execution before `usage-example` applied the plugin). * * `pypackpack` owns dependency resolution (`docs/ecosystem.md` §1, §5); this task's only job is * translating this project's Gradle-side inputs (the DSL-resolved dependency list, the `ppp` @@ -35,7 +35,7 @@ import java.io.File open class InstallDependenciesTask : DefaultTask() { // `@Internal`, matching `BuildPythonArtifactTask`'s fix for the same problem: Gradle 8's task // property validation (execution-time, not compile-time) rejects any public task property with - // no annotation at all. This surfaced only once `sample` started applying the plugin and + // no annotation at all. This surfaced only once `usage-example` started applying the plugin and // actually running `installPythonDependencies` as part of the `packagePython` chain -- nothing // had ever reached this task's execution before. `@Internal` is the honest answer for now; this // property does not participate in up-to-date checking. diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallTargetDependenciesTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallTargetDependenciesTask.kt index 9ebf11e..d63c105 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallTargetDependenciesTask.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/python/InstallTargetDependenciesTask.kt @@ -1,5 +1,6 @@ package org.thisisthepy.python.multiplatform.toolchain.dependency.lang.python +import org.thisisthepy.python.multiplatform.toolchain.deleteWithoutFollowingLinks import kotlinx.coroutines.runBlocking import org.gradle.api.DefaultTask import org.gradle.api.GradleException @@ -107,7 +108,7 @@ fun installDependenciesForTarget( requirementsDir.canonicalFile != installDir.canonicalFile) { "the requirements directory $requirementsDir must not be inside the install directory $installDir" } - if (installDir.exists() && !installDir.deleteRecursively()) { + if (installDir.exists() && !installDir.deleteWithoutFollowingLinks()) { throw GradleException("could not clear $installDir before installing Python dependencies into it") } installDir.mkdirs() diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgent.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgent.kt new file mode 100644 index 0000000..08d6ec2 --- /dev/null +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgent.kt @@ -0,0 +1,92 @@ +package org.thisisthepy.python.multiplatform.toolchain.devagent + +import org.gradle.api.Action +import org.gradle.api.Task +import org.gradle.api.tasks.JavaExec +import org.thisisthepy.python.multiplatform.toolchain.dsl.BuildTypesContainer +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.security.SecureRandom +import java.util.Base64 + +/* + * The launcher's half of the dev agent (toolchain#105; python-multiplatform SPEC L-12; docs/design/live-connection.md). + * + * python-multiplatform's runtime starts its agent only when both hold: the process has + * [DEV_AGENT_ENVIRONMENT_VARIABLE], and a payload root holds [DEV_AGENT_ENABLE_MARKER]. This plugin provides + * both, for a build whose active Python build type has `enableHotReload = true` (the example build file's + * `debug`): the staging tasks write the marker, and the desktop run task gets a fresh port and token. + * A build without it gets neither, and a process started with the variable by hand still opens no port. + */ + +/** The variable python-multiplatform's `PythonDevAgent` reads: `port=;token=`. */ +const val DEV_AGENT_ENVIRONMENT_VARIABLE = "PYTHON_MULTIPLATFORM_DEV_AGENT" + +/** python-multiplatform's `PythonDevAgent.ENABLE_MARKER`, written at the root of the staged `python/` payload. */ +const val DEV_AGENT_ENABLE_MARKER = "python_multiplatform_devagent.enable" + +/** The name of the `doFirst` action that starts an agent; Gradle shows it as "Execute startPythonDevAgent". */ +const val DEV_AGENT_LAUNCH_ACTION = "startPythonDevAgent" + +/** `/pythonDevAgent/.json`: where a launch leaves its port and token for `tcl connect`. */ +const val DEV_AGENT_CONNECTION_DIRECTORY = "pythonDevAgent" + +/** Random bytes in a token: python-multiplatform refuses fewer than `secrets.token_urlsafe(32)` gives. */ +private const val TOKEN_BYTES = 32 + +/** + * Whether [activeBuildType] is a build the dev agent belongs in: it declares `enableHotReload = true`. + * + * No `buildTypes { }` block, or an active type that does not set it, is false: nothing asked for a live + * channel, so no payload carries the marker. + */ +fun devAgentEnabled(buildTypes: BuildTypesContainer, activeBuildType: String): Boolean = + buildTypes.all().firstOrNull { it.name == activeBuildType }?.enableHotReload == true + +/** One launch's loopback port and token. */ +data class DevAgentLaunch(val port: Int, val token: String) { + /** The value of [DEV_AGENT_ENVIRONMENT_VARIABLE]. */ + val environmentValue: String get() = "port=$port;token=$token" + + /** + * Writes `{"host", "port", "token"}` to [file], replacing it. On a POSIX file system the file is created + * owner-only before the token is written: the token is what keeps any other local user from running code + * in the app. + */ + fun writeConnectionFile(file: File) { + file.parentFile?.mkdirs() + val path = file.toPath() + Files.deleteIfExists(path) + if ("posix" in FileSystems.getDefault().supportedFileAttributeViews()) { + Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) + } + file.writeText("""{"host": "127.0.0.1", "port": $port, "token": "$token"}""" + "\n") + } +} + +/** A launch with a free loopback port and [TOKEN_BYTES] random bytes as an unpadded URL-safe token (43 characters). */ +fun newDevAgentLaunch(random: SecureRandom = SecureRandom()): DevAgentLaunch { + val bytes = ByteArray(TOKEN_BYTES).also(random::nextBytes) + val token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + val port = ServerSocket(0, 0, InetAddress.getLoopbackAddress()).use { it.localPort } + return DevAgentLaunch(port, token) +} + +/** + * The `doFirst` of a desktop run task: a fresh launch per run, handed to the app as the variable and to the + * laptop as [connectionFile]. The port is picked here rather than by the agent (port 0) because the laptop + * has to know it before the app has printed anything; the window between closing the probe socket and the + * app binding it is the only race, and losing it fails the agent's bind with a warning, not the app. + */ +class DevAgentLaunchAction(private val connectionFile: File) : Action { + override fun execute(task: Task) { + val launch = newDevAgentLaunch() + (task as JavaExec).environment(DEV_AGENT_ENVIRONMENT_VARIABLE, launch.environmentValue) + launch.writeConnectionFile(connectionFile) + task.logger.lifecycle("Python dev agent: 127.0.0.1:${launch.port}, connection file $connectionFile") + } +} diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/DSLCore.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/DSLCore.kt index 52e0c47..84ae692 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/DSLCore.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/DSLCore.kt @@ -5,10 +5,9 @@ import javax.inject.Inject open class PythonExtension @Inject constructor(objects: ObjectFactory) { - /** `compileSdk = "3.14.7"` or `compileSdk = PY3_14_7`; see [PythonSdk]. */ + /** `compileSdk = "3.15.0"` or `compileSdk = PY3_15_0` (`"3.15t"` for the free-threaded build); see [PythonSdk]. */ val compileSdk: PythonSdk = PythonSdk() - val PY3_14_7: PythonSdkVersion = PythonSdkVersion.PY3_14_7 - val PY3_13_0: PythonSdkVersion = PythonSdkVersion.PY3_13_0 + val PY3_15_0: PythonSdkVersion = PythonSdkVersion.PY3_15_0 var localLibraryPath: String? = null val defaultConfig: DefaultConfig = DefaultConfig() diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdk.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdk.kt index 304c22c..ba382f2 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdk.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdk.kt @@ -3,21 +3,23 @@ package org.thisisthepy.python.multiplatform.toolchain.dsl import org.gradle.api.SupportsKotlinAssignmentOverloading /** - * The CPython versions python-multiplatform actually provides a runtime for: its - * `gradle.properties` `pythonVersion` (3.14.7 on 2026-10-03), acquired as described in its - * `docs/platforms/python-version-acquisition.md` (python-build-standalone for desktop, python.org - * for Android, BeeWare's Python-Apple-support for iOS), plus 3.13.0, the older archive on its - * `release` branch's `binary/` that pypackpack's `DefaultBackend.installPython` still downloads. + * The CPython versions python-multiplatform actually provides a runtime for: its `gradle.properties` + * `pythonVersion`, 3.15.0 (python-multiplatform#197). The runtime is CPython 3.15t, free-threaded only + * (python-multiplatform#158, toolchain#76), so the GIL-only 3.14.7 and 3.13.0 are gone. + * + * `3.15.0` without `t` still names python-multiplatform's GIL build: a temporary exception while its + * published runtime is the GIL build (`pythonFreeThreaded=false`) and Android and iOS have no 3.15t build + * (python-multiplatform#163). It goes when python-multiplatform publishes free-threaded as its default + * with the flavour in its version or metadata (`docs/SPEC.md` §1.2). * Add a version here and a constant to [PythonSdkVersion] when python-multiplatform pins one. */ object PythonReleaseServer { - val PUBLISHED: List = listOf("3.14.7", "3.13.0") + val PUBLISHED: List = listOf("3.15.0") } -/** `compileSdk = PY3_14_7`: a version python-multiplatform provides. Only those exist as constants. */ +/** `compileSdk = PY3_15_0`: a version python-multiplatform provides. Only those exist as constants. */ enum class PythonSdkVersion(val version: String) { - PY3_14_7("3.14.7"), - PY3_13_0("3.13.0"), + PY3_15_0("3.15.0"), } /** @@ -75,8 +77,19 @@ fun resolvePythonSdk(sdk: PythonSdk): ResolvedPythonSdk? { .filter { it.channel == parsed.channel } .maxByOrNull { it.micro } + if (parsed.major == 3 && parsed.minor < 15) { + return ResolvedPythonSdk( + version = parsed, + fromConstant = false, + rejection = + "Python compileSdk '${sdk.requested}' is a GIL-only CPython. The runtime is CPython 3.15t, " + + "free-threaded only (python-multiplatform #158): use compileSdk = \"3.15t\" " + + "(published: ${PythonReleaseServer.PUBLISHED.joinToString()}).", + ) + } + return if (published != null) { - ResolvedPythonSdk(version = published, fromConstant = sdk.constant != null, rejection = null) + ResolvedPythonSdk(version = published.copy(freeThreaded = parsed.freeThreaded), fromConstant = sdk.constant != null, rejection = null) } else { ResolvedPythonSdk( version = parsed, diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersion.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersion.kt index dd45d31..fbbddaa 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersion.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersion.kt @@ -24,7 +24,8 @@ enum class PythonReleaseChannel { /** * A parsed CPython version string, in the format `(플러그인예시)build.gradle.kts` actually writes - * (`compileSdk = "3.11.9-alpha"`): `X.Y`, `X.Y.Z`, `X.Y.Z-alpha[N]`, or `X.Y.Z-rc[N]`. + * (`compileSdk = "3.11.9-alpha"`): `X.Y`, `X.Y.Z`, `X.Y.Z-alpha[N]`, or `X.Y.Z-rc[N]`, each with an + * optional trailing `t` for the free-threaded build (`3.15t`). * * Deliberately a different grammar from `pypackpack`'s own `validatePythonVersion` * (`cli/.../CommandExtension.kt`'s `^\d+\.\d+(\.\d+)?$`), which has no channel suffix at all -- @@ -37,10 +38,18 @@ data class PythonVersion( val micro: Int, val channel: PythonReleaseChannel, val serial: Int? = null, + /** The free-threaded build (PEP 703), spelled with a trailing `t`: `3.15t`, `3.15.0t`. */ + val freeThreaded: Boolean = false, ) { /** The `X.Y.Z` form `pypackpack`'s `validatePythonVersion` accepts, channel suffix stripped. */ fun toReleaseString(): String = "$major.$minor.$micro" + /** + * The `X.Y.Z` or `X.Y.Zt` form `pypackpack`'s `BundleRequest.pythonVersion` takes, which fixes + * the wheel tags (`3.15.0` is `cp315-cp315`, `3.15.0t` is `cp315-cp315t`). + */ + fun toWheelVersion(): String = toReleaseString() + if (freeThreaded) "t" else "" + override fun toString(): String = buildString { append(toReleaseString()) @@ -49,10 +58,11 @@ data class PythonVersion( PythonReleaseChannel.RC -> append("-rc${serial ?: ""}") PythonReleaseChannel.NORMAL -> Unit } + if (freeThreaded) append("t") } companion object { - private val PATTERN = Regex("""^(\d+)\.(\d+)(?:\.(\d+))?(?:-(alpha|rc)(\d+)?)?$""") + private val PATTERN = Regex("""^(\d+)\.(\d+)(?:\.(\d+))?(?:-(alpha|rc)(\d+)?)?(t)?$""") /** Throws [IllegalArgumentException] on anything that does not match the format above. */ fun parse(raw: String): PythonVersion { @@ -60,10 +70,10 @@ data class PythonVersion( val match = PATTERN.matchEntire(trimmed) ?: throw IllegalArgumentException( "Invalid Python version '$raw'. Expected 'X.Y', 'X.Y.Z', 'X.Y.Z-alpha[N]', " + - "or 'X.Y.Z-rc[N]'.", + "or 'X.Y.Z-rc[N]', each optionally followed by 't' (free-threaded).", ) - val (majorStr, minorStr, microStr, channelWord, serialStr) = match.destructured + val (majorStr, minorStr, microStr, channelWord, serialStr, threadedStr) = match.destructured val major = majorStr.toInt() val minor = minorStr.toInt() val micro = microStr.toIntOrNull() ?: 0 @@ -76,7 +86,7 @@ data class PythonVersion( else -> PythonReleaseChannel.NORMAL } - return PythonVersion(major, minor, micro, channel, serial) + return PythonVersion(major, minor, micro, channel, serial, threadedStr == "t") } } } diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/hotreload/HotReloadPushTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/hotreload/HotReloadPushTask.kt index d4da984..6f73a42 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/hotreload/HotReloadPushTask.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/hotreload/HotReloadPushTask.kt @@ -4,30 +4,19 @@ import org.gradle.api.DefaultTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Optional +import org.gradle.api.GradleException import org.gradle.api.tasks.TaskAction import java.io.File /** - * Pushes changed Python source files to a connected Android device via `adb push` and sends a - * reload broadcast ([HOT_RELOAD_BROADCAST_ACTION]) to signal the running app. + * `hotReloadPython`: registered by `PythonPlugin.apply` when a build type has `enableHotReload = true`. * - * Registered as `hotReloadPython` by `PythonPlugin.apply` when at least one declared build type - * has `enableHotReload = true`. The task is gated by [enabled]; when `false` it prints a clear - * message explaining what is missing rather than silently doing nothing. - * - * Boundary -- what this task does: - * 1. Walk [sourceRoot] for `.py` files ([collectWatchedSources]). - * 2. Push each file to [remoteBasePath] on the connected device ([buildAdbPushCommands]). - * 3. Send [HOT_RELOAD_BROADCAST_ACTION] via `adb shell am broadcast`. - * - * Boundary -- what this task does NOT do: - * - It does not restart the app or call `importlib.reload()`. That is the Python runtime's - * responsibility (python-multiplatform). The broadcast is the contract point. - * - It does not serve files over HTTPS; [hotReloadConfig] carries `serverHost` for future use - * once a server component exists. - * - It does not watch the filesystem for changes and re-run automatically. That would require - * a long-running daemon process; Gradle tasks are one-shot. A build-system-level file watcher - * (e.g. `./gradlew hotReloadPython --continuous`) covers this use case at the Gradle level. + * It does not work yet, and says so (toolchain#103): with hot reload enabled the task fails, naming why. + * It used to `adb push` every `.py` and send [HOT_RELOAD_BROADCAST_ACTION], but nothing in the app receives + * that broadcast, so the push looked like a working hot reload while the app never changed. The + * live-connection channel replaces it (`docs/design/live-connection.md`, M2): one socket to the running app, + * desktop, Android and iOS, carrying the changed files to the runtime, which re-imports them. Disabled, the + * task prints how to enable it. */ abstract class HotReloadPushTask : DefaultTask() { @@ -64,63 +53,14 @@ abstract class HotReloadPushTask : DefaultTask() { ) return } - - val root = sourceRoot - if (root == null || !root.isDirectory) { - logger.lifecycle( - "hotReloadPython: sourceRoot is not set or does not exist ($root). " + - "Set python { localLibraryPath = \"...\" } to point at your Python source tree.", - ) - return - } - - val files = collectWatchedSources(root) - if (files.isEmpty()) { - logger.lifecycle("hotReloadPython: no .py files found under $root.") - return - } - - if (remoteBasePath.isBlank()) { - logger.lifecycle( - "hotReloadPython: remoteBasePath is not configured. " + - "This is a toolchain bug -- please report it.", - ) - return - } - - logger.lifecycle("hotReloadPython: pushing ${files.size} file(s) to $remoteBasePath …") - - if (serverHost.isNotBlank()) { - logger.info( - "hotReloadPython: serverHost='$serverHost' is set but no HTTP upload client " + - "is implemented in this version of the toolchain. Files will be pushed via " + - "adb only. A server-based push path requires a server component and an upload " + - "client; neither is available in this repository today.", - ) - } - - val commands = buildAdbPushCommands( - sourceFiles = files, - remoteBasePath = remoteBasePath, - sourceRootDir = root, - adbPath = adbPath, + // toolchain#103: the adb push + RELOAD_PYTHON broadcast path had no receiver in the app, so it + // looked like a working hot reload while nothing changed. It stays off until the live-connection + // channel replaces it (docs/design/live-connection.md, M2); buildAdbPushCommands goes with it then. + throw GradleException( + "hotReloadPython: hot reload does not work yet. The app has no receiver for the RELOAD_PYTHON " + + "broadcast this task used to send after `adb push`, so pushed files were never loaded (toolchain#103). " + + "It is being replaced by a live connection to the running app (docs/design/live-connection.md); " + + "until then, rebuild and reinstall the app to see changes.", ) - - commands.forEach { cmd -> - logger.info("hotReloadPython: $cmd") - val parts = cmd.split(" ") - val result = ProcessBuilder(parts) - .redirectErrorStream(true) - .start() - val output = result.inputStream.bufferedReader().readText() - val exit = result.waitFor() - if (exit != 0) { - logger.error("hotReloadPython: command failed (exit=$exit): $cmd\n$output") - } else if (output.isNotBlank()) { - logger.lifecycle(output.trim()) - } - } - - logger.lifecycle("hotReloadPython: done. Reload signal sent to device.") } } diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTask.kt index 8a100aa..de2af5e 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTask.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTask.kt @@ -1,5 +1,6 @@ package org.thisisthepy.python.multiplatform.toolchain.typedpython +import org.thisisthepy.python.multiplatform.toolchain.deleteWithoutFollowingLinks import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.ConfigurableFileCollection @@ -32,7 +33,7 @@ val TYPEDPYTHON_MODES = listOf("checked", "compiled") /** The `typedpython` 0.1.0 wheel's own `Requires-Python`. The gate's venv is created for it. */ const val TYPEDPYTHON_PYTHON_REQUEST = ">=3.13" -/** Project property naming a directory holding the wheel, until `typedpython` is on PyPI. */ +/** Project property naming a directory holding both wheels; overrides the gate artifact. */ const val TYPEDPYTHON_WHEEL_DIR_PROPERTY = "typedpython.wheelDir" const val TYPEDPYTHON_NO_STUBS_NOTICE = @@ -72,6 +73,22 @@ abstract class TypedpythonCheckTask : DefaultTask() { @get:Input abstract val pyreflyVersion: Property + /** + * The `typedpython-gate` zip resolved from [gateCoordinates] (#90), empty when the project's + * repositories do not have it. + */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NAME_ONLY) + abstract val gateArchive: ConfigurableFileCollection + + /** `group:artifact:version` of the gate ([DEFAULT_TYPEDPYTHON_GATE] or `-Ptypedpython.gate`). */ + @get:Input + abstract val gateCoordinates: Property + + /** Where [gateArchive] is unzipped. Derived from it, so not an input of its own. */ + @get:Internal + abstract val gateDir: DirectoryProperty + /** `-Ptypedpython.wheelDir`, resolved against the project directory. */ @get:Input @get:Optional @@ -104,17 +121,22 @@ abstract class TypedpythonCheckTask : DefaultTask() { return } - // Before anything is created or run. `typedpython` is not ours on PyPI, so without a wheel - // directory there is nothing safe to install: skip, loudly -- never fall back to an index. + // Before anything is created or run. `typedpython` is not ours on PyPI, so it is installed from + // a wheel directory (-Ptypedpython.wheelDir) or from the gate artifact as a hash-pinned file, + // never by name from an index. With neither: skip, loudly. val wheels = wheelDir.orNull - if (wheels == null) { - logger.warn(typedpythonSkippedWarning(gateVersion.get(), pyreflyVersion.get())) - reportFile.parentFile.mkdirs() - reportFile.writeText("skipped: no -P$TYPEDPYTHON_WHEEL_DIR_PROPERTY\n") - return - } + val archive = gateArchive.files.firstOrNull { it.isFile } val venv = venvDir.get().asFile - val executable = ensureTypedpythonGate(venv, gateVersion.get(), pyreflyVersion.get(), wheels) + val executable = when { + wheels != null -> ensureTypedpythonGate(venv, gateVersion.get(), pyreflyVersion.get(), wheels) + archive != null -> ensureTypedpythonGateFromArchive(venv, archive, gateDir.get().asFile) + else -> { + logger.warn(typedpythonSkippedWarning(gateVersion.get(), pyreflyVersion.get())) + reportFile.parentFile.mkdirs() + reportFile.writeText("skipped: no gate artifact and no -P$TYPEDPYTHON_WHEEL_DIR_PROPERTY\n") + return + } + } val stubs = stubDirs.files.toList() if (stubs.isEmpty()) logger.lifecycle(TYPEDPYTHON_NO_STUBS_NOTICE) @@ -166,18 +188,7 @@ fun ensureTypedpythonGate(venv: File, version: String, pyreflyVersion: String, w if (executable.isFile && marker.isFile && marker.readText() == request) return executable val work = venv.parentFile.apply { mkdirs() } - if (!File(venv, "pyvenv.cfg").isFile) { - // Through pypackpack's UV backend (AGENTS.md section 13); it can create a venv with these options. - val created = runBlocking { - BackendInterface.create(BackendType.UV).createVirtualEnvironment( - path = venv.path, - pythonVersion = TYPEDPYTHON_PYTHON_REQUEST, - extraArgs = typedpythonVenvOptions(), - workingDir = work, - ) - } - created.onFailure { throw GradleException("Could not create the typedpython venv at $venv: ${it.message}", it) } - } + createTypedpythonVenv(venv, work) runUv(typedpythonInstallCommand(venv, version, pyreflyVersion, wheelDir), work).let { (exit, output) -> if (exit != 0) { throw GradleException( @@ -192,6 +203,60 @@ fun ensureTypedpythonGate(venv: File, version: String, pyreflyVersion: String, w return executable } +/** + * Unzips the `typedpython-gate` [archive] into [gateDir], then installs typedpython from it and pyrefly + * from PyPI, both pinned by the manifest's hashes (`--require-hashes`), unless the same requirements are + * already installed. A host without a pyrefly wheel in the manifest fails, naming the platform. + */ +fun ensureTypedpythonGateFromArchive(venv: File, archive: File, gateDir: File): File { + gateDir.deleteWithoutFollowingLinks() + gateDir.mkdirs() + java.util.zip.ZipFile(archive).use { zip -> + zip.entries().asSequence().filter { !it.isDirectory }.forEach { entry -> + val target = File(gateDir, entry.name).canonicalFile + check(target.toPath().startsWith(gateDir.canonicalFile.toPath())) { "Entry ${entry.name} leaves $gateDir" } + target.parentFile.mkdirs() + zip.getInputStream(entry).use { input -> target.outputStream().use { input.copyTo(it) } } + } + } + val manifestFile = File(gateDir, TYPEDPYTHON_GATE_MANIFEST) + if (!manifestFile.isFile) throw GradleException("$archive has no $TYPEDPYTHON_GATE_MANIFEST at its root.") + val manifest = try { + parseTypedpythonGateManifest(manifestFile.readText()) + } catch (e: IllegalArgumentException) { + throw GradleException("$archive: ${e.message}", e) + } + val osName = System.getProperty("os.name").orEmpty() + val osArch = System.getProperty("os.arch").orEmpty() + if (pyreflyWheelForHost(manifest, osName, osArch) == null) { + throw GradleException(pyreflyNoWheelMessage(manifest, osName, osArch)) + } + val requirementsText = try { + typedpythonGateRequirements(manifest, gateDir) + } catch (e: IllegalStateException) { + throw GradleException(e.message.orEmpty(), e) + } + + val executable = typedpythonExecutable(venv) + val marker = File(venv, ".typedpython-request") + if (executable.isFile && marker.isFile && marker.readText() == requirementsText) return executable + + val work = venv.parentFile.apply { mkdirs() } + createTypedpythonVenv(venv, work) + val requirements = File(work, "gate-requirements.txt").apply { writeText(requirementsText) } + runUv(typedpythonHashedInstallCommand(venv, requirements), work).let { (exit, output) -> + if (exit != 0) { + throw GradleException( + "Could not install typedpython ${manifest.typedpythonVersion} (from $archive) and pyrefly " + + "${manifest.pyreflyVersion} (from PyPI) into $venv with --require-hashes (uv exit $exit):\n$output", + ) + } + } + if (!executable.isFile) throw GradleException("typedpython was installed into $venv but $executable does not exist") + marker.writeText(requirementsText) + return executable +} + fun typedpythonWheelInstruction(version: String, pyreflyVersion: String): String = "The directory named by -P$TYPEDPYTHON_WHEEL_DIR_PROPERTY= must hold the " + "typedpython-$version wheel and the pyrefly-$pyreflyVersion wheel for this platform; nothing " + @@ -204,9 +269,12 @@ fun typedpythonWheelInstruction(version: String, pyreflyVersion: String): String */ fun typedpythonSkippedWarning(version: String, pyreflyVersion: String): String = "\n!!! typedpythonCheck SKIPPED: the project's Python was NOT type-checked !!!\n" + - "Why: the typedpython gate is not on PyPI yet, and it is never installed from an index " + - "(a package of that name there would not be ours).\n" + - "To turn the check on: pass -P$TYPEDPYTHON_WHEEL_DIR_PROPERTY=. " + + "Why: the gate artifact ($DEFAULT_TYPEDPYTHON_GATE:$TYPEDPYTHON_GATE_CLASSIFIER@zip, or the one " + + "-P$TYPEDPYTHON_GATE_PROPERTY names) is not in this project's repositories, and the typedpython gate " + + "is never installed by name from an index (a package of that name there would not be ours).\n" + + "To turn the check on: add the repository that has the gate (python-multiplatform's " + + "`./gradlew publishAllToMavenLocal` puts it in mavenLocal()), or pass " + + "-P$TYPEDPYTHON_WHEEL_DIR_PROPERTY=. " + typedpythonWheelInstruction(version, pyreflyVersion) + "\n" /** @@ -215,6 +283,20 @@ fun typedpythonSkippedWarning(version: String, pyreflyVersion: String): String = */ fun typedpythonCheckedDirs(packageDir: File, metaDirs: List, libDirs: List): List = listOf(packageDir) +/** Creates [venv] through pypackpack's UV backend (AGENTS.md section 13) unless it exists. */ +private fun createTypedpythonVenv(venv: File, work: File) { + if (File(venv, "pyvenv.cfg").isFile) return + val created = runBlocking { + BackendInterface.create(BackendType.UV).createVirtualEnvironment( + path = venv.path, + pythonVersion = TYPEDPYTHON_PYTHON_REQUEST, + extraArgs = typedpythonVenvOptions(), + workingDir = work, + ) + } + created.onFailure { throw GradleException("Could not create the typedpython venv at $venv: ${it.message}", it) } +} + /** `uv venv` options for the gate: never the user's `pyproject.toml` project, and a fresh venv. */ fun typedpythonVenvOptions(): Map = linkedMapOf("no-project" to "", "clear" to "") diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt new file mode 100644 index 0000000..6060e6e --- /dev/null +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt @@ -0,0 +1,116 @@ +package org.thisisthepy.python.multiplatform.toolchain.typedpython + +import groovy.json.JsonSlurper +import java.io.File + +/** + * Where the TypedPython gate comes from by default (#90, python-multiplatform #20): the `typedpython-gate` + * zip that python-multiplatform-ksp publishes beside its processor jar. Its version is the + * python-multiplatform project version (python-multiplatform-gradle-plugin's `DEFAULT_PROCESSOR_COORDINATES` + * uses the same). This is the one place both are written; `-Ptypedpython.gate=` + * overrides it. + */ +const val DEFAULT_TYPEDPYTHON_GATE = "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha01" + +/** Project property overriding [DEFAULT_TYPEDPYTHON_GATE]. */ +const val TYPEDPYTHON_GATE_PROPERTY = "typedpython.gate" + +const val TYPEDPYTHON_GATE_CLASSIFIER = "typedpython-gate" + +/** The manifest at the zip's root, beside the wheel. */ +const val TYPEDPYTHON_GATE_MANIFEST = "typedpython-gate.json" + +/** `group:artifact:version` -> the dependency notation of its `typedpython-gate` zip. */ +fun typedpythonGateNotation(coordinates: String): String { + val parts = coordinates.trim().split(':') + require(parts.size == 3 && parts.all { it.isNotBlank() }) { + "-P$TYPEDPYTHON_GATE_PROPERTY must be group:artifact:version, not '$coordinates'." + } + return "${parts.joinToString(":")}:$TYPEDPYTHON_GATE_CLASSIFIER@zip" +} + +/** `typedpython-gate.json`, format 1. Every hash is a lower-case hex SHA-256. */ +data class TypedpythonGateManifest( + val typedpythonVersion: String, + val typedpythonWheel: String, + val typedpythonSha256: String, + val pyreflyVersion: String, + /** pyrefly's PyPI wheel file name -> its SHA-256, one per platform. */ + val pyreflySha256: Map, +) + +private val SHA256 = Regex("[0-9a-f]{64}") + +fun parseTypedpythonGateManifest(json: String): TypedpythonGateManifest { + val root = JsonSlurper().parseText(json) as? Map<*, *> + ?: throw IllegalArgumentException("$TYPEDPYTHON_GATE_MANIFEST is not a JSON object.") + require(root["format"]?.toString() == "1") { + "$TYPEDPYTHON_GATE_MANIFEST has format '${root["format"]}'; this toolchain reads format 1." + } + val typedpython = root["typedpython"] as? Map<*, *> + ?: throw IllegalArgumentException("$TYPEDPYTHON_GATE_MANIFEST has no 'typedpython' entry.") + val pyrefly = root["pyrefly"] as? Map<*, *> + ?: throw IllegalArgumentException("$TYPEDPYTHON_GATE_MANIFEST has no 'pyrefly' entry.") + + fun text(map: Map<*, *>, key: String, owner: String): String = + (map[key] as? String)?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("$TYPEDPYTHON_GATE_MANIFEST: '$owner.$key' is missing.") + + fun hash(value: String, what: String): String { + require(SHA256.matches(value)) { "$TYPEDPYTHON_GATE_MANIFEST: $what is not a SHA-256 ('$value')." } + return value + } + + val pyreflyHashes = (pyrefly["sha256"] as? Map<*, *>).orEmpty() + .map { (file, sha) -> file.toString() to hash(sha?.toString().orEmpty(), "the hash of $file") } + .toMap() + require(pyreflyHashes.isNotEmpty()) { "$TYPEDPYTHON_GATE_MANIFEST: 'pyrefly.sha256' lists no wheel." } + return TypedpythonGateManifest( + typedpythonVersion = text(typedpython, "version", "typedpython"), + typedpythonWheel = text(typedpython, "wheel", "typedpython"), + typedpythonSha256 = hash(text(typedpython, "sha256", "typedpython"), "typedpython.sha256"), + pyreflyVersion = text(pyrefly, "version", "pyrefly"), + pyreflySha256 = pyreflyHashes, + ) +} + +/** + * The `--require-hashes` requirements for the gate. typedpython is a direct reference to the wheel in + * [gateDir], so no index is ever asked for that name (unclaimed on PyPI); pyrefly comes from PyPI, + * accepted only if it matches one of the manifest's hashes. + */ +fun typedpythonGateRequirements(manifest: TypedpythonGateManifest, gateDir: File): String { + val wheel = File(gateDir, manifest.typedpythonWheel) + check(wheel.isFile) { + "The typedpython-gate zip names ${manifest.typedpythonWheel} in $TYPEDPYTHON_GATE_MANIFEST but does not contain it." + } + val pyreflyHashes = manifest.pyreflySha256.values.distinct().joinToString(" ") { "--hash=sha256:$it" } + return "typedpython @ ${wheel.absoluteFile.toPath().toUri()} --hash=sha256:${manifest.typedpythonSha256}\n" + + "pyrefly==${manifest.pyreflyVersion} $pyreflyHashes\n" +} + +/** Everything comes from [requirements], and every distribution must match a hash in it. */ +fun typedpythonHashedInstallCommand(venv: File, requirements: File): List = + listOf("uv", "pip", "install", "--python", venv.path, "--require-hashes", "-r", requirements.path) + +/** The manifest's pyrefly wheel for this OS and architecture, or `null` when there is none. */ +fun pyreflyWheelForHost(manifest: TypedpythonGateManifest, osName: String, osArch: String): String? { + val arm = osArch.lowercase() in setOf("aarch64", "arm64") + val x64 = osArch.lowercase() in setOf("x86_64", "amd64") + val os = osName.lowercase() + val matches: (String) -> Boolean = when { + os.startsWith("mac") && arm -> { name -> "macosx" in name && name.endsWith("_arm64.whl") } + os.startsWith("mac") && x64 -> { name -> "macosx" in name && name.endsWith("_x86_64.whl") } + os.startsWith("linux") && arm -> { name -> "manylinux" in name && "_aarch64" in name } + os.startsWith("linux") && x64 -> { name -> "manylinux" in name && "_x86_64" in name } + os.startsWith("windows") && arm -> { name -> name.endsWith("win_arm64.whl") } + os.startsWith("windows") && x64 -> { name -> name.endsWith("win_amd64.whl") } + else -> return null + } + return manifest.pyreflySha256.keys.sorted().firstOrNull(matches) +} + +fun pyreflyNoWheelMessage(manifest: TypedpythonGateManifest, osName: String, osArch: String): String = + "typedpythonCheck: pyrefly ${manifest.pyreflyVersion} has no wheel for this platform ($osName $osArch) " + + "among the hashes in the typedpython-gate manifest (${manifest.pyreflySha256.keys.sorted().joinToString()}). " + + "Pass -P$TYPEDPYTHON_WHEEL_DIR_PROPERTY= holding a typedpython and a pyrefly wheel for this platform." diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/EmbedLevelTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/EmbedLevelTest.kt index d7fee64..262c04d 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/EmbedLevelTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/EmbedLevelTest.kt @@ -74,11 +74,11 @@ class EmbedLevelTest { @Test fun `the embed record is JSON carrying the level, family, warning and expected interpreter version`() { - val json = embedRecordJson(2, "android", "raised \"x\"", "3.14.7") + val json = embedRecordJson(2, "android", "raised \"x\"", "3.15.0") assertTrue(json.contains("\"embedLevel\": 2"), json) assertTrue(json.contains("\"platformFamily\": \"android\""), json) assertTrue(json.contains("raised \\\"x\\\""), json) - assertTrue(json.contains("\"interpreterVersion\": \"3.14.7\""), json) + assertTrue(json.contains("\"interpreterVersion\": \"3.15.0\""), json) // toolchain ships no interpreter (SPEC §1.12, #42), so the record no longer says whether one is bundled. assertFalse(json.contains("interpreterBundled"), json) val none = embedRecordJson(0, "linux", null) @@ -89,9 +89,9 @@ class EmbedLevelTest { @Test fun `the expected interpreter version is the compileSdk release at levels 1 and 2, none at 0`() { - assertNull(expectedInterpreterVersion(0, sdk("3.14.7"))) - assertEquals("3.14.7", expectedInterpreterVersion(1, sdk("3.14"))) - assertEquals("3.14.7", expectedInterpreterVersion(2, sdk("3.14.7"))) + assertNull(expectedInterpreterVersion(0, sdk("3.15.0"))) + assertEquals("3.15.0", expectedInterpreterVersion(1, sdk("3.15"))) + assertEquals("3.15.0", expectedInterpreterVersion(2, sdk("3.15.0"))) assertNull(expectedInterpreterVersion(2, null)) // A rejected compileSdk (not provided by python-multiplatform) names no version to expect. assertNull(expectedInterpreterVersion(2, sdk("3.11.9-alpha"))) diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTreeTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTreeTest.kt new file mode 100644 index 0000000..8e00baf --- /dev/null +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTreeTest.kt @@ -0,0 +1,85 @@ +package org.thisisthepy.python.multiplatform.toolchain + +import org.thisisthepy.python.multiplatform.toolchain.bundle.PythonStagingLayout +import org.thisisthepy.python.multiplatform.toolchain.bundle.stagePythonPayload +import org.thisisthepy.python.multiplatform.toolchain.dependency.lang.python.installDependenciesForTarget +import org.thisisthepy.python.multiplatform.toolchain.typedpython.ensureTypedpythonGateFromArchive +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Deleting a directory never follows a symbolic link out of it (pypackpack#96, the same defect here). Kotlin's + * `deleteRecursively` walks into a link to a directory and empties its target. + */ +class FileTreeTest { + // Inside this module's build directory (AGENTS.md §2). + private val root = File("build/file-tree-test").absoluteFile.apply { deleteWithoutFollowingLinks(); mkdirs() } + + @AfterTest + fun cleanup() { + root.deleteWithoutFollowingLinks() + } + + /** `/link` -> `/outside`, which holds `keep.txt`. */ + private fun linkOutside(dir: File): File { + val outside = File(root, "outside").apply { mkdirs() } + File(outside, "keep.txt").writeText("keep") + dir.mkdirs() + Files.createSymbolicLink(File(dir, "link").toPath(), outside.toPath()) + return File(outside, "keep.txt") + } + + @Test + fun `a link inside the tree is removed and its target kept`() { + val tree = File(root, "tree") + val keep = linkOutside(File(tree, "a")) + File(tree, "a/file.txt").writeText("x") + + assertTrue(tree.deleteWithoutFollowingLinks()) + + assertFalse(tree.exists()) + assertEquals("keep", keep.readText()) + assertTrue(File(root, "missing").deleteWithoutFollowingLinks()) + } + + @Test + fun `restaging the payload does not follow a link out of the previous one`() { + val bundle = File(root, "bundle/python/pkg").apply { mkdirs() } + File(bundle, "__init__.py").writeText("") + val destination = File(root, "destination") + val keep = linkOutside(File(destination, PythonStagingLayout.PAYLOAD_ROOT)) + + stagePythonPayload(File(root, "bundle"), destination, PythonStagingLayout.PAYLOAD_ROOT) + + assertEquals("keep", keep.readText()) + } + + @Test + fun `clearing a target's install directory does not follow a link out of it`() { + val installDir = File(root, "install") + val keep = linkOutside(installDir) + + installDependenciesForTarget(emptyList(), "aarch64-linux-android", installDir, File(root, "requirements")) + + assertEquals("keep", keep.readText()) + } + + @Test + fun `clearing the typedpython gate directory does not follow a link out of it`() { + val gateDir = File(root, "gate") + val keep = linkOutside(gateDir) + val archive = File(root, "gate.zip") + ZipOutputStream(archive.outputStream()).use { it.putNextEntry(ZipEntry("empty.txt")); it.closeEntry() } + + runCatching { ensureTypedpythonGateFromArchive(File(root, "venv"), archive, gateDir) } + + assertEquals("keep", keep.readText()) + } +} diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersionTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersionTest.kt index 6d1d558..77952c8 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersionTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PublishedPythonVersionTest.kt @@ -41,11 +41,12 @@ class PublishedPythonVersionTest { @Test fun `only python-multiplatform runtime modules count`() { - assertTrue(isPythonMultiplatformModule("io.github.thisisthepy", "python-multiplatform")) - assertTrue(isPythonMultiplatformModule("io.github.thisisthepy", "python-multiplatform-desktop")) - assertTrue(isPythonMultiplatformModule("io.github.thisisthepy", "python-multiplatform-android")) - assertFalse(isPythonMultiplatformModule("io.github.thisisthepy", "python-multiplatform-gradle-plugin")) - assertFalse(isPythonMultiplatformModule("io.github.thisisthepy", "python-multiplatform-ksp")) + assertTrue(isPythonMultiplatformModule("org.thisisthepy", "python-multiplatform")) + assertTrue(isPythonMultiplatformModule("org.thisisthepy", "python-multiplatform-desktop")) + assertTrue(isPythonMultiplatformModule("org.thisisthepy", "python-multiplatform-android")) + assertFalse(isPythonMultiplatformModule("org.thisisthepy", "python-multiplatform-gradle-plugin")) + assertFalse(isPythonMultiplatformModule("org.thisisthepy", "python-multiplatform-ksp")) + assertFalse(isPythonMultiplatformModule("io.github.thisisthepy", "python-multiplatform")) assertFalse(isPythonMultiplatformModule("org.example", "python-multiplatform")) } diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginApplyTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginApplyTest.kt index c43ee56..a6af961 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginApplyTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginApplyTest.kt @@ -21,7 +21,7 @@ import kotlin.test.assertTrue * docs/SPEC.md §1.1: applying the plugin id to a real Gradle `Project` creates the `python` * extension and registers the tasks of §1.10–§1.15, all in group `python`. * - * Before this file the only thing that applied the plugin was a `sample` build, which needs + * Before this file the only thing that applied the plugin was a `usage-example` build, which needs * `publishToMavenLocal` first and an Android SDK; a ProjectBuilder project needs neither. */ class PythonPluginApplyTest { diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginAttachmentTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginAttachmentTest.kt index 5cf38f2..96f76d9 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginAttachmentTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginAttachmentTest.kt @@ -18,7 +18,7 @@ import kotlin.test.assertTrue * - Android: the staged Android root is an asset source directory of `android.sourceSets.main`, * registered at apply time (AGENTS.md §15: AGP reads source sets in its own `afterEvaluate`). * `PythonPlugin` reaches that object reflectively, so the test applies the real Android Gradle - * plugin (the version `sample` uses) rather than a fake, to prove the method chain exists. + * plugin (the version `usage-example` uses) rather than a fake, to prove the method chain exists. */ class PythonPluginAttachmentTest { private fun newProject(): Project = diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginBuildLevelTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginBuildLevelTest.kt index 2b3b2c5..fa74654 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginBuildLevelTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginBuildLevelTest.kt @@ -14,7 +14,7 @@ import kotlin.test.assertTrue * variant, inside that variant's task action (AGENTS.md §14), with a message naming that slot. * * Blank resolves to `"instant"`: both `DebugBuildType` and `ReleaseBuildType` default `compileLevel` - * to `""`, and `sample` never sets it. + * to `""`, and `usage-example` never sets it. */ class PythonPluginBuildLevelTest { @Test diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginDevAgentTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginDevAgentTest.kt new file mode 100644 index 0000000..a388b39 --- /dev/null +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginDevAgentTest.kt @@ -0,0 +1,73 @@ +package org.thisisthepy.python.multiplatform.toolchain + +import org.gradle.api.Action +import org.gradle.api.Describable +import org.gradle.api.internal.project.ProjectInternal +import org.gradle.api.tasks.JavaExec +import org.gradle.testfixtures.ProjectBuilder +import org.thisisthepy.python.multiplatform.toolchain.bundle.StagePythonBundleTask +import org.thisisthepy.python.multiplatform.toolchain.devagent.DEV_AGENT_ENVIRONMENT_VARIABLE +import org.thisisthepy.python.multiplatform.toolchain.devagent.DEV_AGENT_LAUNCH_ACTION +import org.thisisthepy.python.multiplatform.toolchain.dsl.PythonExtension +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * toolchain#105: the active build type's `enableHotReload` decides both halves. Its staging writes the + * dev-agent marker into every destination, and the desktop run task (Compose's `run`) gets a port, a token + * and a connection file. A build type without it gets neither. + */ +class PythonPluginDevAgentTest { + private fun evaluated(buildType: String? = null): ProjectInternal { + val project = ProjectBuilder.builder().build() as ProjectInternal + buildType?.let { project.extensions.extraProperties["python.buildType"] = it } + project.pluginManager.apply("org.thisisthepy.python.multiplatform") + // What Compose Multiplatform's `compose.desktop.application` registers. + project.tasks.register("run", JavaExec::class.java) + project.extensions.getByType(PythonExtension::class.java).apply { + packaging { hotReload { serverHost = "https://localhost:8080" } } + buildTypes { + getByName("debug") { enableHotReload = true } + getByName("release") { } + } + } + project.evaluate() + return project + } + + /** Gradle's wrapper around a named `doFirst` action describes itself as "Execute ". */ + private val Action<*>.name: String get() = (this as? Describable)?.displayName.orEmpty() + + private fun ProjectInternal.stageTasks() = + listOf("Android", "Ios", "Desktop").map { tasks.getByName("stagePythonBundle$it") as StagePythonBundleTask } + + @Test + fun `a hot-reload build stages the marker everywhere and its desktop run starts an agent`() { + val project = evaluated() + + project.stageTasks().forEach { assertTrue(it.devAgentMarker, "${it.name} stages no marker") } + val run = project.tasks.getByName("run") as JavaExec + val action = run.actions.firstOrNull { DEV_AGENT_LAUNCH_ACTION in it.name } + assertNotNull(action, "run has no dev-agent launch action: ${run.actions.map { it.name }}") + + action.execute(run) + val value = run.environment[DEV_AGENT_ENVIRONMENT_VARIABLE] as String + assertTrue(Regex("port=\\d+;token=[A-Za-z0-9_-]{43,}").matches(value), value) + val connection = File(project.layout.buildDirectory.get().asFile, "pythonDevAgent/run.json") + assertTrue(connection.isFile && value.substringAfter("token=") in connection.readText(), "$connection") + } + + @Test + fun `a release build stages no marker and its desktop run starts nothing`() { + val project = evaluated(buildType = "release") + + project.stageTasks().forEach { assertFalse(it.devAgentMarker, "${it.name} stages the marker") } + val run = project.tasks.getByName("run") as JavaExec + assertTrue(run.actions.none { DEV_AGENT_LAUNCH_ACTION in it.name }, "${run.actions.map { it.name }}") + assertEquals(null, run.environment[DEV_AGENT_ENVIRONMENT_VARIABLE]) + } +} diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginHotReloadTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginHotReloadTest.kt index 05202cf..61052b1 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginHotReloadTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginHotReloadTest.kt @@ -2,7 +2,12 @@ package org.thisisthepy.python.multiplatform.toolchain import org.thisisthepy.python.multiplatform.toolchain.dsl.HotReloadExtension import org.thisisthepy.python.multiplatform.toolchain.dsl.PackagingExtension +import org.thisisthepy.python.multiplatform.toolchain.hotreload.HotReloadPushTask import org.thisisthepy.python.multiplatform.toolchain.hotreload.validateHotReloadConfig +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import kotlin.io.path.createTempDirectory +import kotlin.test.assertTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -25,6 +30,39 @@ import kotlin.test.assertFailsWith */ class PythonPluginHotReloadTest { + private fun hotReloadTask(enabled: Boolean): HotReloadPushTask { + val project = ProjectBuilder.builder().build() + val sources = createTempDirectory("hot-reload-src").toFile().apply { resolve("app.py").writeText("X = 1\n") } + return project.tasks.register("hotReloadPython", HotReloadPushTask::class.java) { + hotReloadEnabled = enabled + sourceRoot = sources + remoteBasePath = "/data/local/tmp/app/python" + // A path that cannot run: if the task tried `adb`, it would fail with an IOException instead. + adbPath = "/nonexistent/adb-must-not-run" + }.get() + } + + /** + * toolchain#103: nothing in python-multiplatform receives the RELOAD_PYTHON broadcast, so pushing files + * would look like a working hot reload while the app never changes. Until the live-connection channel + * replaces it (docs/design/live-connection.md, M2), an enabled task fails saying so, and runs no adb. + */ + @Test + fun `an enabled hot reload fails saying it does not work yet, without running adb`() { + val task = hotReloadTask(enabled = true) + + val error = assertFailsWith { task.actions.forEach { it.execute(task) } } + + val message = error.message.orEmpty() + assertTrue("does not work yet" in message && "RELOAD_PYTHON" in message && "#103" in message, message) + } + + @Test + fun `a disabled hot reload still only prints its guide`() { + val task = hotReloadTask(enabled = false) + task.actions.forEach { it.execute(task) } + } + @Test fun `disabled hotReload passes validation with empty serverHost`() { val ext = HotReloadExtension() diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginPythonOnlyTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginPythonOnlyTest.kt index 6e6ed67..a638d03 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginPythonOnlyTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginPythonOnlyTest.kt @@ -34,7 +34,7 @@ open class FakeEmbeddedPythonVersion(var pythonVersion: String) class PythonPluginPythonOnlyTest { private fun configured( project: Project, - sdk: String? = "3.14.7", + sdk: String? = "3.15.0", pythonMultiplatformVersion: String? = null, configure: PythonExtension.() -> Unit, ): Project { @@ -52,7 +52,7 @@ class PythonPluginPythonOnlyTest { } private fun evaluated( - sdk: String? = "3.14.7", + sdk: String? = "3.15.0", pythonMultiplatformVersion: String? = null, configure: PythonExtension.() -> Unit, ): Project = @@ -99,7 +99,7 @@ class PythonPluginPythonOnlyTest { val pack = project.pack("packagePythonAndroidArm64Debug") assertEquals(2, pack.embedLevel) - assertEquals("3.14.7", pack.interpreterVersion) + assertEquals("3.15.0", pack.interpreterVersion) pack.destinationDirectory.get().asFile.mkdirs() pack.actions.forEach { it.execute(pack) } @@ -108,7 +108,7 @@ class PythonPluginPythonOnlyTest { assertTrue("python/app/main.py" in entries, "$entries") assertFalse(entries.any { it.startsWith("runtime/") || it == "runtime-manifest.json" }, "$entries") val record = File(buildDir, "distributions/app-androidArm64-debug.embed.json").readText() - assertTrue("\"interpreterVersion\": \"3.14.7\"" in record, record) + assertTrue("\"interpreterVersion\": \"3.15.0\"" in record, record) assertFalse("interpreterBundled" in record, record) } @@ -123,15 +123,15 @@ class PythonPluginPythonOnlyTest { @Test fun `compileSdk still selects the wheels' python version at level 2`() { - val project = evaluated(sdk = "3.14") { androidArm64() } + val project = evaluated(sdk = "3.15") { androidArm64() } val install = assertIs(project.tasks.getByName("installPythonDependenciesAndroidArm64")) - assertEquals("3.14", install.installArguments["python-version"]) + assertEquals("3.15", install.installArguments["python-version"]) } @Test fun `a disagreeing python multiplatform version property fails only the level 2 bundling tasks`() { - val project = evaluated(sdk = "3.14.7", pythonMultiplatformVersion = "3.13.0") { + val project = evaluated(sdk = "3.15.0", pythonMultiplatformVersion = "3.13.0") { packaging { embedLevel = 0 } androidArm64() macosArm64() @@ -139,7 +139,7 @@ class PythonPluginPythonOnlyTest { val android = project.build("buildPythonAndroidArm64Debug") val rejection = assertNotNull(android.pythonVersionRejection) - assertTrue("3.14.7" in rejection && "3.13.0" in rejection, rejection) + assertTrue("3.15.0" in rejection && "3.13.0" in rejection, rejection) // macOS honours level 0: python-multiplatform embeds nothing there, so nothing to compare. assertNull(project.build("buildPythonMacosArm64Debug").pythonVersionRejection) @@ -151,7 +151,7 @@ class PythonPluginPythonOnlyTest { @Test fun `an agreeing version, or none, leaves the bundling task alone`() { - val agreeing = evaluated(pythonMultiplatformVersion = "3.14.7") { androidArm64() } + val agreeing = evaluated(pythonMultiplatformVersion = "3.15.0") { androidArm64() } assertNull(agreeing.build("buildPythonAndroidArm64Debug").pythonVersionRejection) val unknown = evaluated { androidArm64() } @@ -172,14 +172,14 @@ class PythonPluginPythonOnlyTest { configured(app) { androidArm64() } val rejection = assertNotNull(app.build("buildPythonAndroidArm64Debug").pythonVersionRejection) - assertTrue("3.14.7" in rejection && "3.13.0" in rejection, rejection) + assertTrue("3.15.0" in rejection && "3.13.0" in rejection, rejection) } @Test fun `the property overrides the python-multiplatform project's extension`() { val app = appBesidePythonMultiplatform("3.13.0") - configured(app, pythonMultiplatformVersion = "3.14.7") { androidArm64() } + configured(app, pythonMultiplatformVersion = "3.15.0") { androidArm64() } assertNull(app.build("buildPythonAndroidArm64Debug").pythonVersionRejection) } diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginSourceSetTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginSourceSetTest.kt index 91721f4..e5df5e3 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginSourceSetTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginSourceSetTest.kt @@ -22,7 +22,7 @@ import kotlin.test.assertNull * * `localLibraryPath` stays the explicit override it always was -- declaring both `localLibraryPath` * and `commonMain.srcDirs` is not a conflict, `localLibraryPath` simply wins, so every existing - * consumer (`sample` sets `localLibraryPath` and no `sourceSets` block) keeps resolving + * consumer (`usage-example` sets `localLibraryPath` and no `sourceSets` block) keeps resolving * exactly as before. * * Before [resolvePackageDir] exists, this file fails to compile rather than failing an assertion -- diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginVariantGraphTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginVariantGraphTest.kt index 6bf453f..0c69029 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginVariantGraphTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginVariantGraphTest.kt @@ -49,7 +49,7 @@ import kotlin.test.assertTrue * * [resolveVariants] returns an empty list when `python { }` platform declares nothing, and * `PythonPlugin` then leaves `buildPython`/`packagePython` doing exactly what they do today: one - * host-target bundle, one `.zip`. `sample` declares no platforms, so the only + * host-target bundle, one `.zip`. `usage-example` declares no platforms, so the only * chain that currently runs end to end is unaffected by any of this. The platform block is what * creates more than one target; without it there is exactly one, and one build type chosen by * `-Ppython.buildType` as before. diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonVersionAgreementTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonVersionAgreementTest.kt index 7e2375d..363c300 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonVersionAgreementTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonVersionAgreementTest.kt @@ -19,41 +19,41 @@ class PythonVersionAgreementTest { @Test fun `the same X Y Z agrees`() { - assertNull(checkPythonVersionAgreement(sdk("3.14.7"), "3.14.7")) + assertNull(checkPythonVersionAgreement(sdk("3.15.0"), "3.15.0")) // `X.Y` resolves to the newest published micro, which is what is compared. - assertNull(checkPythonVersionAgreement(sdk("3.14"), "3.14.7")) - assertNull(checkPythonVersionAgreement(sdk("3.14.7"), " 3.14.7 ")) + assertNull(checkPythonVersionAgreement(sdk("3.15"), "3.15.0")) + assertNull(checkPythonVersionAgreement(sdk("3.15.0"), " 3.15.0 ")) } @Test fun `a different X Y Z is rejected, naming both and where the embedded one comes from`() { - val rejection = assertNotNull(checkPythonVersionAgreement(sdk("3.13.0"), "3.14.7")) - assertTrue("3.13.0" in rejection && "3.14.7" in rejection, rejection) + val rejection = assertNotNull(checkPythonVersionAgreement(sdk("3.15.0"), "3.13.0")) + assertTrue("3.13.0" in rejection && "3.15.0" in rejection, rejection) assertTrue("python-multiplatform's pythonVersion" in rejection && "gradle.properties" in rejection, rejection) assertTrue("embedded" in rejection, rejection) - // Same minor, different micro: still a different interpreter (cf. iOS 3.14.6). - assertNotNull(checkPythonVersionAgreement(sdk("3.14.7"), "3.14.6")) + // Same minor, different micro: still a different interpreter (cf. iOS 3.14.6 against 3.14.7). + assertNotNull(checkPythonVersionAgreement(sdk("3.15.0"), "3.15.1")) } @Test fun `either side unknown means no check`() { - assertNull(checkPythonVersionAgreement(null, "3.14.7")) - assertNull(checkPythonVersionAgreement(sdk("3.14.7"), null)) + assertNull(checkPythonVersionAgreement(null, "3.15.0")) + assertNull(checkPythonVersionAgreement(sdk("3.15.0"), null)) assertNull(checkPythonVersionAgreement(null, null)) } @Test fun `a python-multiplatform version that is not a version is rejected, naming it`() { - val rejection = assertNotNull(checkPythonVersionAgreement(sdk("3.14.7"), "latest")) - assertTrue("'latest'" in rejection && "3.14.7" in rejection, rejection) + val rejection = assertNotNull(checkPythonVersionAgreement(sdk("3.15.0"), "latest")) + assertTrue("'latest'" in rejection && "3.15.0" in rejection, rejection) } @Test fun `the property wins over the extension, blank counts as unset, nothing means no check`() { - assertEquals("3.13.0", selectPythonMultiplatformVersion("3.13.0", "3.14.7")) - assertEquals("3.14.7", selectPythonMultiplatformVersion(null, "3.14.7")) - assertEquals("3.14.7", selectPythonMultiplatformVersion(" ", " 3.14.7 ")) + assertEquals("3.13.0", selectPythonMultiplatformVersion("3.13.0", "3.15.0")) + assertEquals("3.15.0", selectPythonMultiplatformVersion(null, "3.15.0")) + assertEquals("3.15.0", selectPythonMultiplatformVersion(" ", " 3.15.0 ")) assertEquals("3.13.0", selectPythonMultiplatformVersion("3.13.0", null)) assertNull(selectPythonMultiplatformVersion(null, null)) assertNull(selectPythonMultiplatformVersion("", " ")) diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTaskTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTaskTest.kt index 2ded432..b0ee13a 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTaskTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTaskTest.kt @@ -163,4 +163,24 @@ class BuildPythonArtifactTaskTest { result.outputDir.resolve("python/vendor_pkg/module.py").readText(), ) } + + /** + * `compileSdk` must reach `BundleRequest.pythonVersion` so wheel tags follow the app's + * interpreter (pypackpack#77: 3.15.0 is cp315-cp315, 3.15.0t is cp315-cp315t). Fails to compile + * before `bundleRequestFor` and `wheelVersionOf` exist. + */ + @Test + fun `the compileSdk version is forwarded to packpack's BundleRequest pythonVersion`() { + val request = bundleRequestFor( + packageDir = fixturePackageDir(), + target = "aarch64-apple-darwin", + buildType = "debug", + outputDir = kotlin.io.path.createTempDirectory("packpack-bundle-out-pyver").toFile(), + pythonVersion = wheelVersionOf("3.15.0"), + ) + assertEquals("3.15.0", request.pythonVersion) + assertEquals("3.15.0t", wheelVersionOf("3.15.0t")) + assertEquals("3.14.7", wheelVersionOf("3.14.7")) + assertEquals(null, bundleRequestFor(fixturePackageDir(), "aarch64-apple-darwin", "debug", java.io.File(".")).pythonVersion) + } } diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTaskTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTaskTest.kt index 384cdc1..a1a01a5 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTaskTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTaskTest.kt @@ -66,6 +66,25 @@ class StagePythonBundleTaskTest { assertEquals("VALUE = 1\n", staged.readText()) } + /** + * toolchain#105: a hot-reload build's payload carries python-multiplatform's dev-agent marker, and only + * that build's. The runtime starts no agent without it (SPEC L-12), so a release payload must never keep + * one from an earlier debug staging; the clean sync removes it like any other stale file. + */ + @Test + fun `the dev-agent marker is staged only when asked for, and a later staging without it removes it`() { + val bundle = bundleDir("fixture_pkg/__init__.py" to "VALUE = 1\n") + val destination = emptyDestination() + val marker = File(destination, "python/python_multiplatform_devagent.enable") + + val debug = stagePythonPayload(bundle, destination, PythonStagingLayout.PAYLOAD_ROOT, devAgentMarker = true) + assertTrue(marker.isFile, "expected the marker at $marker") + assertEquals(1, debug.fileCount, "the marker is not a payload file") + + stagePythonPayload(bundle, destination, PythonStagingLayout.PAYLOAD_ROOT, devAgentMarker = false) + assertFalse(marker.exists(), "a release staging kept the debug marker") + } + @Test fun `keeps nested package structure, since that is what import resolution reads`() { val bundle = bundleDir( @@ -121,7 +140,7 @@ class StagePythonBundleTaskTest { // A regression guard for a defect this task actually had. With the bundle declared nowhere, // Gradle had nothing to compare and reported `stagePythonBundleDesktop UP-TO-DATE` on every // run after the first, so the staged tree -- and the jar built from it -- kept a payload - // from an earlier revision of the Python package. Found by building `:sample: + // from an earlier revision of the Python package. Found by building `:usage-example: // desktopJar`, deleting a module, and building again: the module was still in the archive. // // Asserted on the annotation rather than through a Gradle run because up-to-date checking diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgentLaunchTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgentLaunchTest.kt new file mode 100644 index 0000000..d25f250 --- /dev/null +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgentLaunchTest.kt @@ -0,0 +1,71 @@ +package org.thisisthepy.python.multiplatform.toolchain.devagent + +import org.thisisthepy.python.multiplatform.toolchain.dsl.BuildTypesContainer +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * toolchain#105, the launcher's half of python-multiplatform SPEC L-12: which builds get the marker, and what a + * desktop launch hands the app (the variable) and the laptop (the connection file). + */ +class DevAgentLaunchTest { + + @Test + fun `the agent is enabled for the active build type that enables hot reload, and for no other`() { + val buildTypes = BuildTypesContainer().apply { + getByName("debug") { enableHotReload = true } + getByName("release") { } + } + assertTrue(devAgentEnabled(buildTypes, "debug")) + assertFalse(devAgentEnabled(buildTypes, "release")) + // No buildTypes { } at all: nothing asked for hot reload, so no build carries the marker. + assertFalse(devAgentEnabled(BuildTypesContainer(), "debug")) + } + + @Test + fun `a launch has a 32-byte url-safe token and a free loopback port`() { + val launch = newDevAgentLaunch() + // python-multiplatform's MIN_TOKEN_LENGTH: len(secrets.token_urlsafe(32)). + assertTrue(launch.token.length >= 43, launch.token) + assertTrue(launch.token.all { it.isLetterOrDigit() || it == '-' || it == '_' }, launch.token) + assertNotEquals(launch.token, newDevAgentLaunch().token) + ServerSocket(launch.port, 0, InetAddress.getLoopbackAddress()).close() + } + + @Test + fun `the variable has the form the runtime parses`() { + val launch = DevAgentLaunch(port = 4719, token = "t".repeat(43)) + assertEquals("port=4719;token=${"t".repeat(43)}", launch.environmentValue) + assertEquals("PYTHON_MULTIPLATFORM_DEV_AGENT", DEV_AGENT_ENVIRONMENT_VARIABLE) + assertEquals("python_multiplatform_devagent.enable", DEV_AGENT_ENABLE_MARKER) + } + + @Test + fun `the connection file names host, port and token and only its owner can read it`() { + val file = File(createTempDirectory("dev-agent-connection").toFile(), "nested/desktopRun.json") + file.parentFile.mkdirs() + file.writeText("stale") + val launch = DevAgentLaunch(port = 4719, token = "t".repeat(43)) + + launch.writeConnectionFile(file) + + val text = file.readText() + assertTrue("\"host\": \"127.0.0.1\"" in text && "\"port\": 4719" in text && "\"token\": \"${"t".repeat(43)}\"" in text, text) + if ("posix" in FileSystems.getDefault().supportedFileAttributeViews()) { + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(file.toPath()), + ) + } + } +} diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdkTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdkTest.kt index 06aa7a5..dc28efd 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdkTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdkTest.kt @@ -12,7 +12,10 @@ import kotlin.test.assertTrue * - a string, `compileSdk = "3.11.9-alpha"`: any version. One the release server does not publish * would be built automatically (INTENT §4.1), which is not available yet, so it is refused, inside * the bundling task. - * - a named constant, `compileSdk = PY3_13_0`: only versions the server publishes exist as constants. + * - a named constant, `compileSdk = PY3_15_0`: only versions the server publishes exist as constants. + * + * The runtime is CPython 3.15t, free-threaded only (python-multiplatform #158, toolchain #76): 3.14.7 and + * 3.13.0 are gone, and 3.15.0 is the one version published. * * In a build script both are plain `=` (Gradle's Kotlin assignment overloading); plain Kotlin calls * `assign` directly. @@ -25,11 +28,11 @@ class PythonSdkTest { @Test fun `a named constant is a published version`() { - val sdk = PythonSdk().apply { assign(PythonSdkVersion.PY3_13_0) } + val sdk = PythonSdk().apply { assign(PythonSdkVersion.PY3_15_0) } val resolved = resolvePythonSdk(sdk)!! - assertEquals("3.13.0", resolved.version.toString()) + assertEquals("3.15.0", resolved.version.toString()) assertTrue(resolved.fromConstant) assertNull(resolved.rejection) } @@ -42,37 +45,70 @@ class PythonSdkTest { } @Test - fun `python-multiplatform's pinned 3_14_7 is a constant`() { - val resolved = resolvePythonSdk(PythonSdk().apply { assign(PythonSdkVersion.PY3_14_7) })!! + fun `3_15_0 is the one published version and the one constant`() { + assertEquals(listOf("3.15.0"), PythonReleaseServer.PUBLISHED) + assertEquals(listOf(PythonSdkVersion.PY3_15_0), PythonSdkVersion.values().toList()) + } - assertEquals("3.14.7", resolved.version.toString()) - assertNull(resolved.rejection) + @Test + fun `3_15_0 is a published constant and X dot Y resolves to it`() { + val byConstant = resolvePythonSdk(PythonSdk().apply { assign(PythonSdkVersion.PY3_15_0) })!! + assertEquals("3.15.0", byConstant.version.toString()) + assertNull(byConstant.rejection) + assertEquals(true, byConstant.fromConstant) + + val byMinor = resolvePythonSdk(PythonSdk().apply { assign("3.15") })!! + assertEquals("3.15.0", byMinor.version.toReleaseString()) + assertNull(byMinor.rejection) + } + + @Test + fun `a CPython before 3_15 is refused as a GIL build, inside the task`() { + listOf("3.14.7", "3.14", "3.13.0", "3.13").forEach { raw -> + val rejection = resolvePythonSdk(PythonSdk().apply { assign(raw) })!!.rejection.orEmpty() + + assertTrue(raw in rejection, rejection) + assertTrue("free-threaded" in rejection && "3.15t" in rejection, rejection) + } + } + + @Test + fun `the free-threaded spelling resolves to a published version and keeps the t`() { + listOf("3.15t", "3.15.0t").forEach { raw -> + val resolved = resolvePythonSdk(PythonSdk().apply { assign(raw) })!! + + assertNull(resolved.rejection, raw) + assertEquals(true, resolved.version.freeThreaded, raw) + assertEquals("3.15.0", resolved.version.toReleaseString(), raw) + assertEquals("3.15.0t", resolved.version.toWheelVersion(), raw) + } + assertEquals("3.15.0", resolvePythonSdk(PythonSdk().apply { assign("3.15") })!!.version.toWheelVersion()) } @Test fun `a string naming a published version is accepted, X dot Y included`() { - listOf("3.13.0", "3.13").forEach { raw -> + listOf("3.15.0", "3.15").forEach { raw -> val resolved = resolvePythonSdk(PythonSdk().apply { assign(raw) })!! - assertEquals("3.13.0", resolved.version.toReleaseString(), raw) + assertEquals("3.15.0", resolved.version.toReleaseString(), raw) assertNull(resolved.rejection, raw) } } @Test fun `a string the server does not publish is refused because auto-build is not available yet`() { - val resolved = resolvePythonSdk(PythonSdk().apply { assign("3.11.9-alpha") })!! + val resolved = resolvePythonSdk(PythonSdk().apply { assign("3.15.9-alpha") })!! val rejection = resolved.rejection.orEmpty() - assertTrue(rejection.contains("3.11.9-alpha"), rejection) - assertTrue(rejection.contains("3.14.7") && rejection.contains("3.13.0"), "should list what is published: $rejection") + assertTrue(rejection.contains("3.15.9-alpha"), rejection) + assertTrue(rejection.contains("3.15.0"), "should list what is published: $rejection") assertTrue(rejection.contains("build", ignoreCase = true), rejection) } @Test fun `the last assignment wins, whichever form it uses`() { val sdk = PythonSdk().apply { - assign(PythonSdkVersion.PY3_13_0) + assign(PythonSdkVersion.PY3_15_0) assign("3.11.9-alpha") } diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersionTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersionTest.kt index 9151423..720f59c 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersionTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonVersionTest.kt @@ -82,7 +82,7 @@ class PythonVersionTest { val error = assertFailsWith { PythonVersion.parse("not-a-version") } assertEquals( "Invalid Python version 'not-a-version'. Expected 'X.Y', 'X.Y.Z', 'X.Y.Z-alpha[N]', " + - "or 'X.Y.Z-rc[N]'.", + "or 'X.Y.Z-rc[N]', each optionally followed by 't' (free-threaded).", error.message, ) } @@ -91,4 +91,16 @@ class PythonVersionTest { fun `an unknown channel word is rejected loudly`() { assertFailsWith { PythonVersion.parse("3.13.0-beta1") } } + + @Test + fun `a trailing t marks the free-threaded build and the wheel version keeps it`() { + val threaded = PythonVersion.parse("3.15t") + + assertEquals(true, threaded.freeThreaded) + assertEquals("3.15.0", threaded.toReleaseString()) + assertEquals("3.15.0t", threaded.toWheelVersion()) + assertEquals("3.15.0t", threaded.toString()) + assertEquals(false, PythonVersion.parse("3.15.0").freeThreaded) + assertEquals("3.15.0", PythonVersion.parse("3.15.0-rc1").toWheelVersion()) + } } diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTaskTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTaskTest.kt index 94944ae..7e40e2b 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTaskTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTaskTest.kt @@ -26,10 +26,10 @@ import kotlin.test.BeforeTest * The build scripts are Kotlin DSL (`build.gradle.kts`), as a consumer's are. What these tests * **cannot** see: `withPluginClasspath()` injects the plugin's classes instead of resolving them, so * the Kotlin DSL's pin of the buildscript `kotlin-stdlib` to Gradle's embedded version - * (`{strictly 1.9.23}` on Gradle 8.9, see `:sample:buildEnvironment`) does not apply here. A + * (`{strictly 1.9.23}` on Gradle 8.9, see `:usage-example:buildEnvironment`) does not apply here. A * gate install through `pypackpack`'s suspend backend passed every test below and still died in - * `sample` with `NoClassDefFoundError: kotlin/coroutines/jvm/internal/SpillingKt` -- see - * [TypedpythonCheckTask]. Classpath problems show up in `sample`, not here. + * `usage-example` with `NoClassDefFoundError: kotlin/coroutines/jvm/internal/SpillingKt` -- see + * [TypedpythonCheckTask]. Classpath problems show up in `usage-example`, not here. * * Before `typedpythonCheck` exists, these fail with "Task 'typedpythonCheck' not found" or with * `buildPython` succeeding over an `Any` leak -- the pre-implementation failure, not a regression. diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonDecisionsTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonDecisionsTest.kt index b4d1384..f9a0893 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonDecisionsTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonDecisionsTest.kt @@ -105,7 +105,7 @@ class TypedpythonDecisionsTest { fun `the skip warning says what was skipped, why, and how to turn the check on`() { val warning = typedpythonSkippedWarning("0.1.0", "1.3.2") assertTrue("SKIPPED" in warning && "NOT type-checked" in warning, warning) - assertTrue("not on PyPI" in warning, warning) + assertTrue("from an index" in warning, warning) assertTrue("-Ptypedpython.wheelDir" in warning, warning) assertTrue("typedpython-0.1.0" in warning && "pyrefly-1.3.2" in warning, warning) } diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt new file mode 100644 index 0000000..95ad4c3 --- /dev/null +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt @@ -0,0 +1,118 @@ +package org.thisisthepy.python.multiplatform.toolchain.typedpython + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `typedpythonCheck` with the gate from python-multiplatform-ksp's `typedpython-gate` zip and no + * `-Ptypedpython.wheelDir` (#90, `docs/SPEC.md` §1.18). Needs that zip in `~/.m2` (python-multiplatform's + * `./gradlew :python-multiplatform-ksp:publishToMavenLocal`), `uv` on `PATH` and network access (pyrefly + * comes from PyPI). Without the zip these tests are skipped with that reason, as on CI. + */ +class TypedpythonGateArtifactTest { + private val mavenLocalZip = File( + System.getProperty("user.home"), + ".m2/repository/org/thisisthepy/python-multiplatform-ksp/3.15.0-alpha01/" + + "python-multiplatform-ksp-3.15.0-alpha01-typedpython-gate.zip", + ) + + @BeforeTest + fun requireGate() { + assumeTrue( + "SKIPPED: needs $DEFAULT_TYPEDPYTHON_GATE:$TYPEDPYTHON_GATE_CLASSIFIER@zip in ~/.m2 " + + "(python-multiplatform: ./gradlew :python-multiplatform-ksp:publishToMavenLocal)", + mavenLocalZip.isFile, + ) + } + + private fun project(name: String, repositories: String): File { + val dir = File("build/typedpython-testkit/$name").absoluteFile + dir.deleteRecursively() + File(dir, "pkg/src/main/demo").mkdirs() + File(dir, "settings.gradle.kts").writeText("rootProject.name = \"$name\"\n") + File(dir, "build.gradle.kts").writeText( + "plugins {\n id(\"org.thisisthepy.python.multiplatform\")\n}\n\n" + + "repositories {\n$repositories\n}\n\npython {\n localLibraryPath = \"pkg\"\n}\n", + ) + File(dir, "pkg/pyproject.toml").writeText("[project]\nname = \"demo\"\nversion = \"0.0.1\"\n") + File(dir, "pkg/src/main/demo/__init__.py").writeText("") + File(dir, "pkg/src/main/demo/ok.py").writeText("def g(x: int) -> int:\n return x\n") + return dir + } + + private fun runner(dir: File, vararg arguments: String): GradleRunner = + GradleRunner.create() + .withProjectDir(dir) + .withTestKitDir(File("build/testkit-home").absoluteFile) + .withPluginClasspath() + .withArguments(listOf("typedpythonCheck", "--stacktrace") + arguments) + .forwardOutput() + + @Test + fun `with the gate in mavenLocal the check installs and runs without a wheel directory`() { + val dir = project("gate-mavenlocal", " mavenLocal()") + + val result = runner(dir).build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":typedpythonCheck")?.outcome, result.output) + assertFalse("SKIPPED" in result.output, result.output) + assertTrue(File(dir, "build/typedpython/gate/$TYPEDPYTHON_GATE_MANIFEST").isFile) + val requirements = File(dir, "build/typedpython/gate-requirements.txt").readText() + assertTrue(requirements.startsWith("typedpython @ file:"), requirements) + assertTrue("0 errors" in File(dir, "build/typedpython/report.txt").readText()) + } + + @Test + fun `a gate the repositories do not have is a skip with the warning, not a failure`() { + val dir = project("gate-missing", " mavenLocal()") + + val result = runner(dir, "-P$TYPEDPYTHON_GATE_PROPERTY=org.thisisthepy:python-multiplatform-ksp:0.0.0-none").build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":typedpythonCheck")?.outcome, result.output) + assertTrue("typedpythonCheck SKIPPED" in result.output, result.output) + assertFalse(File(dir, "build/typedpython/venv").exists()) + } + + /** A pyrefly whose hash is not in the manifest is refused by `--require-hashes`: the task fails. */ + @Test + fun `a pyrefly that matches none of the manifest's hashes fails the task`() { + val repo = File("build/typedpython-testkit/tampered-repo").absoluteFile + repo.deleteRecursively() + val target = File(repo, "org/thisisthepy/python-multiplatform-ksp/9.9.9/python-multiplatform-ksp-9.9.9-typedpython-gate.zip") + target.parentFile.mkdirs() + ZipFile(mavenLocalZip).use { source -> + ZipOutputStream(target.outputStream()).use { out -> + source.entries().asSequence().forEach { entry -> + var bytes = source.getInputStream(entry).readBytes() + if (entry.name == TYPEDPYTHON_GATE_MANIFEST) { + val tampered = Regex("(\"pyrefly-[^\"]+\\.whl\"\\s*:\\s*\")[0-9a-f]{64}\"") + .replace(bytes.decodeToString()) { it.groupValues[1] + "0".repeat(64) + "\"" } + bytes = tampered.encodeToByteArray() + } + out.putNextEntry(ZipEntry(entry.name)) + out.write(bytes) + out.closeEntry() + } + } + } + val dir = project( + "gate-tampered", + " maven {\n url = uri(\"${repo.toURI()}\")\n metadataSources { artifact() }\n }", + ) + + val result = runner(dir, "-P$TYPEDPYTHON_GATE_PROPERTY=org.thisisthepy:python-multiplatform-ksp:9.9.9").buildAndFail() + + assertTrue("--require-hashes" in result.output, result.output) + assertFalse(File(dir, "build/typedpython/report.txt").exists()) + } +} diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt new file mode 100644 index 0000000..05fada3 --- /dev/null +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt @@ -0,0 +1,129 @@ +package org.thisisthepy.python.multiplatform.toolchain.typedpython + +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The gate from python-multiplatform-ksp's `typedpython-gate` zip (#90, `docs/SPEC.md` §1.18): the + * coordinates, the manifest, the hash-pinned requirements and pyrefly's wheel for the host. No Gradle. + */ +class TypedpythonGateTest { + private val root = File("build/typedpython-gate-unit").absoluteFile + + @BeforeTest + fun clean() { + root.deleteRecursively() + root.mkdirs() + } + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + private val manifestJson = + """ + {"format": 1, + "typedpython": {"version": "0.1.0", "wheel": "typedpython-0.1.0-py3-none-any.whl", "sha256": "${"a".repeat(64)}"}, + "pyrefly": {"version": "1.3.2", "sha256": { + "pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl": "${"1".repeat(64)}", + "pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl": "${"2".repeat(64)}", + "pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "${"3".repeat(64)}", + "pyrefly-1.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "${"4".repeat(64)}", + "pyrefly-1.3.2-py3-none-win_amd64.whl": "${"5".repeat(64)}"}}} + """.trimIndent() + + @Test + fun `the gate is the typedpython-gate zip of python-multiplatform-ksp, from one constant`() { + assertEquals("org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha01", DEFAULT_TYPEDPYTHON_GATE) + assertEquals( + "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha01:typedpython-gate@zip", + typedpythonGateNotation(DEFAULT_TYPEDPYTHON_GATE), + ) + assertEquals("typedpython.gate", TYPEDPYTHON_GATE_PROPERTY) + assertFailsWith { typedpythonGateNotation("python-multiplatform-ksp") } + } + + @Test + fun `the manifest names the typedpython wheel and every pyrefly wheel's hash`() { + val manifest = parseTypedpythonGateManifest(manifestJson) + + assertEquals("0.1.0", manifest.typedpythonVersion) + assertEquals("typedpython-0.1.0-py3-none-any.whl", manifest.typedpythonWheel) + assertEquals("a".repeat(64), manifest.typedpythonSha256) + assertEquals("1.3.2", manifest.pyreflyVersion) + assertEquals(5, manifest.pyreflySha256.size) + } + + @Test + fun `a manifest of another format or without a hash is refused`() { + assertFailsWith { parseTypedpythonGateManifest(manifestJson.replace("\"format\": 1", "\"format\": 2")) } + assertFailsWith { parseTypedpythonGateManifest(manifestJson.replace("\"sha256\": \"${"a".repeat(64)}\"", "\"sha256\": \"\"")) } + assertFailsWith { parseTypedpythonGateManifest("{\"format\": 1}") } + } + + /** + * typedpython is a direct file reference, so it is never looked up on an index whatever someone + * publishes under that name; pyrefly comes from PyPI, and every line carries its hashes. + */ + @Test + fun `the requirements pin typedpython to the unzipped wheel and pyrefly to the manifest's hashes`() { + val wheel = File(root, "typedpython-0.1.0-py3-none-any.whl").apply { writeText("wheel") } + val manifest = parseTypedpythonGateManifest(manifestJson) + + val lines = typedpythonGateRequirements(manifest, root).lines().filter { it.isNotBlank() } + + assertEquals(2, lines.size, lines.joinToString("\n")) + assertEquals("typedpython @ ${wheel.toPath().toUri()} --hash=sha256:${"a".repeat(64)}", lines[0]) + assertTrue(lines[1].startsWith("pyrefly==1.3.2 "), lines[1]) + assertEquals(5, Regex("--hash=sha256:[0-9a-f]{64}").findAll(lines[1]).count(), lines[1]) + } + + @Test + fun `the requirements are refused when the zip lacks the wheel the manifest names`() { + val error = assertFailsWith { + typedpythonGateRequirements(parseTypedpythonGateManifest(manifestJson), root) + } + assertTrue("typedpython-0.1.0-py3-none-any.whl" in error.message.orEmpty(), error.message) + } + + @Test + fun `the install requires hashes and reads only the generated requirements`() { + assertEquals( + listOf("uv", "pip", "install", "--python", "/b/venv", "--require-hashes", "-r", "/b/gate-requirements.txt"), + typedpythonHashedInstallCommand(File("/b/venv"), File("/b/gate-requirements.txt")), + ) + } + + @Test + fun `pyrefly's wheel for the host is found by os and architecture, or the platform is named`() { + val manifest = parseTypedpythonGateManifest(manifestJson) + + assertEquals("pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl", pyreflyWheelForHost(manifest, "Mac OS X", "aarch64")) + assertEquals("pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl", pyreflyWheelForHost(manifest, "Mac OS X", "x86_64")) + assertEquals( + "pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + pyreflyWheelForHost(manifest, "Linux", "amd64"), + ) + assertEquals("pyrefly-1.3.2-py3-none-win_amd64.whl", pyreflyWheelForHost(manifest, "Windows 11", "amd64")) + assertNull(pyreflyWheelForHost(manifest, "Windows 11", "aarch64")) + assertNull(pyreflyWheelForHost(manifest, "FreeBSD", "amd64")) + + val message = pyreflyNoWheelMessage(manifest, "FreeBSD", "amd64") + assertTrue("FreeBSD" in message && "amd64" in message && "pyrefly 1.3.2" in message, message) + } + + @Test + fun `the skip warning names the gate artifact and both ways to provide it`() { + val warning = typedpythonSkippedWarning("0.1.0", "1.3.2") + assertTrue("SKIPPED" in warning && "NOT type-checked" in warning, warning) + assertTrue("python-multiplatform-ksp" in warning && "typedpython-gate" in warning, warning) + assertTrue("-Ptypedpython.gate" in warning && "-Ptypedpython.wheelDir" in warning, warning) + } +} diff --git a/sample/build.gradle.kts b/usage-example/build.gradle.kts similarity index 83% rename from sample/build.gradle.kts rename to usage-example/build.gradle.kts index 34b8c40..9d38ac3 100644 --- a/sample/build.gradle.kts +++ b/usage-example/build.gradle.kts @@ -12,22 +12,23 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.application) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.native.desktop).apply(false) alias(libs.plugins.compose.compiler) - // sample is its own build (settings.gradle.kts here). The toolchain plugin is resolved by + // usage-example is its own build (settings.gradle.kts here). The toolchain plugin is resolved by // coordinate from mavenLocal (`./gradlew :toolchain:publishToMavenLocal` in ../), as is // python-multiplatform's bindings plugin, which stages and packages the CPython stdlib (#22). alias(libs.plugins.toolchain) alias(libs.plugins.python.multiplatform.bindings) } -// Exercises the target DSL surface end to end -- until now `sample` did not apply the +// Exercises the target DSL surface end to end -- until now `usage-example` did not apply the // plugin at all, so the DSL in `dsl/` had no executable definition (`docs/ecosystem.md` §2, §4 // item 7). This is deliberately the subset of `(플러그인예시)build.gradle.kts` that today's tasks // (`PythonPlugin.kt`) actually read: `compileSdk`, `packaging`, `buildTypes`, `platforms`, and // `integration()`. The rest of the DSL surface (`buildFeatures`) is declared but not wired to any // task yet -- see the plugin's own README/report for what is scaffolding versus live. python { - compileSdk = PY3_14_7 + compileSdk = PY3_15_0 // A real `pypackpack` package (`pyproject.toml` + `src/main/`), so the chain has a payload // to carry. Without it `buildPython` skips `pypackpack` entirely and every step downstream of it // -- the zip, and now the staging tasks -- correctly produces nothing, which makes "the artifact @@ -56,7 +57,15 @@ python { } } packaging { - fileName = "sample" + fileName = "usage-example" + // Required once a build type enables hot reload; nothing reads it until a server exists (SPEC §1.14). + hotReload { serverHost = "https://localhost:8080" } + } + // The example build file's debug switch. It stages python-multiplatform's dev-agent marker and gives the + // desktop `run` a port and a token (toolchain#105); `-Ppython.buildType=release` would do neither. + buildTypes { + getByName("debug") { enableHotReload = true } + getByName("release") { } } // `compose = true` is left out: it needs `python.compose.pythonxCompose` and // `python.compose.kotlinModule`, and neither artifact is published yet (docs/SPEC.md §1.16). @@ -120,7 +129,14 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) - implementation(compose.components.resources) + // `components-resources` stays at 1.11.1 under `org.jetbrains.compose.components` + // (not published under `org.thisisthepy.compose`), so exclude its transitive upstream + // `org.jetbrains.compose.{runtime,foundation,ui}` to avoid pulling a second Compose copy. + implementation(compose.components.resources) { + exclude(group = "org.jetbrains.compose.runtime") + exclude(group = "org.jetbrains.compose.foundation") + exclude(group = "org.jetbrains.compose.ui") + } implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) diff --git a/usage-example/gradle.properties b/usage-example/gradle.properties new file mode 100644 index 0000000..2f1d93c --- /dev/null +++ b/usage-example/gradle.properties @@ -0,0 +1,9 @@ +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx2048M +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 +android.nonTransitiveRClass=true +android.useAndroidX=true +# org.thisisthepy.compose:compose-gradle-plugin:1.11.1-ext.3's Maven Central POM marks +# hot-reload-gradle-plugin as optional (shadow configuration), so Gradle does not place it on the +# plugin classpath unless disabled or declared separately. +org.jetbrains.compose.hot.reload.disable=true diff --git a/sample/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml similarity index 64% rename from sample/gradle/libs.versions.toml rename to usage-example/gradle/libs.versions.toml index 25d1651..8686b1f 100644 --- a/sample/gradle/libs.versions.toml +++ b/usage-example/gradle/libs.versions.toml @@ -1,4 +1,4 @@ -# sample/ is its own build (toolchain#22): a consumer of python-multiplatform, so it uses +# usage-example/ is its own build (toolchain#22): a consumer of python-multiplatform, so it uses # python-multiplatform's toolchain (its gradle/libs.versions.toml), not the toolchain plugin's. # The plugin itself (../toolchain) stays on its own Kotlin for its consumers. [versions] @@ -7,13 +7,13 @@ android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "34" androidx-activityCompose = "1.12.4" -androidx-lifecycle = "2.10.0" -compose-multiplatform = "1.11.1" +androidx-lifecycle = "2.11.0-beta01" +compose-multiplatform = "1.11.1-ext.2" +compose-plugin = "1.11.1-ext.3" kotlin = "2.4.20-Beta2" kotlinx-coroutines = "1.10.1" -# python-multiplatform's coordinates today (its #70 will unify the plugin's version with the library's). -python-multiplatform = "3.14.7-alpha01" -python-multiplatform-plugin = "3.13.0" +# python-multiplatform's library, KSP and bindings plugin share one version (its #70, #95, #318, #333). +python-multiplatform = "3.15.0-alpha01" # The toolchain plugin, from ../toolchain via mavenLocal (`./gradlew :toolchain:publishToMavenLocal`). toolchain = "1.0.0-alpha" @@ -22,12 +22,13 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } -python-multiplatform = { module = "io.github.thisisthepy:python-multiplatform", version.ref = "python-multiplatform" } +python-multiplatform = { module = "org.thisisthepy:python-multiplatform", version.ref = "python-multiplatform" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +compose-multiplatform = { id = "org.thisisthepy.compose", version.ref = "compose-plugin" } +kotlin-native-desktop = { id = "org.thisisthepy.kotlin.native.desktop", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -python-multiplatform-bindings = { id = "io.github.thisisthepy.python.multiplatform.bindings", version.ref = "python-multiplatform-plugin" } +python-multiplatform-bindings = { id = "org.thisisthepy.python.multiplatform.bindings", version.ref = "python-multiplatform" } toolchain = { id = "org.thisisthepy.python.multiplatform", version.ref = "toolchain" } diff --git a/usage-example/settings.gradle.kts b/usage-example/settings.gradle.kts new file mode 100644 index 0000000..e1f7cfe --- /dev/null +++ b/usage-example/settings.gradle.kts @@ -0,0 +1,61 @@ +// usage-example/ is its own Gradle build (toolchain#22, #62), run with the repository's wrapper: +// ./gradlew -p usage-example …. It consumes python-multiplatform, which is built with Kotlin 2.4, so it uses +// that toolchain (gradle/libs.versions.toml here); the plugin in ../toolchain stays on +// its own Kotlin for its consumers. Both plugins and the +// python-multiplatform library come from mavenLocal: publish them first (see AGENTS.md §16). +pluginManagement { + repositories { + mavenLocal { + content { + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral { + // `org.thisisthepy.compose.*:1.11.1-ext.2` POMs on Maven Central omit Gradle's + // `published-with-gradle-metadata` marker comment, so Gradle must be told to read `.module` + // metadata alongside `.pom` to resolve KMP platform variants and plugin runtime elements. + metadataSources { + gradleMetadata() + mavenPom() + } + } + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal { + content { + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral { + // `org.thisisthepy.compose.*:1.11.1-ext.2` POMs on Maven Central omit Gradle's + // `published-with-gradle-metadata` marker comment, so Gradle must be told to read `.module` + // metadata alongside `.pom` to resolve KMP platform variants and plugin runtime elements. + metadataSources { + gradleMetadata() + mavenPom() + } + } + } +} + +rootProject.name = "usage-example" diff --git a/sample/src/androidMain/AndroidManifest.xml b/usage-example/src/androidMain/AndroidManifest.xml similarity index 100% rename from sample/src/androidMain/AndroidManifest.xml rename to usage-example/src/androidMain/AndroidManifest.xml diff --git a/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainActivity.kt b/usage-example/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainActivity.kt similarity index 100% rename from sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainActivity.kt rename to usage-example/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainActivity.kt diff --git a/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.android.kt b/usage-example/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.android.kt similarity index 100% rename from sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.android.kt rename to usage-example/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.android.kt diff --git a/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.android.kt b/usage-example/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.android.kt similarity index 100% rename from sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.android.kt rename to usage-example/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.android.kt diff --git a/sample/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/usage-example/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from sample/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml rename to usage-example/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml diff --git a/sample/src/androidMain/res/drawable/ic_launcher_background.xml b/usage-example/src/androidMain/res/drawable/ic_launcher_background.xml similarity index 100% rename from sample/src/androidMain/res/drawable/ic_launcher_background.xml rename to usage-example/src/androidMain/res/drawable/ic_launcher_background.xml diff --git a/sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/usage-example/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml rename to usage-example/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/usage-example/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to usage-example/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/sample/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/usage-example/src/androidMain/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from sample/src/androidMain/res/mipmap-hdpi/ic_launcher.png rename to usage-example/src/androidMain/res/mipmap-hdpi/ic_launcher.png diff --git a/sample/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/usage-example/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from sample/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png rename to usage-example/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png diff --git a/sample/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/usage-example/src/androidMain/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from sample/src/androidMain/res/mipmap-mdpi/ic_launcher.png rename to usage-example/src/androidMain/res/mipmap-mdpi/ic_launcher.png diff --git a/sample/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/usage-example/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from sample/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png rename to usage-example/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png diff --git a/sample/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/usage-example/src/androidMain/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from sample/src/androidMain/res/mipmap-xhdpi/ic_launcher.png rename to usage-example/src/androidMain/res/mipmap-xhdpi/ic_launcher.png diff --git a/sample/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/usage-example/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from sample/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png rename to usage-example/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/usage-example/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png rename to usage-example/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png diff --git a/sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/usage-example/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png rename to usage-example/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/usage-example/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png rename to usage-example/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/usage-example/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png rename to usage-example/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/sample/src/androidMain/res/values/strings.xml b/usage-example/src/androidMain/res/values/strings.xml similarity index 100% rename from sample/src/androidMain/res/values/strings.xml rename to usage-example/src/androidMain/res/values/strings.xml diff --git a/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/App.kt b/usage-example/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/App.kt similarity index 100% rename from sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/App.kt rename to usage-example/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/App.kt diff --git a/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Greeting.kt b/usage-example/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Greeting.kt similarity index 100% rename from sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Greeting.kt rename to usage-example/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Greeting.kt diff --git a/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.kt b/usage-example/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.kt similarity index 100% rename from sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.kt rename to usage-example/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.kt diff --git a/usage-example/src/commonMain/python/pyproject.toml b/usage-example/src/commonMain/python/pyproject.toml new file mode 100644 index 0000000..f78ed03 --- /dev/null +++ b/usage-example/src/commonMain/python/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "usage-example-python" +version = "0.1.0" +description = "The Python half of the usage-example app, bundled by pypackpack and staged by the toolchain plugin." +requires-python = ">=3.13" diff --git a/sample/src/commonMain/python/src/main/example_py/__init__.py b/usage-example/src/commonMain/python/src/main/example_py/__init__.py similarity index 80% rename from sample/src/commonMain/python/src/main/example_py/__init__.py rename to usage-example/src/commonMain/python/src/main/example_py/__init__.py index 2b57ef6..330c213 100644 --- a/sample/src/commonMain/python/src/main/example_py/__init__.py +++ b/usage-example/src/commonMain/python/src/main/example_py/__init__.py @@ -8,4 +8,4 @@ def greeting() -> str: - return f"Hello from the sample's Python payload {VERSION}" + return f"Hello from usage-example's Python payload {VERSION}" diff --git a/sample/src/commonMain/python/src/main/example_py/data/table.json b/usage-example/src/commonMain/python/src/main/example_py/data/table.json similarity index 100% rename from sample/src/commonMain/python/src/main/example_py/data/table.json rename to usage-example/src/commonMain/python/src/main/example_py/data/table.json diff --git a/sample/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.jvm.kt b/usage-example/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.jvm.kt similarity index 100% rename from sample/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.jvm.kt rename to usage-example/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.jvm.kt diff --git a/sample/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.kt b/usage-example/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.kt similarity index 100% rename from sample/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.kt rename to usage-example/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.kt diff --git a/sample/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt b/usage-example/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt similarity index 100% rename from sample/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt rename to usage-example/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt diff --git a/sample/src/iosMain/Configuration/Config.xcconfig b/usage-example/src/iosMain/Configuration/Config.xcconfig similarity index 100% rename from sample/src/iosMain/Configuration/Config.xcconfig rename to usage-example/src/iosMain/Configuration/Config.xcconfig diff --git a/sample/src/iosMain/app.xcodeproj/project.pbxproj b/usage-example/src/iosMain/app.xcodeproj/project.pbxproj similarity index 99% rename from sample/src/iosMain/app.xcodeproj/project.pbxproj rename to usage-example/src/iosMain/app.xcodeproj/project.pbxproj index 7217dbf..1e4bfea 100644 --- a/sample/src/iosMain/app.xcodeproj/project.pbxproj +++ b/usage-example/src/iosMain/app.xcodeproj/project.pbxproj @@ -176,7 +176,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/../../..\"\n./gradlew -p sample embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/../../..\"\n./gradlew -p usage-example embedAndSignAppleFrameworkForXcode\n"; }; A7C3F1E20B5D4C6E8F901234 /* Install Python */ = { isa = PBXShellScriptBuildPhase; diff --git a/sample/src/iosMain/install-python-phase.sh b/usage-example/src/iosMain/install-python-phase.sh similarity index 70% rename from sample/src/iosMain/install-python-phase.sh rename to usage-example/src/iosMain/install-python-phase.sh index 7772614..d999823 100755 --- a/sample/src/iosMain/install-python-phase.sh +++ b/usage-example/src/iosMain/install-python-phase.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Xcode Run Script phase for the sample's iOS app (toolchain#22): put this app's python/ payload, +# Xcode Run Script phase for usage-example's iOS app (toolchain#22): put this app's python/ payload, # CPython's standard library and Python.framework into the .app. # # The payload comes from this build: stagePythonBundleIosForXcode prints PYTHON_PAYLOAD_DIR=, which is @@ -15,17 +15,17 @@ set -euo pipefail # TODO(python-multiplatform#90): no checkout once the plugin provides these to consumers. : "${PYTHON_MULTIPLATFORM_DIR:?set PYTHON_MULTIPLATFORM_DIR to a python-multiplatform checkout (interim, python-multiplatform#90)}" -# 1. This app's payload, from the sample's own build. Captured, parsed, then checked, so a failing +# 1. This app's payload, from usage-example's own build. Captured, parsed, then checked, so a failing # Gradle can never hand rsync an empty source. cd "$SRCROOT/../../.." -out=$(./gradlew -q -p sample stagePythonBundleIosForXcode) +out=$(./gradlew -q -p usage-example stagePythonBundleIosForXcode) payload=$(printf '%s\n' "$out" | sed -n 's/^PYTHON_PAYLOAD_DIR=//p') [ -n "$payload" ] && [ -d "$payload" ] || { echo "error: no Python payload dir from stagePythonBundleIosForXcode" >&2; exit 1; } export PYTHON_PAYLOAD_DIR="$payload" # 2. Python.framework (libpython) into /Frameworks, for the simulator or the device slice. # TODO(python-multiplatform#90): from the plugin's staged Python.xcframework, not the checkout's build/. -xcframework="$PYTHON_MULTIPLATFORM_DIR/python-multiplatform/build/python-standalone/extracted/${PYTHON_VERSION:-3.14.7}/ios/Python.xcframework" +xcframework="$PYTHON_MULTIPLATFORM_DIR/python-multiplatform/build/python-standalone/extracted/${PYTHON_VERSION:-3.15.0}/ios/Python.xcframework" case "${EFFECTIVE_PLATFORM_NAME:-}" in -iphonesimulator) slice="ios-arm64_x86_64-simulator" ;; -iphoneos) slice="ios-arm64" ;; @@ -39,7 +39,15 @@ if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]; then codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" --timestamp=none "$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH/Python.framework" fi -# 3. The stdlib prefix and the payload, by python-multiplatform's own phase script. -# TODO(python-multiplatform#90): run from this project's Gradle root, not the checkout. -cd "$PYTHON_MULTIPLATFORM_DIR" -/bin/bash tools/xcode/install-python.sh +# 3. The stdlib prefix and the payload, by python-multiplatform's phase script. Since +# python-multiplatform#320 that script only runs as the copy installPythonXcodeScript writes to +# build/python-multiplatform/xcode/, with this project's stageIosPythonHomeForXcode baked in. +# usage-example is its own build under the toolchain wrapper, so the script's Gradle calls go +# through a shim that adds -p usage-example. +./gradlew -q -p usage-example installPythonXcodeScript +: "${DERIVED_FILE_DIR:?Xcode must set DERIVED_FILE_DIR}" +mkdir -p "$DERIVED_FILE_DIR" +shim="$DERIVED_FILE_DIR/gradlew-usage-example" +printf '#!/bin/sh\nexec "%s/gradlew" -p "%s/usage-example" "$@"\n' "$PWD" "$PWD" > "$shim" +chmod +x "$shim" +GRADLEW="$shim" /bin/bash usage-example/build/python-multiplatform/xcode/install-python.sh diff --git a/sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainViewController.kt b/usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainViewController.kt similarity index 100% rename from sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainViewController.kt rename to usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainViewController.kt diff --git a/sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.ios.kt b/usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.ios.kt similarity index 100% rename from sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.ios.kt rename to usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.ios.kt diff --git a/usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt b/usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt new file mode 100644 index 0000000..e895dda --- /dev/null +++ b/usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt @@ -0,0 +1,62 @@ +package org.thisisthepy.python.multiplatform.toolchain.example + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSProcessInfo +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.NSUserDomainMask +import platform.Foundation.create +import platform.Foundation.writeToFile +import platform.posix.exit +import python.multiplatform.ffi.Python3 + +/** + * `xcrun simctl launch --python-smoke` runs [runPythonSmokeIfRequested] and exits + * before any UI, so CI can check the installed app's Python on a simulator (toolchain#22). + * + * It checks both halves of the .app: python-multiplatform's (the stdlib it copies into + * `python-multiplatform-home/` and Python.framework) and toolchain's (`python/`: the app's `example_py` + * and the dependency `iniconfig`). + * + * The report goes to stdout and to [PYTHON_SMOKE_FILE] in the app's Documents directory. CI reads the + * file: `--console-pty` lost the output when the app had already exited by the time it attached, which is + * how the job failed three times with nothing printed (toolchain#92). + */ +const val PYTHON_SMOKE_ARGUMENT = "--python-smoke" + +/** The report's file name in the app's Documents directory (`simctl get_app_container data`). */ +const val PYTHON_SMOKE_FILE = "python-smoke.txt" + +private const val PY_EVAL_INPUT = 258 + +fun runPythonSmokeIfRequested() { + if (PYTHON_SMOKE_ARGUMENT !in NSProcessInfo.processInfo.arguments.map { it.toString() }) return + val (code, report) = + try { + Python3.initialize() + Python3.exec("import os, sys, example_py, iniconfig") + val globals = Python3.import("__main__").dict + val result = + Python3.eval( + "f'{example_py.VERSION} {iniconfig.__name__} {sys.version.split()[0]}\\nPYTHON_SMOKE_STDLIB {os.path.realpath(os.__file__)}'", + PY_EVAL_INPUT, + globals, + globals, + ) + 0 to "PYTHON_SMOKE_OK $result" + } catch (t: Throwable) { + 1 to "PYTHON_SMOKE_FAILED: $t" + } + println(report) + writeSmokeReport("$report\n") + exit(code) +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private fun writeSmokeReport(report: String) { + val documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstOrNull() as? String ?: return + NSString.create(string = report).writeToFile("$documents/$PYTHON_SMOKE_FILE", atomically = true, encoding = NSUTF8StringEncoding, error = null) +} diff --git a/sample/src/iosMain/swift/Assets.xcassets/AccentColor.colorset/Contents.json b/usage-example/src/iosMain/swift/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from sample/src/iosMain/swift/Assets.xcassets/AccentColor.colorset/Contents.json rename to usage-example/src/iosMain/swift/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/sample/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/Contents.json b/usage-example/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from sample/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/Contents.json rename to usage-example/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/sample/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/usage-example/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png similarity index 100% rename from sample/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png rename to usage-example/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png diff --git a/sample/src/iosMain/swift/Assets.xcassets/Contents.json b/usage-example/src/iosMain/swift/Assets.xcassets/Contents.json similarity index 100% rename from sample/src/iosMain/swift/Assets.xcassets/Contents.json rename to usage-example/src/iosMain/swift/Assets.xcassets/Contents.json diff --git a/sample/src/iosMain/swift/ContentView.swift b/usage-example/src/iosMain/swift/ContentView.swift similarity index 100% rename from sample/src/iosMain/swift/ContentView.swift rename to usage-example/src/iosMain/swift/ContentView.swift diff --git a/sample/src/iosMain/swift/Info.plist b/usage-example/src/iosMain/swift/Info.plist similarity index 100% rename from sample/src/iosMain/swift/Info.plist rename to usage-example/src/iosMain/swift/Info.plist diff --git a/sample/src/iosMain/swift/Preview Content/Preview Assets.xcassets/Contents.json b/usage-example/src/iosMain/swift/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from sample/src/iosMain/swift/Preview Content/Preview Assets.xcassets/Contents.json rename to usage-example/src/iosMain/swift/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/sample/src/iosMain/swift/app.swift b/usage-example/src/iosMain/swift/app.swift similarity index 100% rename from sample/src/iosMain/swift/app.swift rename to usage-example/src/iosMain/swift/app.swift diff --git a/sample/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.wasmJs.kt b/usage-example/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.wasmJs.kt similarity index 100% rename from sample/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.wasmJs.kt rename to usage-example/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.wasmJs.kt diff --git a/sample/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt b/usage-example/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt similarity index 100% rename from sample/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt rename to usage-example/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt diff --git a/sample/src/wasmJsMain/resources/index.html b/usage-example/src/wasmJsMain/resources/index.html similarity index 100% rename from sample/src/wasmJsMain/resources/index.html rename to usage-example/src/wasmJsMain/resources/index.html diff --git a/sample/src/wasmJsMain/resources/styles.css b/usage-example/src/wasmJsMain/resources/styles.css similarity index 100% rename from sample/src/wasmJsMain/resources/styles.css rename to usage-example/src/wasmJsMain/resources/styles.css