Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ plugins {
`stagePythonHome` downloads the matching CPython build, verifies it against the release's
`SHA256SUMS`, caches it machine-wide and sets `PYTHONHOME` on `run` and `test`. A `PYTHONHOME` you set
yourself is never touched; disable staging with `pythonBindings { stagePythonHome.set(false) }`.
A packaged end-user app still has to ship a prefix and set `PYTHONHOME` itself.
A Compose Desktop app packaged with `createDistributable` (or a `package*` task) carries the staged
stdlib and `libpython` in its resources and starts without `PYTHONHOME`
(`pythonBindings { packagePythonHome.set(false) }` turns that off); any other packaging can name its
prefix with `-Dpython.multiplatform.home=...`. See `docs/platforms/desktop-packaged-app.md`.
</details>

<details>
Expand Down
18 changes: 18 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ fail, then implement.
extracts it. `Status: partial` — exercised by the shared suite on the simulator; no iOS-specific
lifecycle test beyond `PM/iosSimulatorArm64Test/.../AsyncioAvailabilityProbeTest.kt`.
- **L-8** Initialisation sets `builtins.compiled` to an identity decorator (an existing one is kept), so `@compiled` needs no import on any platform (N-7, issue #42). `Status: implemented` — `PM/commonTest/.../ffi/BuiltinCompiledTest.kt`.
- **L-9** Desktop: a packaged application starts with no `PYTHONHOME` in its environment (issue #60,
ROADMAP §15.4). The Gradle plugin copies the prefix `stagePythonHome` stages into Compose Desktop's
application resources (every `prepare*AppResources` task, so `createDistributable` and the
`package*` tasks built on it) under `python-multiplatform-home/`: the standard library
(`lib/python<X.Y>/`, or `Lib/` and `DLLs/` on Windows) and the shared library, without
`__pycache__`, headers, `bin/` or Tcl/Tk. `pythonBindings { packagePythonHome.set(false) }` turns
this off. At run time, before `Py_Initialize`, desktop takes the first of: `PYTHONHOME` from the
environment (CPython reads it itself and nothing else happens); the system property
`python.multiplatform.home`; `<compose.application.resources.dir>/python-multiplatform-home` when
that directory exists. A prefix taken from a property goes through L-3's check (a catchable
`IllegalStateException` naming the path and where it came from), is handed to CPython with
`Py_SetPythonHome`, and is where `libpython` is loaded from when it contains one.
`Status: partial` — `GP/PackagedPythonHomeTest.kt` (what is copied, which tasks, the switch);
`PM/desktopTest/.../env/PackagedPythonHomeTest.kt` (resolution order, `wchar_t` encoding, library
lookup) and `PackagedPythonHomeLaunchTest.kt` (a child JVM with `PYTHONHOME` removed imports `json`
from the prefix a property names, or from a Compose resources directory). The
`createDistributable` run itself is a manual check —
`docs/platforms/desktop-packaged-app.md`.

## 2. Low-level C API (downcall surface)

Expand Down
6 changes: 4 additions & 2 deletions docs/locale/README_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ plugins {

`stagePythonHome` 이 맞는 CPython 빌드를 내려받아 릴리스의 `SHA256SUMS` 로 검증하고, 기기 전체가 쓰는
캐시에 풀고, `run` 과 `test` 에 `PYTHONHOME` 을 설정한다. 직접 설정한 `PYTHONHOME` 은 건드리지 않는다.
`pythonBindings { stagePythonHome.set(false) }` 로 끌 수 있다. 최종 사용자용으로 패키징한 앱은 여전히
prefix 를 함께 배포하고 `PYTHONHOME` 을 직접 설정해야 한다.
`pythonBindings { stagePythonHome.set(false) }` 로 끌 수 있다. `createDistributable`(또는 `package*`
태스크)로 패키징한 Compose Desktop 앱은 스테이징된 stdlib 와 `libpython` 을 리소스에 담아 `PYTHONHOME`
없이 시작한다(`pythonBindings { packagePythonHome.set(false) }` 로 끈다). 그 밖의 패키징은
`-Dpython.multiplatform.home=...` 으로 prefix 를 지정한다. `docs/platforms/desktop-packaged-app.md` 참고.
</details>

<details>
Expand Down
121 changes: 121 additions & 0 deletions docs/platforms/desktop-packaged-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Desktop: the standard library in a packaged application

SPEC L-9, issue #60, ROADMAP §15.4.

During development `stagePythonHome` downloads a python-build-standalone prefix once per machine and
the plugin sets `PYTHONHOME` on every `JavaExec` and `Test` task (SPEC L-4). A packaged application
is started by a launcher, not by Gradle, and nothing put `PYTHONHOME` in its environment, so
`Py_Initialize()` aborted with `Failed to import encodings module`. This page records how a packaged
app now gets its prefix, why it was done this way, and how to check it.

## What happens

**Build time** (`python-multiplatform-gradle-plugin`, `PythonBindingsPlugin.configurePackagedPythonHome`).
Every Compose Desktop `prepare*AppResources` task — the `Sync` that `createDistributable`,
`packageDmg`/`packageMsi`/`packageDeb` and their `Release` variants take their resources from —
depends on `stagePythonHome` and copies part of the staged prefix into `python-multiplatform-home/`:

| Host | Copied (relative to the prefix) |
|---|---|
| macOS, Linux | `lib/python<X.Y>/**`, `lib/libpython<X.Y>.*` (`<X.Y>t` for a free-threaded build) |
| Windows | `Lib/**`, `DLLs/**`, `*.dll` |

`**/__pycache__/**`, `include/`, `bin/`, `lib/pkgconfig` and Tcl/Tk are not copied. For 3.14.7
macos-aarch64 that is 1,195 files and 39.6 MB (19.4 MB of it `libpython3.14.dylib`) of a 68.9 MB
prefix, measured on the staged prefix of this machine.

In the app image the directory is `$APPDIR/resources/python-multiplatform-home`; on macOS
`<App>.app/Contents/app/resources/python-multiplatform-home`, on Linux
`<app>/lib/app/resources/python-multiplatform-home`.

**Run time** (`python-multiplatform`). `PackagedPythonHome.resolve()` (`jvmMain`) takes the first of:

1. `PYTHONHOME` in the environment — then nothing else happens; CPython reads it itself.
2. The system property `python.multiplatform.home`, used even if the path does not exist, so that a
wrong value is reported by name.
3. `<compose.application.resources.dir>/python-multiplatform-home`, only if that directory exists.
Compose sets `-Dcompose.application.resources.dir=$APPDIR/resources` on every launcher it builds.

`Python3.initialize()` calls `applyPackagedPythonHome()` right after the `PYTHONHOME` pre-flight
check (SPEC L-3). On desktop it checks the resolved prefix with the same `PythonHomeCheck`
(throwing a catchable `IllegalStateException` that names the path and the property it came from)
and passes it to `Py_SetPythonHome` as a `wchar_t` string. Before that, when the bindings load,
`manager.loadLibPython` loads `libpython` from the same prefix instead of extracting the classpath
copy into the working directory.

## Why this design

**Into the app image, not a jar resource extracted at startup.** The consumer's `python/` payload is
a jar resource that `ClasspathPayload` extracts to a temp directory with a digest stamp. The same
mechanism for the stdlib was considered and not taken:

- The stdlib is 1,195 files / 39.6 MB uncompressed. Extracted from a jar it is shipped twice (once
compressed in the app, once extracted per user), and the first launch pays for writing all of it.
Put into the app image, it is on disk at install time: no extraction ever, so there is no stamp to
check on each launch and no cold-start cost at all.
- The payload's cache lives under `java.io.tmpdir`, which operating systems clean up on their own
schedule. A stamp that survives a partial clean-up which removed part of `encodings/` would be an
abort at `Py_Initialize` rather than a re-extraction. (Not observed here; a reason not to take the
risk for the one tree whose absence is fatal.)
- `libpython` has to be a file for `System.load` anyway. Loading it from the classpath copy means
extracting 19.4 MB into the working directory and finding it through `java.library.path` — this
repo's tests pass `-Djava.library.path=.`, a launched app has no such flag and may have `/` as its
working directory. Loading it from the packaged prefix also pairs it with its own stdlib.
- jpackage builds an image for the host it runs on, so only one platform's prefix is ever packaged;
the cost `PythonHomeStaging`'s KDoc measured against shipping four platforms' stdlibs in the
*library* jar does not arise.

**Into Compose's resources task, not `nativeDistributions.appResourcesRootDir`.** That property is a
single directory the consumer may already use for their own files; setting it from the plugin would
replace theirs. Adding a `from` to the `Sync` that assembles the resources composes with whatever
the consumer configured, and matching the task by name keeps the plugin free of a compile-time
dependency on the Compose Gradle plugin (as the wasm wiring matches `wasmJsProcessResources`).

**`Py_SetPythonHome`, not an environment variable.** The launcher accepts `--java-options` and no
environment, and a JVM cannot set its own (`desktopMain/README.md`). `Py_SetPythonHome` is part of
the Stable ABI (deprecated since 3.11 in favour of `PyConfig.home`, whose struct layout the Stable
ABI does not promise) and writes CPython's path configuration directly, so `Py_Initialize()` uses it
whenever `PYTHONHOME` is unset.

## Other packagers

Anything that is not Compose's `createDistributable` (plain `jpackage`, Conveyor, an uber jar) gets
no copy from the plugin. Copy the same subset of the staged prefix (`stagePythonHome`'s output,
`~/.gradle/python-multiplatform/python-home/<version>+<release>/<platform>/python`) next to the app
and pass `-Dpython.multiplatform.home=<path>`; jpackage expands `$APPDIR` inside `--java-options`.

## Limits

- No `.pyc` is shipped. CPython compiles what it imports on first use and writes `__pycache__` into
the app image where it can; where it cannot (a read-only install), it compiles on every launch.
- `pythonBindings { packagePythonHome.set(false) }` turns the copy off. `stagePythonHome.set(false)`
does not: the packaged prefix must be the one the library was built against regardless of what a
developer's own `PYTHONHOME` points at, so the task is registered (lazily) either way.
- The library needs `--enable-preview` on JDK 21 (`java.lang.foreign` is a preview API there) and
`--enable-native-access=ALL-UNNAMED`. A packaged app gets JVM options only from
`compose.desktop.application { jvmArgs(...) }`; the sample's `build.gradle.kts` sets them for
`JavaExec` tasks only.

## How to check it

Run on the build host, from the repository root, with no `PYTHONHOME` in the launched app's
environment:

```bash
./gradlew :sample:createDistributable --console=plain > .tmp/dist.log 2>&1; echo "EXIT=$?"

# The prefix is in the image (macOS paths; Linux: sample/build/compose/binaries/main/app/<name>/lib/app/resources)
APP=sample/build/compose/binaries/main/app/org.thisisthepy.python.multiplatform.demo.app
ls "$APP/Contents/app/resources/python-multiplatform-home/lib" # libpython3.14.dylib python3.14
test -f "$APP/Contents/app/resources/python-multiplatform-home/lib/python3.14/encodings/__init__.py" && echo STDLIB_OK
find "$APP/Contents/app/resources/python-multiplatform-home" -name __pycache__ | wc -l # 0

# Launch with PYTHONHOME removed; the sample prints its runtime line and runs asyncio before the window
env -u PYTHONHOME -u PYTHON_MULTIPLATFORM_LIBPYTHON \
"$APP/Contents/MacOS/org.thisisthepy.python.multiplatform.demo" > .tmp/packaged-run.log 2>&1 &
```

Expected in `.tmp/packaged-run.log`: `runtime : 3.14.7 ...` and the `await` lines (which import
`asyncio`), with no `Fatal Python error`. The window stays open; close it or kill the process.
Running from `/` as the working directory (`cd / && env -u PYTHONHOME "$OLDPWD/$APP/..."`) also
checks that nothing is extracted into the working directory.
13 changes: 10 additions & 3 deletions docs/roadmap/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,16 @@ Old §15a, §15b, §15d, §15f, §15g, §15h are closed; §15e item 4 is closed
Windows-x86_64 are pinned as string assertions in `PythonHomeStagingTest`; Windows' `Lib/`+`DLLs/`
layout and `linux-aarch64` (no `libpython` in `desktopJar`, relies on `manager.loadFromSidecar`) have
never executed.
- **15.4 A packaged desktop application carries no CPython prefix — open.** `stagePythonHome` serves
`run`/`test` on a developer machine; nothing makes `jpackage`, Conveyor or an installer ship a
prefix and set `PYTHONHOME` for an end user.
- **15.4 A packaged desktop application carries no CPython prefix — implemented, end-to-end check
pending (issue #60, SPEC L-9).** The plugin copies the staged prefix's stdlib and `libpython` into
Compose Desktop's `prepare*AppResources` (so `createDistributable` and `package*`) as
`python-multiplatform-home/`; at run time the library resolves it through
`compose.application.resources.dir` (or `-Dpython.multiplatform.home`) and hands it to CPython with
`Py_SetPythonHome`. A child-JVM test with `PYTHONHOME` removed covers the run-time half
(`PM/desktopTest/.../env/PackagedPythonHomeLaunchTest.kt`); the packaged sample run is
`docs/platforms/desktop-packaged-app.md`. Left: Conveyor and non-Compose jpackage need the
property set by hand; no `.pyc` is shipped; only the build host's platform is packaged (jpackage
builds for its host anyway).
- **15.5 The Android AAR duplicates the pure-Python stdlib per ABI — open (measured option, not
taken).** 790 files / 11.91 MB are byte-identical between `arm64-v8a` and `x86_64`; de-duplicating
saves 3.16 MB compressed but changes the published asset layout `<abi>/lib/python<X.Y>` that
Expand Down
Loading
Loading