From 77bc34d1d6d3d8d20531d7eb0b7b6ed5c25b6213 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:53:44 +0900 Subject: [PATCH 01/20] Refactor: Restore usage-example/ (not sample/) User decision (via the lead): the user created usage-example/ (2b91383, 2024-08-20), and the rename to sample/ in #62 came only from the lead's proposal table, never approved. The folder and every reference go back: settings, CI (-p usage-example, working-directory, artifact and path filters), the Xcode phases, the build's rootProject.name and packaging fileName, the Python package's name and greeting, KDoc, AGENTS, PROJECT, INTENT, SPEC, README en/ko and the guide. The Python sources stay at usage-example/src/commonMain/python (the app-template layout), and the build still runs with the root wrapper (./gradlew -p usage-example). Closes #80 --- .github/workflows/test-ios.yml | 12 +++++------ .github/workflows/test.yml | 20 +++++++++--------- AGENTS.md | 20 +++++++++--------- PROJECT.md | 18 ++++++++-------- README.md | 10 ++++----- docs/INTENT.md | 2 +- docs/SPEC.md | 12 +++++------ docs/guide/getting-started.html | 12 +++++------ docs/guide/guide-staging.html | 2 +- docs/locale/README_ko.md | 10 ++++----- sample/src/commonMain/python/pyproject.toml | 5 ----- settings.gradle.kts | 2 +- toolchain/build.gradle.kts | 6 +++--- .../multiplatform/toolchain/PythonPlugin.kt | 16 +++++++------- .../bundle/AssemblePythonPackageTask.kt | 2 +- .../bundle/BuildPythonArtifactTask.kt | 2 +- .../lang/python/InstallDependenciesTask.kt | 4 ++-- .../toolchain/PythonPluginApplyTest.kt | 2 +- .../toolchain/PythonPluginAttachmentTest.kt | 2 +- .../toolchain/PythonPluginBuildLevelTest.kt | 2 +- .../toolchain/PythonPluginSourceSetTest.kt | 2 +- .../toolchain/PythonPluginVariantGraphTest.kt | 2 +- .../bundle/StagePythonBundleTaskTest.kt | 2 +- .../typedpython/TypedpythonCheckTaskTest.kt | 6 +++--- {sample => usage-example}/build.gradle.kts | 6 +++--- {sample => usage-example}/gradle.properties | 0 .../gradle/libs.versions.toml | 2 +- {sample => usage-example}/settings.gradle.kts | 6 +++--- .../src/androidMain/AndroidManifest.xml | 0 .../toolchain/example/MainActivity.kt | 0 .../toolchain/example/Platform.android.kt | 0 .../toolchain/example/PythonSmoke.android.kt | 0 .../drawable-v24/ic_launcher_foreground.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../res/mipmap-hdpi/ic_launcher.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../res/mipmap-xxxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../src/androidMain/res/values/strings.xml | 0 .../multiplatform/toolchain/example/App.kt | 0 .../toolchain/example/Greeting.kt | 0 .../toolchain/example/Platform.kt | 0 .../src/commonMain/python/pyproject.toml | 5 +++++ .../python/src/main/example_py/__init__.py | 2 +- .../src/main/example_py/data/table.json | 0 .../toolchain/example/Platform.jvm.kt | 0 .../toolchain/example/PythonSmoke.kt | 0 .../multiplatform/toolchain/example/main.kt | 0 .../src/iosMain/Configuration/Config.xcconfig | 0 .../src/iosMain/app.xcodeproj/project.pbxproj | 2 +- .../src/iosMain/install-python-phase.sh | 6 +++--- .../toolchain/example/MainViewController.kt | 0 .../toolchain/example/Platform.ios.kt | 0 .../toolchain/example/PythonSmoke.ios.kt | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../AppIcon.appiconset/app-icon-1024.png | Bin .../swift/Assets.xcassets/Contents.json | 0 .../src/iosMain/swift/ContentView.swift | 0 .../src/iosMain/swift/Info.plist | 0 .../Preview Assets.xcassets/Contents.json | 0 .../src/iosMain/swift/app.swift | 0 .../toolchain/example/Platform.wasmJs.kt | 0 .../multiplatform/toolchain/example/main.kt | 0 .../src/wasmJsMain/resources/index.html | 0 .../src/wasmJsMain/resources/styles.css | 0 74 files changed, 101 insertions(+), 101 deletions(-) delete mode 100644 sample/src/commonMain/python/pyproject.toml rename {sample => usage-example}/build.gradle.kts (96%) rename {sample => usage-example}/gradle.properties (100%) rename {sample => usage-example}/gradle/libs.versions.toml (95%) rename {sample => usage-example}/settings.gradle.kts (79%) rename {sample => usage-example}/src/androidMain/AndroidManifest.xml (100%) rename {sample => usage-example}/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainActivity.kt (100%) rename {sample => usage-example}/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.android.kt (100%) rename {sample => usage-example}/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.android.kt (100%) rename {sample => usage-example}/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml (100%) rename {sample => usage-example}/src/androidMain/res/drawable/ic_launcher_background.xml (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-hdpi/ic_launcher.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-mdpi/ic_launcher.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-xhdpi/ic_launcher.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename {sample => usage-example}/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename {sample => usage-example}/src/androidMain/res/values/strings.xml (100%) rename {sample => usage-example}/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/App.kt (100%) rename {sample => usage-example}/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Greeting.kt (100%) rename {sample => usage-example}/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.kt (100%) create mode 100644 usage-example/src/commonMain/python/pyproject.toml rename {sample => usage-example}/src/commonMain/python/src/main/example_py/__init__.py (80%) rename {sample => usage-example}/src/commonMain/python/src/main/example_py/data/table.json (100%) rename {sample => usage-example}/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.jvm.kt (100%) rename {sample => usage-example}/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.kt (100%) rename {sample => usage-example}/src/desktopMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt (100%) rename {sample => usage-example}/src/iosMain/Configuration/Config.xcconfig (100%) rename {sample => usage-example}/src/iosMain/app.xcodeproj/project.pbxproj (99%) rename {sample => usage-example}/src/iosMain/install-python-phase.sh (91%) rename {sample => usage-example}/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/MainViewController.kt (100%) rename {sample => usage-example}/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.ios.kt (100%) rename {sample => usage-example}/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt (100%) rename {sample => usage-example}/src/iosMain/swift/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {sample => usage-example}/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {sample => usage-example}/src/iosMain/swift/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png (100%) rename {sample => usage-example}/src/iosMain/swift/Assets.xcassets/Contents.json (100%) rename {sample => usage-example}/src/iosMain/swift/ContentView.swift (100%) rename {sample => usage-example}/src/iosMain/swift/Info.plist (100%) rename {sample => usage-example}/src/iosMain/swift/Preview Content/Preview Assets.xcassets/Contents.json (100%) rename {sample => usage-example}/src/iosMain/swift/app.swift (100%) rename {sample => usage-example}/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/Platform.wasmJs.kt (100%) rename {sample => usage-example}/src/wasmJsMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/main.kt (100%) rename {sample => usage-example}/src/wasmJsMain/resources/index.html (100%) rename {sample => usage-example}/src/wasmJsMain/resources/styles.css (100%) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 82a9ca6..0162026 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -1,6 +1,6 @@ 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. @@ -9,9 +9,9 @@ on: branches: [develop] pull_request: paths: - - "sample/src/iosMain/**" - - "sample/build.gradle.kts" - - "sample/gradle/**" + - "usage-example/src/iosMain/**" + - "usage-example/build.gradle.kts" + - "usage-example/gradle/**" - "toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/**" - ".github/workflows/test-ios.yml" workflow_dispatch: @@ -48,7 +48,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: | @@ -68,7 +68,7 @@ jobs: slice="$pmd/python-multiplatform/build/python-standalone/extracted/3.14.7/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" \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33cd036..5c09c7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,8 +6,8 @@ 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: push: @@ -94,7 +94,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 @@ -120,9 +120,9 @@ jobs: python-version: "3.14" - 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 + # usage-example/gradle/libs.versions.toml (library 3.14.7-alpha01, plugin 3.13.0 until # python-multiplatform#70). - name: Publish packpack, the toolchain plugin and python-multiplatform to mavenLocal run: | @@ -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; } @@ -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) @@ -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/AGENTS.md b/AGENTS.md index a609f58..dc6db5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ there. Work belongs inside an existing module or directory: sources under `src/< CI-only scripts under `.github/scripts/`, temporary files under the git-ignored `.tmp/`. If you think a new top-level entry is needed, propose it (what, why, which alternatives inside existing directories you ruled out) and wait for approval. This was added after unapproved root folders -(`ksp-fixtures/`, `tools/`, `kotlin-js-store/`, `iosApp/`, `sample/python`) had to be dismantled. +(`ksp-fixtures/`, `tools/`, `kotlin-js-store/`, `iosApp/`, `usage-example/python`) had to be dismantled. ## 3. Worktrees link large artefacts instead of copying them @@ -170,8 +170,8 @@ GitHub issues `thisisthepy/toolchain#2` (plugin checklist) and `#1` (`tcl`), it - Never edit, move, rename or `git add` it. - When the code's DSL disagrees with it, the file wins, and the disagreement is recorded in `docs/SPEC.md` and `docs/INTENT.md` §4 until the maintainer decides. Do not "fix" either side on your own. -- `sample/` is the **executable subset** of that file: the part the plugin actually reads. - When a DSL property becomes live, add it to `sample/build.gradle.kts`, so there is a build +- `usage-example/` is the **executable subset** of that file: the part the plugin actually reads. + When a DSL property becomes live, add it to `usage-example/build.gradle.kts`, so there is a build that fails if the wiring breaks. ## 12. Layout @@ -179,7 +179,7 @@ GitHub issues `thisisthepy/toolchain#2` (plugin checklist) and `#1` (`tcl`), it | Module | What it is | |---|---| | `toolchain/` (`:toolchain`) | The Gradle plugin (id `org.thisisthepy.python.multiplatform`, class `PythonPlugin`; source set `main`). DSL in `dsl/`, tasks in `bundle/`, `dependency/`, `hotreload/`. The same module holds toolchain-lite, the `tcl install ` CLI, in its own source sets (`src/cliMain`, `src/cliTest`; #78): they depend on packpack and coroutines only, never on `main`, and the plugin's publication carries none of them. | -| `sample/` | **Its own Gradle build** (own settings and catalog; toolchain#22): a Compose Multiplatform app applying this plugin and python-multiplatform, on python-multiplatform's Kotlin/Compose/AGP. Run it with this repository's wrapper: `./gradlew -p sample …`. Its Python package is `sample/src/commonMain/python/`. | +| `usage-example/` | **Its own Gradle build** (own settings and catalog; toolchain#22): a Compose Multiplatform app applying this plugin and python-multiplatform, on python-multiplatform's Kotlin/Compose/AGP. Run it with this repository's wrapper: `./gradlew -p usage-example …`. Its Python package is `usage-example/src/commonMain/python/`. | | `.github/scripts/release/` | The develop → release → main sync (rule 4). | `pyproject.toml` at the root is toolchain-lite, the PyPI distribution of `tcl` (#69): one platform @@ -188,8 +188,8 @@ wheel per OS carrying the native `tcl` binary, plus the launcher package `toolch wheels; its version must equal `toolchain/build.gradle.kts`'s `tclVersion`, and `docs/pypi/README.md` is its PyPI page. Keep the launcher a launcher: the work stays in Kotlin. -Nothing else belongs at the root (#62): no IDE directories, no lock-file directories (the sample's wasm -yarn lock is `sample/gradle/wasm-yarn.lock`), no copied wrappers. The pre-#62 layout is the tag +Nothing else belongs at the root (#62): no IDE directories, no lock-file directories (usage-example's wasm +yarn lock is `usage-example/gradle/wasm-yarn.lock`), no copied wrappers. The pre-#62 layout is the tag `archive/pre-restructure`. ## 13. pypackpack owns the work; toolchain owns the vocabulary @@ -233,10 +233,10 @@ history removing. classpath broke every consumer's wasm target once. - `-Xskip-metadata-version-check` exists because `packpack` is built with a newer Kotlin than this build. Bumping Kotlin build-wide is a decision for the maintainer, not a fix. -- `sample` resolves the plugin **by Maven coordinate from `mavenLocal()`**, not through +- `usage-example` resolves the plugin **by Maven coordinate from `mavenLocal()`**, not through `includeBuild`. After changing `:toolchain`, run `:toolchain:publishToMavenLocal` before building - `sample`, or you are testing the previous plugin. -- `sample` is a separate build on python-multiplatform's toolchain (Kotlin 2.4.20-Beta2, Compose + `usage-example`, or you are testing the previous plugin. +- `usage-example` is a separate build on python-multiplatform's toolchain (Kotlin 2.4.20-Beta2, Compose 1.11.1, AGP 8.10.1, Gradle 8.11.1), because a Kotlin 2.1 compiler cannot read python-multiplatform's klibs. The plugin itself stays on this build's Kotlin for its consumers; do not bump it to match. @@ -244,7 +244,7 @@ history removing. Prerequisites: `org.thisisthepy.python.multiplatform:packpack:0.1.0` in `~/.m2` (published from the `pypackpack` repository), `uv` on `PATH`, and network access: `InstallDependenciesTaskTest` and -`InstallerTest` run a real `uv add` against PyPI. `sample` additionally needs an Android SDK +`InstallerTest` run a real `uv add` against PyPI. `usage-example` additionally needs an Android SDK (`ANDROID_HOME`) and python-multiplatform in `~/.m2`: from a python-multiplatform checkout, `./gradlew publishAllToMavenLocal` (library `3.14.7-alpha01`, bindings plugin `3.13.0` until its #70). diff --git a/PROJECT.md b/PROJECT.md index 6ad2bc2..34d4d56 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -10,7 +10,7 @@ Kotlin Multiplatform 앱의 Python 부분을 Gradle `python { }` 블록으로 | 목표 DSL (사용자 작성) | 메인 체크아웃 루트의 `(플러그인예시)build.gradle.kts`: 추적되지 않는 파일, worktree 에는 없다 | | 플러그인 체크리스트 | GitHub 이슈 `thisisthepy/toolchain#2` | | toolchain-lite | GitHub 이슈 `thisisthepy/toolchain#1` (`tcl install pythonx-compose`) | -| 실행 가능한 부분집합 | `sample/build.gradle.kts` | +| 실행 가능한 부분집합 | `usage-example/build.gradle.kts` | | 의도 / 계약 | `docs/INTENT.md` / `docs/SPEC.md` | ## 현재 상태 (2026-10-03, 코드와 테스트를 읽고 판정) @@ -86,7 +86,7 @@ Python 코드와 의존성이 실려 빌드·실행되는 상태. 기한을 맞 | 마일스톤 | 기한 | 범위 | 완료 기준 | 근거 | |---|---|---|---|---| -| M1 DSL = 예시 파일 | 10-17 | 플랫폼(#7 완료), pip(#9), `compileSdk` 상수(#10), `defaultConfig` 버전(#11), `buildFeatures`(#12), `projectFlavors`(#13), 플러그인 적용·zip·jar/APK 테스트(#14) | 예시 파일의 모든 속성이 컴파일되고, 읽히거나 좁게 거부됨. `sample` 이 그 모양으로 빌드됨 | 이슈 7개, 외부 의존 없음 | +| M1 DSL = 예시 파일 | 10-17 | 플랫폼(#7 완료), pip(#9), `compileSdk` 상수(#10), `defaultConfig` 버전(#11), `buildFeatures`(#12), `projectFlavors`(#13), 플러그인 적용·zip·jar/APK 테스트(#14) | 예시 파일의 모든 속성이 컴파일되고, 읽히거나 좁게 거부됨. `usage-example` 이 그 모양으로 빌드됨 | 이슈 7개, 외부 의존 없음 | | M2 실제 페이로드 | 11-07 | `bytecode`(#15), 소스셋·타깃별 의존성(#16), `embedLevel`(#17), ~~`compileSdk` 로 인터프리터 선택(#18)~~ → python-multiplatform `pythonVersion` 대조(#42), `KLIBDEPENS`(#19) | 각 변형의 번들에 그 타깃의 wheel 이 들어가고, bytecode 변형은 `.pyc` 를 실음 | pypackpack M2(타깃별 실제 설치)에 의존 | | M3 쓸 수 있는 수준 | 11-30 | iOS 페이로드 연결(#20), TypedPython 검사 태스크(#21), 세 플랫폼 실행(#22) | 예시 앱이 Android 에뮬레이터·iOS 시뮬레이터·데스크톱에서 Python 과 의존성을 실행 | python-multiplatform 런타임 로딩에 의존 | @@ -99,7 +99,7 @@ Python 코드와 의존성이 실려 빌드·실행되는 상태. 기한을 맞 ``` toolchain/ Gradle 플러그인 (dsl/, bundle/, dependency/, hotreload/) toolchain/src/cliMain, src/cliTest toolchain-lite CLI `tcl` (`:toolchain` 의 별도 소스셋, #78) -sample/ 별도 Gradle 빌드: 플러그인과 python-multiplatform 을 적용한 Compose Multiplatform 앱 + python/ +usage-example/ 별도 Gradle 빌드: 플러그인과 python-multiplatform 을 적용한 Compose Multiplatform 앱 + python/ (python-multiplatform 의 Kotlin 2.4.20-Beta2 / Compose 1.11.1 / AGP 8.10.1, 자체 wrapper) docs/ INTENT.md, SPEC.md, locale/, guide/ (GitHub Pages) ``` @@ -111,19 +111,19 @@ docs/ INTENT.md, SPEC.md, locale/, guide/ (GitHub Pages) ```bash ./gradlew :toolchain:test --rerun --console=plain > .tmp/toolchain-test.log 2>&1; echo "EXIT=$?" ./gradlew :toolchain:cliTest --rerun --console=plain > .tmp/tcl-test.log 2>&1; echo "EXIT=$?" -./gradlew :toolchain:publishToMavenLocal # sample 빌드 전에 반드시 +./gradlew :toolchain:publishToMavenLocal # usage-example 빌드 전에 반드시 python3 docs/guide/check_guide.py # 가이드 검사 -# sample 은 별도 빌드: python-multiplatform 체크아웃에서 ./gradlew publishAllToMavenLocal 후 -./gradlew -p sample packagePython -./gradlew -p sample createDistributable # 데스크톱 앱; 실행 시 --python-smoke 로 헤드리스 확인 +# usage-example 은 별도 빌드: python-multiplatform 체크아웃에서 ./gradlew publishAllToMavenLocal 후 +./gradlew -p usage-example packagePython +./gradlew -p usage-example createDistributable # 데스크톱 앱; 실행 시 --python-smoke 로 헤드리스 확인 ``` Python 테스트는 없다. 루트 `pyproject.toml` 이 가리키는 Python 패키지도 존재하지 않는다. ## 결정 사항 -- **sample 은 별도 빌드** (#22, 리드 결정 2026-10-03): python-multiplatform 은 Kotlin 2.4.20-Beta2 로 빌드되고 Kotlin 2.1 - 컴파일러는 그 klib 을 읽지 못한다. 소비자인 sample 이 의존성의 툴체인에 맞추고, 플러그인 자체는 소비자를 위해 +- **usage-example 은 별도 빌드** (#22, 리드 결정 2026-10-03): python-multiplatform 은 Kotlin 2.4.20-Beta2 로 빌드되고 Kotlin 2.1 + 컴파일러는 그 klib 을 읽지 못한다. 소비자인 usage-example 이 의존성의 툴체인에 맞추고, 플러그인 자체는 소비자를 위해 지금의 Kotlin 에 둔다. - **위임**: 의존성은 `BackendInterface.create(BackendType.UV)`, 번들은 diff --git a/README.md b/README.md index 72139f8..4834520 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. diff --git a/docs/INTENT.md b/docs/INTENT.md index bee2a86..19c61e4 100644 --- a/docs/INTENT.md +++ b/docs/INTENT.md @@ -14,7 +14,7 @@ The intent is derived from what the maintainer wrote by hand, in this order of a | GitHub issue [`toolchain#2`](https://github.com/thisisthepy/toolchain/issues/2), "Kotlin Gradle Plugin and Build Tools" | The plugin checklist: Python version setup, target platforms, hot reload / code push, source sets, compile levels, build automation. | | GitHub issue [`toolchain#1`](https://github.com/thisisthepy/toolchain/issues/1), "Toolchain-lite for python-only users" | A simplified CLI for Python users: `tcl install pythonx-compose`. | | `pyproject.toml` / the original `README.md` | "Python Multiplatform Build Plugin/Tool with Kotlin Multiplatform Mobile". | -| `sample/` | The executable subset of the target DSL that the plugin reads today. | +| `usage-example/` | The executable subset of the target DSL that the plugin reads today. | Anything below that is an inference rather than a statement from those sources is marked `> Inferred: confirm with the maintainer.` diff --git a/docs/SPEC.md b/docs/SPEC.md index a39cbc0..b09ffc6 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -34,7 +34,7 @@ plugin id to a ProjectBuilder project and checks the extension, every task name (`installPythonDependencies`, `buildPython`, `packagePython`, `stagePythonBundle` and its `Android`/`Ios`/`Desktop` tasks; `hotReloadPython` and `codePushPython` after evaluation), the `python` group, and the `packagePython → buildPython → installPythonDependencies` chain. Resolving -the plugin from `mavenLocal()` is still exercised only by building `sample`. +the plugin from `mavenLocal()` is still exercised only by building `usage-example`. ### 1.2 `compileSdk`: the Python version `compileSdk` accepts `X.Y`, `X.Y.Z`, `X.Y.Z-alpha[N]` or `X.Y.Z-rc[N]` and classifies it into @@ -53,7 +53,7 @@ automatically (INTENT §4.1), which is not available yet: the reason fails `buil when there is a package to bundle), not the configuration. **Status: implemented**. `plugin/dsl/PythonSdk.kt`; `ptest/dsl/PythonSdkTest.kt`, -`ptest/bundle/BuildPythonSdkRejectionTest.kt`; `sample` uses `compileSdk = PY3_14_7`. +`ptest/bundle/BuildPythonSdkRejectionTest.kt`; `usage-example` uses `compileSdk = PY3_14_7`. **What `compileSdk` selects (#42).** `compileSdk` selects the Python version of the wheels that go into `python/`: it is the `--python-version` of every per-target install (§1.10, #16). It does @@ -284,7 +284,7 @@ family and flavor, options, rejection), `ptest/PythonPluginTargetDependenciesTes install directories, `libDirs` and dependencies on an applied plugin), `ptest/dependency/lang/python/InstallDependenciesTaskTest.kt` and `ptest/dependency/lang/python/InstallTargetDependenciesTaskTest.kt` (a real install of `six` for -`aarch64-linux-android`; both need `uv` and network). CI's consumer job checks that sample's +`aarch64-linux-android`; both need `uv` and network). CI's consumer job checks that usage-example's `iniconfig` is in the bundle and the zip. → *Known limits: without `compileSdk` no `python-version` is passed and uv uses the interpreter it finds. `installPythonDependencies` still `uv add`s every source set for the host, so a package with @@ -386,7 +386,7 @@ Hand-off to the platform's packaging step: `build/pythonStaging/ios/python/` is missing or empty (no package configured). It never copies into the `.app`. With python-multiplatform, its `tools/xcode/install-python.sh` is the single phase that copies `python/` (and the stdlib): it takes `PYTHON_PAYLOAD_DIR` (or `PYTHON_PAYLOAD_TASK` within its - own Gradle root); the sample's `sample/src/iosMain/install-python-phase.sh` computes the directory + own Gradle root); usage-example's `usage-example/src/iosMain/install-python-phase.sh` computes the directory with this task and passes it. Exactly one Xcode phase may copy `python/`. Wiring by hand without python-multiplatform: the guide's staging page has the safe phase snippet (capture, parse, check the directory, then `rsync --delete`); the repository no longer ships it as a script (#62). @@ -520,7 +520,7 @@ commands, skip warning, mode, exit-code interpretation). The TestKit tests in `pyrefly` wheels) and `uv`; without it they are **skipped** with that reason, as on CI. Not covered by a test here: exit 2 end to end (only `interpretTypedpythonResult`), and the install in a *consumer's* classpath: `withPluginClasspath()` bypasses the Kotlin DSL's `kotlin-stdlib` pin; -that was verified by building sample's `buildPython`. +that was verified by building usage-example's `buildPython`. → *Until `typedpython` is on PyPI, a build without `-Ptypedpython.wheelDir` skips the check with a warning.* → *Platform overlays (`src/`) are checked as files but only `src/main` is an import root: **partial**.* @@ -575,7 +575,7 @@ These exist in the code but are not asked for by the example build file or the i 1. **`python { localLibraryPath = "…" }`**: not in the example build file, which uses `sourceSets { commonMain { srcDirs(…) } }`. It overrides `srcDirs` and is the only source root - hot reload reads. `sample` depends on it. + hot reload reads. `usage-example` depends on it. 2. **`-Ppython.buildType=`**: a project property choosing the active build type when no platform variant is declared. The example build file says nothing about selecting a build type. 3. **Hot reload over `adb push` + broadcast.** The example describes an HTTPS `serverHost` with a 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/locale/README_ko.md b/docs/locale/README_ko.md index 8086994..173384a 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` 가 필요합니다. 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/settings.gradle.kts b/settings.gradle.kts index 50894b3..2f98c13 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,5 +32,5 @@ dependencyResolutionManagement { 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..d522430 100644 --- a/toolchain/build.gradle.kts +++ b/toolchain/build.gradle.kts @@ -20,8 +20,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 -- @@ -51,7 +51,7 @@ dependencies { // 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()}") } 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..21365fa 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 @@ -107,7 +107,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 +266,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 @@ -620,7 +620,7 @@ private fun registerStagingTasks(project: Project): Map): List.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/bundle/StagePythonBundleTaskTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTaskTest.kt index 384cdc1..56c2ba9 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 @@ -121,7 +121,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/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/sample/build.gradle.kts b/usage-example/build.gradle.kts similarity index 96% rename from sample/build.gradle.kts rename to usage-example/build.gradle.kts index 34b8c40..9168cdb 100644 --- a/sample/build.gradle.kts +++ b/usage-example/build.gradle.kts @@ -13,14 +13,14 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.multiplatform) 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 @@ -56,7 +56,7 @@ python { } } packaging { - fileName = "sample" + fileName = "usage-example" } // `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). diff --git a/sample/gradle.properties b/usage-example/gradle.properties similarity index 100% rename from sample/gradle.properties rename to usage-example/gradle.properties diff --git a/sample/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml similarity index 95% rename from sample/gradle/libs.versions.toml rename to usage-example/gradle/libs.versions.toml index 25d1651..5751dec 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] diff --git a/sample/settings.gradle.kts b/usage-example/settings.gradle.kts similarity index 79% rename from sample/settings.gradle.kts rename to usage-example/settings.gradle.kts index 0a3439c..1284f17 100644 --- a/sample/settings.gradle.kts +++ b/usage-example/settings.gradle.kts @@ -1,5 +1,5 @@ -// 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 +// 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). @@ -32,4 +32,4 @@ dependencyResolutionManagement { } } -rootProject.name = "sample" +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 91% rename from sample/src/iosMain/install-python-phase.sh rename to usage-example/src/iosMain/install-python-phase.sh index 7772614..9a237c1 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,10 +15,10 @@ 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" 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/sample/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 similarity index 100% rename from sample/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt rename to usage-example/src/iosMain/kotlin/org/thisisthepy/python/multiplatform/toolchain/example/PythonSmoke.ios.kt 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 From 73246466fe76ce6e408f6fb7c06523188558834a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:19:22 +0900 Subject: [PATCH 02/20] CPython 3.15.0: PY3_15_0, 3.15t spelling, wheel tags follow compileSdk (#84) --- .github/workflows/test-ios.yml | 2 +- .github/workflows/test.yml | 8 +-- AGENTS.md | 2 +- PROJECT.md | 2 +- docs/SPEC.md | 22 +++++-- docs/guide/guide-variants.html | 2 +- .../bundle/BuildPythonArtifactTask.kt | 57 ++++++++++++++----- .../multiplatform/toolchain/dsl/DSLCore.kt | 3 +- .../multiplatform/toolchain/dsl/PythonSdk.kt | 7 ++- .../toolchain/dsl/PythonVersion.kt | 20 +++++-- .../bundle/BuildPythonArtifactTaskTest.kt | 20 +++++++ .../toolchain/dsl/PythonSdkTest.kt | 30 ++++++++++ .../toolchain/dsl/PythonVersionTest.kt | 14 ++++- usage-example/build.gradle.kts | 2 +- usage-example/gradle/libs.versions.toml | 2 +- .../src/iosMain/install-python-phase.sh | 2 +- 16 files changed, 157 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 0162026..d42e4ec 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -65,7 +65,7 @@ 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 usage-example/src/iosMain/app.xcodeproj -target iosApp \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c09c7c..2e43e01 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,12 +117,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 # 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 - # usage-example/gradle/libs.versions.toml (library 3.14.7-alpha01, plugin 3.13.0 until + # usage-example/gradle/libs.versions.toml (library 3.15.0-alpha01, plugin 3.13.0 until # python-multiplatform#70). - name: Publish packpack, the toolchain plugin and python-multiplatform to mavenLocal run: | @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index dc6db5c..ee30c31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,7 +246,7 @@ Prerequisites: `org.thisisthepy.python.multiplatform:packpack:0.1.0` in `~/.m2` `pypackpack` repository), `uv` on `PATH`, and network access: `InstallDependenciesTaskTest` and `InstallerTest` run a real `uv add` against PyPI. `usage-example` additionally needs an Android SDK (`ANDROID_HOME`) and python-multiplatform in `~/.m2`: from a python-multiplatform checkout, -`./gradlew publishAllToMavenLocal` (library `3.14.7-alpha01`, bindings plugin `3.13.0` until its #70). +`./gradlew publishAllToMavenLocal` (library `3.15.0-alpha01`, bindings plugin `3.13.0` until its #70). Run each module separately (rule 8), with output to a file under `.tmp/`: diff --git a/PROJECT.md b/PROJECT.md index 34d4d56..3ff5650 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -16,7 +16,7 @@ Kotlin Multiplatform 앱의 Python 부분을 Gradle `python { }` 블록으로 ## 현재 상태 (2026-10-03, 코드와 테스트를 읽고 판정) **구현 (테스트 있음)** -- `compileSdk` 버전 문자열 파싱 (alpha / rc / normal 채널), 그리고 이름 상수 `PY3_14_7`·`PY3_13_0` +- `compileSdk` 버전 문자열 파싱 (alpha / rc / normal 채널), 그리고 이름 상수 `PY3_15_0`·`PY3_14_7`·`PY3_13_0` (python-multiplatform 이 런타임을 제공하는 버전만). 제공되지 않는 버전 문자열은 `buildPython…` 에서 이유와 함께 실패 - `python { }` 최상위의 `android("android") { }` / `ios { }` / `desktop()` 과 변형 호출 (`androidArm64()` 등) → `pypackpack` 타깃 트리플 매핑, Kotlin 타깃 대조 경고, 최소 SDK 전달. diff --git a/docs/SPEC.md b/docs/SPEC.md index b09ffc6..67d06bc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -44,16 +44,30 @@ form `pypackpack` accepts. A blank value is a silent skip; a malformed one fails **Status: implemented**. `plugin/dsl/PythonVersion.kt`; `ptest/dsl/PythonVersionTest.kt`. `compileSdk` takes either form the example build file writes, both with `=` (Gradle's Kotlin -assignment overloading on `PythonSdk`): a string, `compileSdk = "3.14.7"`, or a named constant, -`compileSdk = PY3_14_7`. Constants exist only for versions python-multiplatform provides a runtime -for (its pinned `pythonVersion`, 3.14.7, and the older 3.13.0 archive), so the example's +assignment overloading on `PythonSdk`): a string, `compileSdk = "3.15.0"`, or a named constant, +`compileSdk = PY3_15_0`. Constants exist only for versions python-multiplatform provides a runtime +for (its pinned `pythonVersion`, 3.15.0; 3.14.7 and the older 3.13.0 archive are kept), so the example's `PY3_11_9_ALPHA` does not compile (INTENT §4.1). A string resolves to a provided version (`X.Y` takes the newest `X.Y.*`). A string naming a version that is not provided would be built automatically (INTENT §4.1), which is not available yet: the reason fails `buildPython…` (only when there is a package to bundle), not the configuration. +A trailing `t` names the free-threaded build (`compileSdk = "3.15t"`, `"3.15.0t"`); it resolves +against the same published list (python-multiplatform ships both builds of a pinned version) and +there is no free-threaded constant. The `t` never reaches `toReleaseString()`, so the agreement +check with python-multiplatform's `pythonVersion` (§1.12) compares `X.Y.Z` only. **Not checked:** +whether python-multiplatform was built free-threaded (its `freeThreaded` flag is a separate +attribute that toolchain does not read). + +The resolved version is also forwarded to every bundle as pypackpack's +`BundleRequest.pythonVersion` (`3.15.0`, or `3.15.0t`; absent without `compileSdk`), which fixes the +wheel tags (`cp315-cp315`, `cp315-cp315t`). **Status: implemented**. +`ptest/bundle/BuildPythonArtifactTaskTest.kt`, `ptest/dsl/PythonSdkTest.kt`, +`ptest/dsl/PythonVersionTest.kt`. Needs a `packpack` in `~/.m2` built from pypackpack#77 or later +(the same `0.1.0` coordinates; CI publishes it from pypackpack `develop`). + **Status: implemented**. `plugin/dsl/PythonSdk.kt`; `ptest/dsl/PythonSdkTest.kt`, -`ptest/bundle/BuildPythonSdkRejectionTest.kt`; `usage-example` uses `compileSdk = PY3_14_7`. +`ptest/bundle/BuildPythonSdkRejectionTest.kt`; `usage-example` uses `compileSdk = PY3_15_0`. **What `compileSdk` selects (#42).** `compileSdk` selects the Python version of the wheels that go into `python/`: it is the `--python-version` of every per-target install (§1.10, #16). It does 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/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTask.kt index f16565c..4a31276 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTask.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/BuildPythonArtifactTask.kt @@ -191,7 +191,11 @@ open class BuildPythonArtifactTask : DefaultTask() { ?.let { throw GradleException(it) } } - val result = bundleWithPackpack(source, target, buildType, bundleDir, resolvedBuildLevel, minSdk, metaDirs, libDirs, versionName, versionCode) + val result = + bundleWithPackpack( + source, target, buildType, bundleDir, resolvedBuildLevel, minSdk, metaDirs, libDirs, + versionName, versionCode, compileSdkVersion?.let { wheelVersionOf(it) }, + ) logger.lifecycle( "Bundled ${result.fileCount} file(s) for Python '$pythonVersion' via packpack's " + "'${result.bundleType.id}' bundler into ${result.outputDir} " + @@ -202,6 +206,35 @@ open class BuildPythonArtifactTask : DefaultTask() { } } +/** The [BundleRequest] [bundleWithPackpack] hands pypackpack; separate so a test can read what is forwarded. */ +fun bundleRequestFor( + packageDir: File, + target: String, + buildType: String, + outputDir: File, + buildLevel: String = "instant", + minSdk: Int? = null, + metaDirs: 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/dsl/DSLCore.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/DSLCore.kt index 52e0c47..060256f 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,8 +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_15_0: PythonSdkVersion = PythonSdkVersion.PY3_15_0 val PY3_14_7: PythonSdkVersion = PythonSdkVersion.PY3_14_7 val PY3_13_0: PythonSdkVersion = PythonSdkVersion.PY3_13_0 var localLibraryPath: String? = null 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..8531e71 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 @@ -4,18 +4,19 @@ 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 + * `gradle.properties` `pythonVersion` (3.15.0 since python-multiplatform#197; 3.14.7 is kept), 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. * 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", "3.14.7", "3.13.0") } /** `compileSdk = PY3_14_7`: a version python-multiplatform provides. Only those exist as constants. */ enum class PythonSdkVersion(val version: String) { + PY3_15_0("3.15.0"), PY3_14_7("3.14.7"), PY3_13_0("3.13.0"), } @@ -76,7 +77,7 @@ fun resolvePythonSdk(sdk: PythonSdk): ResolvedPythonSdk? { .maxByOrNull { it.micro } 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/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/dsl/PythonSdkTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdkTest.kt index 06aa7a5..522d340 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 @@ -49,6 +49,36 @@ class PythonSdkTest { 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 `3_14_7 stays published beside 3_15_0`() { + assertTrue("3.14.7" in PythonReleaseServer.PUBLISHED && "3.15.0" in PythonReleaseServer.PUBLISHED) + } + + @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 -> 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/usage-example/build.gradle.kts b/usage-example/build.gradle.kts index 9168cdb..0e70ed2 100644 --- a/usage-example/build.gradle.kts +++ b/usage-example/build.gradle.kts @@ -27,7 +27,7 @@ plugins { // `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 diff --git a/usage-example/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml index 5751dec..b85d045 100644 --- a/usage-example/gradle/libs.versions.toml +++ b/usage-example/gradle/libs.versions.toml @@ -12,7 +12,7 @@ compose-multiplatform = "1.11.1" 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 = "3.15.0-alpha01" python-multiplatform-plugin = "3.13.0" # The toolchain plugin, from ../toolchain via mavenLocal (`./gradlew :toolchain:publishToMavenLocal`). toolchain = "1.0.0-alpha" diff --git a/usage-example/src/iosMain/install-python-phase.sh b/usage-example/src/iosMain/install-python-phase.sh index 9a237c1..785bbb8 100755 --- a/usage-example/src/iosMain/install-python-phase.sh +++ b/usage-example/src/iosMain/install-python-phase.sh @@ -25,7 +25,7 @@ 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" ;; From 2bc0784ea14c6f8a7fc89d16314601892a3ffa20 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:41:40 +0900 Subject: [PATCH 03/20] usage-example iOS phase: run install-python.sh from its new path in python-multiplatform (#85) --- usage-example/src/iosMain/install-python-phase.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usage-example/src/iosMain/install-python-phase.sh b/usage-example/src/iosMain/install-python-phase.sh index 785bbb8..cb7484a 100755 --- a/usage-example/src/iosMain/install-python-phase.sh +++ b/usage-example/src/iosMain/install-python-phase.sh @@ -42,4 +42,4 @@ 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 +/bin/bash python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh From 427da8564f46de9613fcadf46efb9c0bd1a43525 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:55:42 +0900 Subject: [PATCH 04/20] CI: Run the tests on pushes to develop, not on every pull request Closes #86 --- .github/workflows/test-ios.yml | 9 ++------- .github/workflows/test.yml | 5 +++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index d42e4ec..6aa68f8 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -5,15 +5,10 @@ name: iOS e2e # 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: - - "usage-example/src/iosMain/**" - - "usage-example/build.gradle.kts" - - "usage-example/gradle/**" - - "toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/**" - - ".github/workflows/test-ios.yml" workflow_dispatch: permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e43e01..df91d71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,10 +9,11 @@ name: Test # 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 From 035a9bbb17406ff0b0f3fdd256d71b0988020b80 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:44:08 +0900 Subject: [PATCH 05/20] Docs: toolchain-lite 0.1.0 is on PyPI (#73) --- docs/SPEC.md | 9 ++++++--- docs/guide/status.html | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 67d06bc..7bb103f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -576,10 +576,13 @@ tcl --help and every wheel to pass `smoke_wheel.py` in a fresh venv (`tcl --help`, `tcl --version`, `python -m toolchain_lite --version`, and `tcl install six` writing a `pyproject.toml`). Pull requests that touch these files run everything but the upload. -- PyPI's 0.0.1 (2025-06-12) packed the old `toolchain/` Gradle directory and installs no `tcl`. +- PyPI's 0.0.1 (2025-06-12) packed the old `toolchain/` Gradle directory and installs no `tcl`. It is not + yanked yet; yanking it is the maintainer's step on PyPI. -**Status: partial**. Built and smoke-tested by CI on every pull request that touches it; nothing -uploaded yet. +**Status: implemented**. 0.1.0 is on PyPI (uploaded 2026-10-03 from the `toolchain-lite-v0.1.0` release): +the four wheels above, each smoke-tested before upload. `uvx --from toolchain-lite==0.1.0 tcl --version` +prints `tcl 0.1.0` (checked 2026-10-17, macOS arm64). Pull requests that touch these files still build +and smoke-test the wheels without uploading. --- diff --git a/docs/guide/status.html b/docs/guide/status.html index b6223cc..6c30272 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핫 리로드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
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
From ffb8de1a5a76827c975f166665e627e82773fce3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:47:30 +0900 Subject: [PATCH 06/20] compileSdk: Drop the GIL-only 3.14.7 and 3.13.0; refuse CPython before 3.15 (#76) PY3_14_7 and PY3_13_0 are gone and PUBLISHED is 3.15.0 only. A string naming a CPython before 3.15 is refused inside buildPython as a GIL-only build, pointing to 3.15t. 3.15.0 without t (PY3_15_0) stays as a recorded temporary exception while python-multiplatform publishes its GIL build. Test fixtures that used 3.14.7 as a version move to 3.15.0. --- PROJECT.md | 2 +- docs/SPEC.md | 15 +++++++- docs/guide/status.html | 2 +- .../multiplatform/toolchain/dsl/DSLCore.kt | 2 - .../multiplatform/toolchain/dsl/PythonSdk.kt | 30 ++++++++++----- .../multiplatform/toolchain/EmbedLevelTest.kt | 10 ++--- .../toolchain/PythonPluginPythonOnlyTest.kt | 22 +++++------ .../toolchain/PythonVersionAgreementTest.kt | 28 +++++++------- .../toolchain/dsl/PythonSdkTest.kt | 38 +++++++++++-------- 9 files changed, 88 insertions(+), 61 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 3ff5650..3d89c9c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -16,7 +16,7 @@ Kotlin Multiplatform 앱의 Python 부분을 Gradle `python { }` 블록으로 ## 현재 상태 (2026-10-03, 코드와 테스트를 읽고 판정) **구현 (테스트 있음)** -- `compileSdk` 버전 문자열 파싱 (alpha / rc / normal 채널), 그리고 이름 상수 `PY3_15_0`·`PY3_14_7`·`PY3_13_0` +- `compileSdk` 버전 문자열 파싱 (alpha / rc / normal 채널), 그리고 이름 상수 `PY3_15_0` (3.14.7·3.13.0 은 GIL 전용이라 제거, 3.15 이전 문자열은 태스크에서 거부, #76) (python-multiplatform 이 런타임을 제공하는 버전만). 제공되지 않는 버전 문자열은 `buildPython…` 에서 이유와 함께 실패 - `python { }` 최상위의 `android("android") { }` / `ios { }` / `desktop()` 과 변형 호출 (`androidArm64()` 등) → `pypackpack` 타깃 트리플 매핑, Kotlin 타깃 대조 경고, 최소 SDK 전달. diff --git a/docs/SPEC.md b/docs/SPEC.md index 7bb103f..5244c2a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -46,12 +46,23 @@ form `pypackpack` accepts. A blank value is a silent skip; a malformed one fails `compileSdk` takes either form the example build file writes, both with `=` (Gradle's Kotlin assignment overloading on `PythonSdk`): a string, `compileSdk = "3.15.0"`, or a named constant, `compileSdk = PY3_15_0`. Constants exist only for versions python-multiplatform provides a runtime -for (its pinned `pythonVersion`, 3.15.0; 3.14.7 and the older 3.13.0 archive are kept), so the example's -`PY3_11_9_ALPHA` does not compile (INTENT §4.1). A string resolves to a provided version (`X.Y` +for (its pinned `pythonVersion`, 3.15.0), so the example's `PY3_11_9_ALPHA` does not compile (INTENT §4.1). A string resolves to a provided version (`X.Y` takes the newest `X.Y.*`). A string naming a version that is not provided would be built automatically (INTENT §4.1), which is not available yet: the reason fails `buildPython…` (only when there is a package to bundle), not the configuration. +**CPython 3.15t only (#76).** The runtime is CPython 3.15t, free-threaded only (python-multiplatform #158). +`PY3_14_7` and `PY3_13_0` are gone, and a string naming a CPython before 3.15 (`"3.14.7"`, `"3.13"`) is +refused as a GIL-only build, inside `buildPython…` as above, with a message that points to `"3.15t"`. +Test: `PythonSdkTest` (`a CPython before 3_15 is refused as a GIL build, inside the task`). + +**Temporary exception: `3.15.0` without `t` (`PY3_15_0`).** It names python-multiplatform's GIL build, +which is what python-multiplatform publishes today: its `gradle.properties` has `pythonFreeThreaded=false`, +its artifact version (`3.15.0-alpha01`) carries no flavour, and Android and iOS have no 3.15t build yet +(python-multiplatform #163). Selecting `cp315t` wheels for that runtime would install wheels it cannot +load. The exception is removed, and `3.15.0` then means `3.15.0t`, when python-multiplatform publishes +free-threaded as its default with the flavour in its version or metadata, and #163 lands; #76 tracks it. + A trailing `t` names the free-threaded build (`compileSdk = "3.15t"`, `"3.15.0t"`); it resolves against the same published list (python-multiplatform ships both builds of a pinned version) and there is no free-threaded constant. The `t` never reaches `toReleaseString()`, so the agreement diff --git a/docs/guide/status.html b/docs/guide/status.html index 6c30272..91e6f6d 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)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핫 리로드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핫 리로드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_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
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 060256f..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 @@ -8,8 +8,6 @@ open class PythonExtension @Inject constructor(objects: ObjectFactory) { /** `compileSdk = "3.15.0"` or `compileSdk = PY3_15_0` (`"3.15t"` for the free-threaded build); see [PythonSdk]. */ val compileSdk: PythonSdk = PythonSdk() val PY3_15_0: PythonSdkVersion = PythonSdkVersion.PY3_15_0 - val PY3_14_7: PythonSdkVersion = PythonSdkVersion.PY3_14_7 - val PY3_13_0: PythonSdkVersion = PythonSdkVersion.PY3_13_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 8531e71..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,22 +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.15.0 since python-multiplatform#197; 3.14.7 is kept), 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.15.0", "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_15_0("3.15.0"), - PY3_14_7("3.14.7"), - PY3_13_0("3.13.0"), } /** @@ -76,6 +77,17 @@ 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.copy(freeThreaded = parsed.freeThreaded), fromConstant = sdk.constant != null, rejection = null) } else { 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/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/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/dsl/PythonSdkTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/dsl/PythonSdkTest.kt index 522d340..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,11 +45,9 @@ class PythonSdkTest { } @Test - fun `python-multiplatform's pinned 3_14_7 is a constant`() { - val resolved = resolvePythonSdk(PythonSdk().apply { assign(PythonSdkVersion.PY3_14_7) })!! - - assertEquals("3.14.7", resolved.version.toString()) - assertNull(resolved.rejection) + 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()) } @Test @@ -62,8 +63,13 @@ class PythonSdkTest { } @Test - fun `3_14_7 stays published beside 3_15_0`() { - assertTrue("3.14.7" in PythonReleaseServer.PUBLISHED && "3.15.0" in PythonReleaseServer.PUBLISHED) + 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 @@ -81,28 +87,28 @@ class PythonSdkTest { @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") } From 0eee0dab76e2bc4a1cb4036daa1b4da8e1d3db5e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:19:17 +0900 Subject: [PATCH 07/20] typedpythonCheck: Take the gate from python-multiplatform-ksp's typedpython-gate zip (#90) Without -Ptypedpython.wheelDir the check resolves io.github.thisisthepy:python-multiplatform-ksp:3.13.0:typedpython-gate@zip leniently from the project's repositories, unzips it and installs with --require-hashes: typedpython as a direct file reference to the unzipped wheel, pyrefly from PyPI pinned to every manifest hash. A host with no pyrefly wheel in the manifest fails naming the platform. The SPEC's index rule now reads: no index install for a name unclaimed on PyPI; hash-pinned installs are allowed. --- docs/SPEC.md | 57 +++++--- .../multiplatform/toolchain/PythonPlugin.kt | 20 +++ .../typedpython/TypedpythonCheckTask.kt | 131 ++++++++++++++---- .../toolchain/typedpython/TypedpythonGate.kt | 116 ++++++++++++++++ .../typedpython/TypedpythonDecisionsTest.kt | 2 +- .../TypedpythonGateArtifactTest.kt | 118 ++++++++++++++++ .../typedpython/TypedpythonGateTest.kt | 129 +++++++++++++++++ 7 files changed, 529 insertions(+), 44 deletions(-) create mode 100644 toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt create mode 100644 toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt create mode 100644 toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 5244c2a..7ba95a5 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -501,22 +501,37 @@ bundled. Every `buildPython` and the single host `buildPytho by `typedpythonCheckedDirs`). `.py` and `.pyi` files there are `@InputFiles` with `@PathSensitive(RELATIVE)`: a changed `.py` re-runs the check, a change to any other file leaves it `UP-TO-DATE`. No package directory, or no `.py` file in it: the check is skipped with a log line and the gate is not installed. -- **Gate.** The `typedpython` wheel and the `pyrefly` it pins, both at pinned versions (`@Input`, - defaults `0.1.0` and `1.3.2`), installed into `build/typedpython/venv`: never the project's `.venv`. - The venv is created through `pypackpack`'s UV backend (`createVirtualEnvironment`, `--no-project - --clear`, Python `>=3.13`; only when the venv does not exist). The install is - `uv pip install --python --no-index --find-links typedpython== pyrefly==` - (skipped when the same request is already installed): **both packages come from `` and - nothing is ever fetched from an index**: `typedpython` is unclaimed on PyPI, so a bare-name install - would run whatever someone publishes under that name on every consumer's build. `` is the - project property `typedpython.wheelDir` and must hold the `typedpython` wheel and the `pyrefly` wheel - for the platform. An install failure fails the task and names that requirement. The install calls - `uv` directly: `pypackpack`'s backend has no operation that installs named requirements into an - existing venv from a local wheel directory (`installDependenciesToTarget` reads `-r pyproject.toml` - into `--target`; `addDependencies` edits `pyproject.toml`): a `pypackpack` change, not made here. -- **No wheel directory.** The check is **skipped with a loud warning** (what was skipped, that the - gate is not on PyPI yet, and `-Ptypedpython.wheelDir=` to turn it on); nothing is created or - installed. With a wheel directory a failed check fails the build. +- **Gate.** The `typedpython` wheel and the `pyrefly` it pins, installed into `build/typedpython/venv`: + never the project's `.venv`. The venv is created through `pypackpack`'s UV backend + (`createVirtualEnvironment`, `--no-project --clear`, Python `>=3.13`; only when the venv does not exist). + The install calls `uv` directly: `pypackpack`'s backend has no operation that installs named + requirements into an existing venv (`installDependenciesToTarget` reads `-r pyproject.toml` into + `--target`; `addDependencies` edits `pyproject.toml`): a `pypackpack` change, not made here. +- **Index rule.** A package whose name is **unclaimed on PyPI** is never installed from an index: + `typedpython` is unclaimed, so a bare-name install would run whatever someone publishes under that + name on every consumer's build. A **hash-pinned** install from PyPI is allowed: `pyrefly` comes from + PyPI only when it matches a pinned SHA-256 (`--require-hashes`). (Decision for python-multiplatform + #20, 2026-10-17; #90.) +- **Where the gate comes from**, first match wins: + 1. `-Ptypedpython.wheelDir=`, the override: `uv pip install --python --no-index + --find-links typedpython== pyrefly==` (`@Input` versions, defaults `0.1.0` and `1.3.2`). + `` must hold both wheels for the platform; nothing comes from an index. + 2. The **gate artifact**, by default: the `typedpython-gate` zip python-multiplatform-ksp publishes beside + its processor jar, `io.github.thisisthepy:python-multiplatform-ksp:3.13.0:typedpython-gate@zip` + (`DEFAULT_TYPEDPYTHON_GATE`, the one place its group and version are written; `-Ptypedpython.gate= + ` overrides it). It is resolved leniently from the project's own + repositories (python-multiplatform's `publishToMavenLocal` puts it in `mavenLocal()`), unzipped into + `build/typedpython/gate`, and its manifest `typedpython-gate.json` (format 1) gives typedpython's + wheel, version and SHA-256 and the SHA-256 of every pyrefly PyPI wheel. The install is `uv pip + install --python --require-hashes -r build/typedpython/gate-requirements.txt`, where + typedpython is a direct `file:` reference to the unzipped wheel with its hash (never looked up on an + index) and `pyrefly==` carries every manifest hash. A host with no pyrefly wheel in the manifest + fails, naming the platform; a pyrefly that matches no hash fails the task. + 3. Neither: the check is **skipped with a loud warning** (what was skipped, that the gate artifact is + not in the project's repositories, and both ways to provide it); nothing is created or installed. + + Either install is skipped when the same request is already installed. An install failure fails the + task. With a gate, a failed check fails the build. - **Run.** `/bin/typedpython check --mode [--search-path ]... ...`, with explicit files (the 0.1.0 gate crashes on a directory argument). `mode` is an `@Input`, default `checked`; any value other than `checked`/`compiled` fails the task. @@ -538,7 +553,12 @@ fix passes; per-variant `buildPython*` depend on the check; only a `.py` change `typedpythonStubs` directory resolves an import that fails without it; the no-stubs line appears once; the package's modules import one another; a `pyrefly` pin absent from the wheel directory fails instead of being fetched; `metaDirs`/`libDirs` are not checked), -`ptest/typedpython/TypedpythonNoWheelTest.kt` (no wheel directory: skipped with the warning), +`ptest/typedpython/TypedpythonNoWheelTest.kt` (no gate: skipped with the warning), +`ptest/typedpython/TypedpythonGateTest.kt` (coordinates, manifest, hash-pinned requirements, pyrefly's +wheel for the host), `ptest/typedpython/TypedpythonGateArtifactTest.kt` (TestKit with the real gate zip +from `~/.m2`: installed and run without a wheel directory; a missing artifact is a skip; a tampered +pyrefly hash fails the task. Skipped without the zip, as on CI unless python-multiplatform-ksp was +published to `mavenLocal`), `ptest/typedpython/TypedpythonDecisionsTest.kt` (source selection, import root, `--no-index` commands, skip warning, mode, exit-code interpretation). The TestKit tests in `TypedpythonCheckTaskTest` need `-Ptypedpython.wheelDir=` (holding the `typedpython` and @@ -546,7 +566,8 @@ commands, skip warning, mode, exit-code interpretation). The TestKit tests in Not covered by a test here: exit 2 end to end (only `interpretTypedpythonResult`), and the install in a *consumer's* classpath: `withPluginClasspath()` bypasses the Kotlin DSL's `kotlin-stdlib` pin; that was verified by building usage-example's `buildPython`. -→ *Until `typedpython` is on PyPI, a build without `-Ptypedpython.wheelDir` skips the check with a warning.* +→ *A build whose repositories lack the gate artifact (and without `-Ptypedpython.wheelDir`) skips the check +with a warning.* → *Platform overlays (`src/`) are checked as files but only `src/main` is an import root: **partial**.* 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 21365fa..1cb1450 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 @@ -27,10 +27,13 @@ import org.thisisthepy.python.multiplatform.toolchain.hotreload.validateCodePush 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 +98,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")) } 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..057c7e1 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 @@ -32,7 +32,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 +72,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 +120,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 +187,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 +202,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.deleteRecursively() + 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 +268,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 +282,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..d008ed4 --- /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 = "io.github.thisisthepy:python-multiplatform-ksp:3.13.0" + +/** 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/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..34861fe --- /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/io/github/thisisthepy/python-multiplatform-ksp/3.13.0/" + + "python-multiplatform-ksp-3.13.0-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=io.github.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, "io/github/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=io.github.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..b756cc7 --- /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("io.github.thisisthepy:python-multiplatform-ksp:3.13.0", DEFAULT_TYPEDPYTHON_GATE) + assertEquals( + "io.github.thisisthepy:python-multiplatform-ksp:3.13.0: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) + } +} From 8180f030cc90850194434ae7300c1e4d6e7d5e8c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:32:08 +0900 Subject: [PATCH 08/20] Fix: Delete directories without following symbolic links out of them (#93) The staged payload, a target's install directory and the typedpython gate directory were cleared with deleteRecursively, which walks into a link to a directory and empties its target. They now call deleteWithoutFollowingLinks, the same function pypackpack#96 added. --- .../multiplatform/toolchain/FileTree.kt | 53 ++++++++++++ .../toolchain/bundle/StagePythonBundleTask.kt | 3 +- .../python/InstallTargetDependenciesTask.kt | 3 +- .../typedpython/TypedpythonCheckTask.kt | 3 +- .../multiplatform/toolchain/FileTreeTest.kt | 85 +++++++++++++++++++ 5 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTree.kt create mode 100644 toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/FileTreeTest.kt 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/bundle/StagePythonBundleTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTask.kt index 194143c..2625e04 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,6 @@ package org.thisisthepy.python.multiplatform.toolchain.bundle +import org.thisisthepy.python.multiplatform.toolchain.deleteWithoutFollowingLinks import org.gradle.api.DefaultTask import org.gradle.api.file.FileTree import org.gradle.api.tasks.Input @@ -147,7 +148,7 @@ fun stagePythonPayload( payloadPath: String = PythonStagingLayout.PAYLOAD_ROOT, ): 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") } 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/typedpython/TypedpythonCheckTask.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonCheckTask.kt index 057c7e1..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 @@ -208,7 +209,7 @@ fun ensureTypedpythonGate(venv: File, version: String, pyreflyVersion: String, w * 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.deleteRecursively() + gateDir.deleteWithoutFollowingLinks() gateDir.mkdirs() java.util.zip.ZipFile(archive).use { zip -> zip.entries().asSequence().filter { !it.isDirectory }.forEach { entry -> 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()) + } +} From 9aa8f80c226e77bfaa939aee4e451849d2150dab Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:56:10 +0900 Subject: [PATCH 09/20] iOS e2e: Read the smoke report from a file in the app's container (#92) The app now writes its report to Documents/python-smoke.txt besides printing it. The job waits until SpringBoard runs, launches without --console-pty, polls for the file and relaunches once if it never appears. --console-pty lost the output three times when the app had exited before the pty attached. --- .github/workflows/test-ios.yml | 30 ++++++++++++-- .../toolchain/example/PythonSmoke.ios.kt | 40 ++++++++++++++----- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 6aa68f8..5562b2c 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -85,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/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 index 16442f0..e895dda 100644 --- 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 @@ -1,40 +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 --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). + * `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 is printed to stdout, which `--console-pty` shows. + * 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 = + val (code, report) = try { Python3.initialize() Python3.exec("import os, sys, example_py, iniconfig") val globals = Python3.import("__main__").dict - val report = + 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, ) - println("PYTHON_SMOKE_OK $report") - 0 + 0 to "PYTHON_SMOKE_OK $result" } catch (t: Throwable) { - println("PYTHON_SMOKE_FAILED: $t") - 1 + 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) +} From 1e9af805b122c329322315634303027ab218deef Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:45:48 +0900 Subject: [PATCH 10/20] CI: Let a develop run in progress finish; a newer push replaces only the queued run --- .github/workflows/test-ios.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 6aa68f8..3652541 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -16,7 +16,7 @@ permissions: concurrency: group: ios-e2e-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: ios-e2e: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df91d71..11c119f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ permissions: concurrency: group: test-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: test: From 4db268d1b159e60f59825e80863ad250d6e8817b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:22:55 +0900 Subject: [PATCH 11/20] Publishing: Prepare the plugin for the Plugin Portal; take packpack from Central first (#97) com.gradle.plugin-publish adds the sources and javadoc jars and publishPlugins; gradlePlugin carries website, VCS URL, display name, description and tags, and both POMs carry url, license, developer and SCM. Signing happens only with a key. A staging repository rehearses a release and check_central_bundle.py checks it, marker included. Maven Central now comes before mavenLocal, which is limited to our groups, so a published packpack wins and an unreleased one still resolves. --- .github/scripts/maven/check_central_bundle.py | 93 +++++++++++++++++++ docs/SPEC.md | 26 +++++- settings.gradle.kts | 8 +- toolchain/build.gradle.kts | 56 +++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100755 .github/scripts/maven/check_central_bundle.py 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/docs/SPEC.md b/docs/SPEC.md index 7ba95a5..8e1a6b8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -26,8 +26,9 @@ Paths below are abbreviated: `plugin/` = `toolchain/src/main/kotlin/org/thisisth Plugin id `org.thisisthepy.python.multiplatform`, implementation class `PythonPlugin` (`toolchain/build.gradle.kts` `gradlePlugin { }`). Applying it creates the `python` extension (`PythonExtension`) and registers the tasks of §1.10–§1.15 and §1.18, all in group `python`. -Consumers resolve it from `mavenLocal()` after `./gradlew :toolchain:publishToMavenLocal`; the plugin -itself depends on `org.thisisthepy.python.multiplatform:packpack:0.1.0` from `mavenLocal()`. +Consumers resolve it from `mavenLocal()` after `./gradlew :toolchain:publishToMavenLocal` until it is +published (§1.19). The plugin depends on `org.thisisthepy.python.multiplatform:packpack:0.1.0`, which this +build looks for on Maven Central first and then in `mavenLocal()`, limited to the `org.thisisthepy` groups. **Status: implemented**. `plugin/PythonPlugin.kt`; `ptest/PythonPluginApplyTest.kt` applies the plugin id to a ProjectBuilder project and checks the extension, every task name and type @@ -571,6 +572,27 @@ with a warning.* → *Platform overlays (`src/`) are checked as files but only `src/main` is an import root: **partial**.* +### 1.19 Publishing the plugin +Source: toolchain#97. **Status: partial**: prepared and rehearsed; nothing is uploaded, which is the user's +decision. + +- The plugin is `org.thisisthepy.python.multiplatform:toolchain:` with its marker + `org.thisisthepy.python.multiplatform:org.thisisthepy.python.multiplatform.gradle.plugin:` + (`java-gradle-plugin`). `com.gradle.plugin-publish` 1.3.1 adds the sources and javadoc jars and the + `publishPlugins` task for the Gradle Plugin Portal; `gradlePlugin { }` carries the website, VCS URL, + display name, description and tags. Both POMs carry url, Apache-2.0, developer and SCM (Maven Central's + requirements too). +- Signing uses an in-memory PGP key from `-PsigningInMemoryKey` / `-PsigningInMemoryKeyPassword` (or the + `ORG_GRADLE_PROJECT_` environment variables); without one nothing is signed and nothing fails. +- `./gradlew :toolchain:publishAllPublicationsToStagingRepository` writes `toolchain/build/staging-deploy`; + `.github/scripts/maven/check_central_bundle.py org.thisisthepy.python.multiplatform toolchain + --plugin-id org.thisisthepy.python.multiplatform` checks it. It failed on the earlier publication (24 + problems: no sources or javadoc jar, no POM metadata on either POM) and passes on a signed rehearsal + (2026-10-17, a throwaway key; `gpg --verify` good). +- The plugin's POM depends on packpack, so packpack must be on Maven Central before the plugin is + published (pypackpack's SPEC, *Distribution through Maven Central*): the Plugin Portal resolves a plugin's + dependencies from Maven Central. + ## 2. `tcl`: toolchain-lite ### 2.1 `tcl install ` diff --git a/settings.gradle.kts b/settings.gradle.kts index 2f98c13..6ba0cda 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,9 +9,9 @@ pluginManagement { includeGroupAndSubgroups("com.google") } } - mavenLocal() mavenCentral() gradlePluginPortal() + mavenLocal { content { includeGroupAndSubgroups("org.thisisthepy") } } } } @@ -24,8 +24,12 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } - mavenLocal() mavenCentral() + // 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") } } } } diff --git a/toolchain/build.gradle.kts b/toolchain/build.gradle.kts index d522430..5c67074 100644 --- a/toolchain/build.gradle.kts +++ b/toolchain/build.gradle.kts @@ -6,6 +6,10 @@ 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" } @@ -57,10 +61,17 @@ dependencies { } 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" + 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 +110,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) } } From b27346b7a73446560eeebb7594e5dbc9beae0b4f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:43:12 +0900 Subject: [PATCH 12/20] Publishing: Release workflow for the Gradle plugin; coordinates in gradle.properties (#99) publish-gradle-plugin.yml runs on a published v* Release in the gradle-plugin-portal environment: the tag names the version, packpack is on Maven Central, the version is new on the Portal, missing secrets are named; the plugin and marker are staged and checked, then publishPlugins. A manual run stops before the upload. The group, versions, plugin id and packpack's coordinate live in gradle.properties only; the plugin class no longer derives from the id. --- .github/workflows/publish-gradle-plugin.yml | 120 ++++++++++++++++++++ build.gradle.kts | 5 +- docs/SPEC.md | 10 ++ gradle.properties | 10 +- toolchain/build.gradle.kts | 12 +- 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/publish-gradle-plugin.yml 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/build.gradle.kts b/build.gradle.kts index 701fe10..31de4da 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,5 +8,6 @@ plugins { 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/SPEC.md b/docs/SPEC.md index 8e1a6b8..477ae36 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -592,6 +592,16 @@ decision. - The plugin's POM depends on packpack, so packpack must be on Maven Central before the plugin is published (pypackpack's SPEC, *Distribution through Maven Central*): the Plugin Portal resolves a plugin's dependencies from Maven Central. +- The coordinates are written once, in `gradle.properties` (#99): `mavenGroup`, `toolchainVersion`, `pluginId`, + `packpackModule`, `packpackVersion`. A group unification (python-multiplatform #95) changes those lines only; + the plugin's implementation class is its Kotlin package and does not follow the id. +- Releasing (#99): a GitHub Release tagged `v` runs `.github/workflows/publish-gradle-plugin.yml` in the + `gradle-plugin-portal` environment (to be restricted to `v*` tags). It checks that the tag names + `toolchainVersion`, that `packpackVersion` is on Maven Central and that the version is not on the Portal yet; + stops naming a missing `GRADLE_PUBLISH_KEY` or `GRADLE_PUBLISH_SECRET` (`SIGNING_KEY` and + `SIGNING_KEY_PASSWORD` are optional: the Portal does not require signatures); stages and checks the plugin and + its marker; and runs `publishPlugins`. A manual run does all of it except the upload, building packpack from + pypackpack's develop when Central does not have it yet. ## 2. `tcl`: toolchain-lite diff --git a/gradle.properties b/gradle.properties index 964007c..3b7cc46 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,4 +7,12 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=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/toolchain/build.gradle.kts b/toolchain/build.gradle.kts index 5c67074..acb5968 100644 --- a/toolchain/build.gradle.kts +++ b/toolchain/build.gradle.kts @@ -14,6 +14,9 @@ plugins { 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()) @@ -48,7 +51,7 @@ 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) @@ -65,8 +68,9 @@ gradlePlugin { 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 " + @@ -188,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) } From fe52885c22c397472e701d269d9881f1358a02e2 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:46:57 +0900 Subject: [PATCH 13/20] Docs: Design the live connection from a notebook to the running app (#101) The user's notebook expects its kernel to be the running app's interpreter (2024: JupyterLab and ipykernel inside the app). Proposes one socket per app for both notebook cells and hot reload, a proxy kernel on the laptop with a stdlib-only agent in the app (pyzmq has no free-threaded mobile wheels), the per-platform transport, the repository split, and a desktop-first milestone. --- docs/design/live-connection.md | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/design/live-connection.md 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. From d93f4c418bb173689311dce9d048e6970b85c7dd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:50:17 +0900 Subject: [PATCH 14/20] Fix: hotReloadPython fails saying hot reload does not work yet (#103) It pushed every .py over adb and sent RELOAD_PYTHON, which nothing in the app receives, and reported success. Until the live-connection channel replaces it, the enabled task fails naming the reason and runs no adb; disabled, it still prints its guide. SPEC, the guide's status page and both READMEs now say hot reload does not work yet. --- PROJECT.md | 4 +- README.md | 2 +- docs/SPEC.md | 23 +++-- docs/guide/status.html | 6 +- docs/locale/README_ko.md | 2 +- .../toolchain/hotreload/HotReloadPushTask.kt | 92 ++++--------------- .../toolchain/PythonPluginHotReloadTest.kt | 38 ++++++++ 7 files changed, 74 insertions(+), 93 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 3d89c9c..145a65f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -62,7 +62,7 @@ Kotlin Multiplatform 앱의 Python 부분을 Gradle `python { }` 블록으로 - APK 로의 스테이징 연결: `android.sourceSets.main.assets` 등록은 실제 AGP 로 테스트됨 (`PythonPluginAttachmentTest`), `preBuild`/`merge*Assets` 의존성은 Android SDK 가 있어야 생기므로 테스트 없음 - iOS: `stagePythonBundleIosForXcode` 가 스테이징 후 `PYTHON_PAYLOAD_DIR=<경로>` 한 줄을 출력(python-multiplatform 의 `install-python.sh` 가 `PYTHON_PAYLOAD_DIR` 로 받아 복사하며, `python/` 을 복사하는 Xcode 단계는 하나뿐이어야 함. 손으로 배선할 때의 안전한 스니펫은 가이드에 있음; 스크립트 파일은 #62 에서 삭제). `.app` 으로 복사하는 Xcode 단계는 python-multiplatform(#59) 소유 -- 핫 리로드: Android 전용 `adb push` + 브로드캐스트. `serverHost`, `cert` 는 검증만 +- 핫 리로드: 아직 동작하지 않음. 앱에 RELOAD_PYTHON 수신자가 없어 `hotReloadPython` 은 그렇게 알리며 실패 (#103). 라이브 연결(docs/design/live-connection.md)로 교체 예정. `serverHost`, `cert` 는 검증만 - 코드 푸시: 검증과 안내 태스크만, 업로드 없음 - `installPythonDependencies`(`uv add`)는 여전히 모든 소스셋을 호스트용으로 설치하므로, 호스트 wheel 이 없는 `androidMain` 전용 패키지는 이 태스크에서 실패할 수 있음. `ResourceBundler` 가 `.pyd` 를 빼므로 Windows @@ -92,7 +92,7 @@ Python 코드와 의존성이 실려 빌드·실행되는 상태. 기한을 맞 11-30 이후로 넘김: `native`/`mixed`(pypackpack Cython 슬롯은 pypackpack M3, 나머지 컴파일러는 빈 껍데기), `useCodeMinifier`(pypackpack 에 minifier 없음), 코드 푸시 업로드와 HTTPS 핫 리로드 서버 -(지금은 `adb` 로 동작), 서버에 없는 CPython 자동 빌드(빌드 파이프라인 필요), `pip { jit }`(레시피 빌드 필요). +(adb 경로는 수신자가 없어 꺼 둠, #103), 서버에 없는 CPython 자동 빌드(빌드 파이프라인 필요), `pip { jit }`(레시피 빌드 필요). ## 구조 diff --git a/README.md b/README.md index 4834520..ec45437 100644 --- a/README.md +++ b/README.md @@ -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/docs/SPEC.md b/docs/SPEC.md index 477ae36..80a273e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -423,16 +423,19 @@ Hand-off to the platform's packaging step: ### 1.14 Hot reload: `hotReloadPython` `packaging { hotReload { serverHost; redirectErrorStream; cert { keyStore | autoGenerate } } }` is validated when any build type sets `enableHotReload = true`: `serverHost` must be non-blank, and -`keyStore` and `autoGenerate` are mutually exclusive. The task collects every `.py` under -`localLibraryPath`, runs `adb push` for each to `/data/local/tmp//python/…`, then -`adb shell am broadcast -a org.thisisthepy.python.RELOAD_PYTHON`. - -**Status: partial**. `plugin/hotreload/`; `ptest/PythonPluginHotReloadTest.kt`, -`ptest/hotreload/WatchAndPushTest.kt` test the validation and the command list; the task action -itself is untested. `serverHost` and `cert` are validated but not used for transport (adb only); -the source root ignores `commonMain.srcDirs`; Android only (a desktop copy helper, -`executeLocalCopy`, exists and is tested but no task calls it). -→ *A hot-reload server reachable at `serverHost`, and a "run" entry point for it: **planned**.* +`keyStore` and `autoGenerate` are mutually exclusive. + +**Hot reload does not work yet (toolchain#103).** With hot reload enabled, `hotReloadPython` fails, saying +so and why: it used to `adb push` every `.py` to `/data/local/tmp//python/…` and send +`adb shell am broadcast -a org.thisisthepy.python.RELOAD_PYTHON`, but python-multiplatform has no receiver +for that broadcast, so the push looked like a working hot reload while the app never changed. Disabled, the +task prints how to enable it. `ptest/PythonPluginHotReloadTest.kt` (the validation; the enabled task fails +naming the reason and runs no `adb`; the disabled task only prints its guide). + +**Status: planned**: a live connection to the running app on desktop, Android and iOS replaces it +(`docs/design/live-connection.md`: M1 desktop, M2 Android, where this task moves onto the channel). +`plugin/hotreload/WatchAndPush.kt`'s `adb` command builder and desktop copy helper are tested but unused; +they go with M2. ### 1.15 Code push: `codePushPython` `packaging { codePush { serverHost; cert; uploadConfig { forceUpload; login { id; password } } } }` diff --git a/docs/guide/status.html b/docs/guide/status.html index 91e6f6d..d90c7b4 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)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핫 리로드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_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
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). 실행 중인 앱으로의 라이브 연결이 대신합니다
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 173384a..bf52e1d 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -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/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/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() From b1a9df85a91e105f35913032e3ea868e0a47c213 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:45 +0900 Subject: [PATCH 15/20] Live connection: stage the dev-agent marker for hot-reload builds; desktop run passes port and token (#105) The active Python build type's enableHotReload (the example build file's debug) is the build-time switch for python-multiplatform's dev agent (its SPEC L-12): - stagePythonBundle* writes python_multiplatform_devagent.enable at the staged python/ root. It is an @Input, and the clean sync removes it when the build type changes, so a release payload never keeps it. - Compose's run and a JVM target's Run get a free loopback port and a 32-byte URL-safe token before each run, as PYTHON_MULTIPLATFORM_DEV_AGENT=port=;token=, and in build/pythonDevAgent/.json (owner-only on POSIX) for the laptop side. usage-example declares debug with enableHotReload, so its build stages the marker and its run tasks carry the launch action. --- docs/SPEC.md | 19 ++++ docs/guide/status.html | 2 +- .../multiplatform/toolchain/PythonPlugin.kt | 31 +++++++ .../toolchain/bundle/StagePythonBundleTask.kt | 19 +++- .../toolchain/devagent/DevAgent.kt | 92 +++++++++++++++++++ .../toolchain/PythonPluginDevAgentTest.kt | 73 +++++++++++++++ .../bundle/StagePythonBundleTaskTest.kt | 19 ++++ .../toolchain/devagent/DevAgentLaunchTest.kt | 71 ++++++++++++++ usage-example/build.gradle.kts | 8 ++ 9 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgent.kt create mode 100644 toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/PythonPluginDevAgentTest.kt create mode 100644 toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/devagent/DevAgentLaunchTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 80a273e..a8a21ca 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -437,6 +437,25 @@ naming the reason and runs no `adb`; the disabled task only prints its guide). `plugin/hotreload/WatchAndPush.kt`'s `adb` command builder and desktop copy helper are tested but unused; they go with M2. +**The dev agent's launch (toolchain#105).** The active Python build type's `enableHotReload` (the example build +file's `debug`) is the build-time switch for python-multiplatform's dev agent (its SPEC L-12). With it: + +- every `stagePythonBundle*` writes `python_multiplatform_devagent.enable` at the root of the staged `python/` + payload. The runtime starts no agent without that marker, so a build type without `enableHotReload` (a + release) opens no port even when its process is started with the variable. The marker is an `@Input` of the + stage task, and the clean sync removes it when the build type changes; +- the desktop run tasks (Compose's `run`, a JVM target's `Run`) get, before each run, a free loopback + port and a 32-byte random token (43 URL-safe characters, python-multiplatform's minimum). They are passed + as `PYTHON_MULTIPLATFORM_DEV_AGENT=port=;token=` and written to `build/pythonDevAgent/.json` + (`{"host", "port", "token"}`, owner-only on POSIX) for the laptop side to read. + +`ptest/devagent/DevAgentLaunchTest.kt` (the switch, the token, the variable's form, the connection file's +permissions), `ptest/bundle/StagePythonBundleTaskTest.kt` (the marker staged only when asked for, and removed by +a later staging without it), `ptest/PythonPluginDevAgentTest.kt` (a hot-reload build marks every destination +and its `run` starts an agent; a release build does neither). **Status: partial**: nothing on the laptop reads +the connection file yet (`tcl connect`, the proxy kernel), a packaged desktop app or an Android or iOS launch +gets no variable (M2, M3), and no CI job starts an app and connects. + ### 1.15 Code push: `codePushPython` `packaging { codePush { serverHost; cert; uploadConfig { forceUpload; login { id; password } } } }` is validated the same way when any build type sets `enableCodePush = true`. The task prints that no diff --git a/docs/guide/status.html b/docs/guide/status.html index d90c7b4..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)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). 실행 중인 앱으로의 라이브 연결이 대신합니다
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
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
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 1cb1450..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,6 +22,10 @@ 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 @@ -436,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 @@ -745,6 +751,31 @@ private fun registerAndroidAssetSourceDirectory(project: Project, directory: Fil } } +/** + * toolchain#105: a build whose active Python build type enables hot reload stages the dev-agent marker into + * every destination, and its desktop run tasks start an agent ([DevAgentLaunchAction]). Compose's `run` and + * the Kotlin JVM target's `Run` 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. * 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 2625e04..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,6 +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 @@ -92,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 @@ -103,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 ""), ) } } @@ -141,11 +150,15 @@ 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.deleteWithoutFollowingLinks() @@ -153,6 +166,8 @@ fun stagePythonPayload( 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/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/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/bundle/StagePythonBundleTaskTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/bundle/StagePythonBundleTaskTest.kt index 56c2ba9..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( 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/usage-example/build.gradle.kts b/usage-example/build.gradle.kts index 0e70ed2..5b87e51 100644 --- a/usage-example/build.gradle.kts +++ b/usage-example/build.gradle.kts @@ -57,6 +57,14 @@ python { } packaging { 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). From a07c6d21d0ac95574cce4f0bdb898e92436a136e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:51:47 +0900 Subject: [PATCH 16/20] Feat: switch Compose dependencies and plugins to org.thisisthepy.compose 1.11.1-ext.2 (#107) --- build.gradle.kts | 1 + gradle.properties | 4 +++ gradle/libs.versions.toml | 5 ++-- settings.gradle.kts | 36 ++++++++++++++++++++++--- usage-example/build.gradle.kts | 10 ++++++- usage-example/gradle.properties | 4 +++ usage-example/gradle/libs.versions.toml | 7 ++--- usage-example/settings.gradle.kts | 34 ++++++++++++++++++++--- 8 files changed, 87 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 31de4da..a82e1d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ 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) } diff --git a/gradle.properties b/gradle.properties index 3b7cc46..2e4995c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,6 +8,10 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 #Android android.nonTransitiveRClass=true android.useAndroidX=true +# org.thisisthepy.compose:compose-gradle-plugin:1.11.1-ext.2'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). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5898058..6c387c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ 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" junit = "4.13.2" kotlin = "2.1.0" kotlinx-coroutines = "1.10.1" @@ -35,6 +35,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-multiplatform" } +kotlin-native-desktop = { id = "org.thisisthepy.kotlin.native.desktop", version.ref = "compose-multiplatform" } 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/settings.gradle.kts b/settings.gradle.kts index 6ba0cda..1ad88e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,9 +9,23 @@ pluginManagement { includeGroupAndSubgroups("com.google") } } - 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") } } + mavenLocal { + content { + includeGroupAndSubgroups("org.thisisthepy") + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } } } @@ -24,12 +38,26 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } - 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") } } + mavenLocal { + content { + includeGroupAndSubgroups("org.thisisthepy") + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } } } diff --git a/usage-example/build.gradle.kts b/usage-example/build.gradle.kts index 5b87e51..9d38ac3 100644 --- a/usage-example/build.gradle.kts +++ b/usage-example/build.gradle.kts @@ -12,6 +12,7 @@ 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) // 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 @@ -128,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 index 816b4c2..f3abf9c 100644 --- a/usage-example/gradle.properties +++ b/usage-example/gradle.properties @@ -3,3 +3,7 @@ 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.2'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/usage-example/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml index b85d045..ec09970 100644 --- a/usage-example/gradle/libs.versions.toml +++ b/usage-example/gradle/libs.versions.toml @@ -7,8 +7,8 @@ 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" 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). @@ -26,7 +26,8 @@ python-multiplatform = { module = "io.github.thisisthepy: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-multiplatform" } +kotlin-native-desktop = { id = "org.thisisthepy.kotlin.native.desktop", version.ref = "compose-multiplatform" } 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" } diff --git a/usage-example/settings.gradle.kts b/usage-example/settings.gradle.kts index 1284f17..e1f7cfe 100644 --- a/usage-example/settings.gradle.kts +++ b/usage-example/settings.gradle.kts @@ -5,7 +5,12 @@ // python-multiplatform library come from mavenLocal: publish them first (see AGENTS.md §16). pluginManagement { repositories { - mavenLocal() + mavenLocal { + content { + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } google { mavenContent { includeGroupAndSubgroups("androidx") @@ -13,14 +18,27 @@ pluginManagement { includeGroupAndSubgroups("com.google") } } - 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() } } dependencyResolutionManagement { repositories { - mavenLocal() + mavenLocal { + content { + excludeGroupAndSubgroups("org.thisisthepy.compose") + excludeGroupAndSubgroups("org.thisisthepy.kotlin") + } + } google { mavenContent { includeGroupAndSubgroups("androidx") @@ -28,7 +46,15 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } - 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() + } + } } } From 69606b732465adb1ac4115f75b597024f562e33f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:36:03 +0900 Subject: [PATCH 17/20] Feat: bump org.thisisthepy.compose Gradle plugin to 1.11.1-ext.3 (#109) --- gradle.properties | 2 +- gradle/libs.versions.toml | 5 +++-- usage-example/gradle.properties | 2 +- usage-example/gradle/libs.versions.toml | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/gradle.properties b/gradle.properties index 2e4995c..1d96e95 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 #Android android.nonTransitiveRClass=true android.useAndroidX=true -# org.thisisthepy.compose:compose-gradle-plugin:1.11.1-ext.2's Maven Central POM marks +# 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c387c2..810df42 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-lifecycle = "2.8.4" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" 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,7 +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.thisisthepy.compose", version.ref = "compose-multiplatform" } -kotlin-native-desktop = { id = "org.thisisthepy.kotlin.native.desktop", 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/usage-example/gradle.properties b/usage-example/gradle.properties index f3abf9c..2f1d93c 100644 --- a/usage-example/gradle.properties +++ b/usage-example/gradle.properties @@ -3,7 +3,7 @@ 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.2's Maven Central POM marks +# 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/usage-example/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml index ec09970..4f02001 100644 --- a/usage-example/gradle/libs.versions.toml +++ b/usage-example/gradle/libs.versions.toml @@ -9,6 +9,7 @@ android-targetSdk = "34" androidx-activityCompose = "1.12.4" 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). @@ -26,8 +27,8 @@ python-multiplatform = { module = "io.github.thisisthepy:python-multiplatform", [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -compose-multiplatform = { id = "org.thisisthepy.compose", version.ref = "compose-multiplatform" } -kotlin-native-desktop = { id = "org.thisisthepy.kotlin.native.desktop", 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" } From 30c10efabc6cb7bc8f6c2c9062b3fbad5646ce33 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:43:20 +0900 Subject: [PATCH 18/20] Feat: switch python-multiplatform coordinates to org.thisisthepy:3.15.0-alpha01 (#111) --- .github/workflows/test.yml | 3 +-- AGENTS.md | 2 +- docs/SPEC.md | 6 +++--- .../multiplatform/toolchain/PublishedPythonVersion.kt | 4 ++-- .../toolchain/typedpython/TypedpythonGate.kt | 2 +- .../toolchain/PublishedPythonVersionTest.kt | 11 ++++++----- .../typedpython/TypedpythonGateArtifactTest.kt | 10 +++++----- .../toolchain/typedpython/TypedpythonGateTest.kt | 4 ++-- usage-example/gradle/libs.versions.toml | 7 +++---- 9 files changed, 24 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11c119f..600e583 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,8 +123,7 @@ jobs: # 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 - # usage-example/gradle/libs.versions.toml (library 3.15.0-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) diff --git a/AGENTS.md b/AGENTS.md index ee30c31..31d0883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,7 +246,7 @@ Prerequisites: `org.thisisthepy.python.multiplatform:packpack:0.1.0` in `~/.m2` `pypackpack` repository), `uv` on `PATH`, and network access: `InstallDependenciesTaskTest` and `InstallerTest` run a real `uv add` against PyPI. `usage-example` additionally needs an Android SDK (`ANDROID_HOME`) and python-multiplatform in `~/.m2`: from a python-multiplatform checkout, -`./gradlew publishAllToMavenLocal` (library `3.15.0-alpha01`, bindings plugin `3.13.0` until its #70). +`./gradlew publishAllToMavenLocal` (`org.thisisthepy:python-multiplatform*` and bindings plugin `3.15.0-alpha01`). Run each module separately (rule 8), with output to a file under `.tmp/`: diff --git a/docs/SPEC.md b/docs/SPEC.md index a8a21ca..6e6b53f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -111,8 +111,8 @@ Where `pythonMultiplatformVersion` comes from, in order (`selectPythonMultiplatf extension's `pythonVersion` (type `python.multiplatform.gradle.EmbeddedPythonVersion`, python-multiplatform#61), read reflectively by name; that project is made to evaluate first (`evaluationDependsOn`); -3. otherwise, for a consumer of the **published** artifact (`io.github.thisisthepy:python-multiplatform*`, - python-multiplatform#61): the first resolvable runtime classpath that declares it is resolved, and +3. otherwise, for a consumer of the **published** artifact (`org.thisisthepy:python-multiplatform*`, + python-multiplatform#61, #95): the first resolvable runtime classpath that declares it is resolved, and the version is the resolved variant's module-metadata attribute `org.thisisthepy.python.version`, else the jar resource `META-INF/python-multiplatform/python.properties` (`pythonVersion`; inside an AAR's `classes.jar`). This resolves a configuration, so it runs only when 1 and 2 give nothing, @@ -540,7 +540,7 @@ bundled. Every `buildPython` and the single host `buildPytho --find-links typedpython== pyrefly==` (`@Input` versions, defaults `0.1.0` and `1.3.2`). `` must hold both wheels for the platform; nothing comes from an index. 2. The **gate artifact**, by default: the `typedpython-gate` zip python-multiplatform-ksp publishes beside - its processor jar, `io.github.thisisthepy:python-multiplatform-ksp:3.13.0:typedpython-gate@zip` + its processor jar, `org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha01:typedpython-gate@zip` (`DEFAULT_TYPEDPYTHON_GATE`, the one place its group and version are written; `-Ptypedpython.gate= ` overrides it). It is resolved leniently from the project's own repositories (python-multiplatform's `publishToMavenLocal` puts it in `mavenLocal()`), unzipped into 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/typedpython/TypedpythonGate.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt index d008ed4..6060e6e 100644 --- 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 @@ -10,7 +10,7 @@ import java.io.File * uses the same). This is the one place both are written; `-Ptypedpython.gate=` * overrides it. */ -const val DEFAULT_TYPEDPYTHON_GATE = "io.github.thisisthepy:python-multiplatform-ksp:3.13.0" +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" 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/typedpython/TypedpythonGateArtifactTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt index 34861fe..95ad4c3 100644 --- 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 @@ -22,8 +22,8 @@ import kotlin.test.assertTrue class TypedpythonGateArtifactTest { private val mavenLocalZip = File( System.getProperty("user.home"), - ".m2/repository/io/github/thisisthepy/python-multiplatform-ksp/3.13.0/" + - "python-multiplatform-ksp-3.13.0-typedpython-gate.zip", + ".m2/repository/org/thisisthepy/python-multiplatform-ksp/3.15.0-alpha01/" + + "python-multiplatform-ksp-3.15.0-alpha01-typedpython-gate.zip", ) @BeforeTest @@ -76,7 +76,7 @@ class TypedpythonGateArtifactTest { 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=io.github.thisisthepy:python-multiplatform-ksp:0.0.0-none").build() + 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) @@ -88,7 +88,7 @@ class TypedpythonGateArtifactTest { 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, "io/github/thisisthepy/python-multiplatform-ksp/9.9.9/python-multiplatform-ksp-9.9.9-typedpython-gate.zip") + 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 -> @@ -110,7 +110,7 @@ class TypedpythonGateArtifactTest { " maven {\n url = uri(\"${repo.toURI()}\")\n metadataSources { artifact() }\n }", ) - val result = runner(dir, "-P$TYPEDPYTHON_GATE_PROPERTY=io.github.thisisthepy:python-multiplatform-ksp:9.9.9").buildAndFail() + 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 index b756cc7..05fada3 100644 --- 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 @@ -41,9 +41,9 @@ class TypedpythonGateTest { @Test fun `the gate is the typedpython-gate zip of python-multiplatform-ksp, from one constant`() { - assertEquals("io.github.thisisthepy:python-multiplatform-ksp:3.13.0", DEFAULT_TYPEDPYTHON_GATE) + assertEquals("org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha01", DEFAULT_TYPEDPYTHON_GATE) assertEquals( - "io.github.thisisthepy:python-multiplatform-ksp:3.13.0:typedpython-gate@zip", + "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha01:typedpython-gate@zip", typedpythonGateNotation(DEFAULT_TYPEDPYTHON_GATE), ) assertEquals("typedpython.gate", TYPEDPYTHON_GATE_PROPERTY) diff --git a/usage-example/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml index 4f02001..8686b1f 100644 --- a/usage-example/gradle/libs.versions.toml +++ b/usage-example/gradle/libs.versions.toml @@ -12,9 +12,8 @@ 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's library, KSP and bindings plugin share one version (its #70, #95, #318, #333). python-multiplatform = "3.15.0-alpha01" -python-multiplatform-plugin = "3.13.0" # The toolchain plugin, from ../toolchain via mavenLocal (`./gradlew :toolchain:publishToMavenLocal`). toolchain = "1.0.0-alpha" @@ -23,7 +22,7 @@ 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" } @@ -31,5 +30,5 @@ compose-multiplatform = { id = "org.thisisthepy.compose", version.ref = "compose 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" } From 9a3dce0d33dae674c78b4fae6645c59f5718c54f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:36:37 +0900 Subject: [PATCH 19/20] Fix: iOS phase runs the install-python.sh copy installPythonXcodeScript bakes (python-multiplatform#320) --- .../src/iosMain/install-python-phase.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/usage-example/src/iosMain/install-python-phase.sh b/usage-example/src/iosMain/install-python-phase.sh index cb7484a..d999823 100755 --- a/usage-example/src/iosMain/install-python-phase.sh +++ b/usage-example/src/iosMain/install-python-phase.sh @@ -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 python-multiplatform-gradle-plugin/src/main/resources/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 From c5a7fe6917d6f03c3ace0e9064dc4cfea6a6e54f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:44:16 +0900 Subject: [PATCH 20/20] Docs: temporary files go to the session scratchpad or .cache/, not .tmp/ The owner abolished the .tmp/ rule on 2026-10-26. --- .gitignore | 1 + AGENTS.md | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) 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/AGENTS.md b/AGENTS.md index 31d0883..53206a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ files) lives **inside this repository's root directory.** | What | Where | |---|---| | Worktrees | `.worktrees/` (git-ignored) | -| Temporary files | `.tmp/` (git-ignored); delete when done | +| Temporary files | the session scratchpad directory, or `.cache/` (git-ignored); delete when done | | CI-only scripts | `.github/scripts/` | Before writing a file, check that its absolute path starts with this repository's root. If it does @@ -35,7 +35,7 @@ Writing to *another* repository is not an exception either. Do it only when told **Never add a new directory (or a new file) at the repository root on your own.** The root layout is the maintainer's: source modules, `docs/`, `gradle/`, `.github/` and the files that tools require there. Work belongs inside an existing module or directory: sources under `src//`, -CI-only scripts under `.github/scripts/`, temporary files under the git-ignored `.tmp/`. If you think +CI-only scripts under `.github/scripts/`, temporary files under the git-ignored `.cache/`. If you think a new top-level entry is needed, propose it (what, why, which alternatives inside existing directories you ruled out) and wait for approval. This was added after unapproved root folders (`ksp-fixtures/`, `tools/`, `kotlin-js-store/`, `iosApp/`, `usage-example/python`) had to be dismantled. @@ -248,13 +248,13 @@ Prerequisites: `org.thisisthepy.python.multiplatform:packpack:0.1.0` in `~/.m2` (`ANDROID_HOME`) and python-multiplatform in `~/.m2`: from a python-multiplatform checkout, `./gradlew publishAllToMavenLocal` (`org.thisisthepy:python-multiplatform*` and bindings plugin `3.15.0-alpha01`). -Run each module separately (rule 8), with output to a file under `.tmp/`: +Run each module separately (rule 8), with output to a file under `.cache/`: ```bash rm -rf toolchain/build/test-results -./gradlew :toolchain:test --rerun --console=plain > .tmp/toolchain-test.log 2>&1; echo "EXIT=$?" +./gradlew :toolchain:test --rerun --console=plain > .cache/toolchain-test.log 2>&1; echo "EXIT=$?" rm -rf toolchain/build/test-results/cliTest -./gradlew :toolchain:cliTest --rerun --console=plain > .tmp/tcl-test.log 2>&1; echo "EXIT=$?" +./gradlew :toolchain:cliTest --rerun --console=plain > .cache/tcl-test.log 2>&1; echo "EXIT=$?" ``` Count results from `/build/test-results/test/*.xml`, not from the log.