From 1f7aa04a29f6bf46ea0caeac620988ea596d8f1f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:37:42 +0900 Subject: [PATCH 1/3] Docs: Specify PYTHONHOME beside a native image (SPEC L-9, #246) Inside a GraalVM native image, the prefix beside the executable is the fourth packaged source, and stageNativeImagePythonHome copies it there when pythonBindings.nativeImageDirectory is set. --- docs/SPEC.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index a0f8edd8..11dc0776 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -81,7 +81,11 @@ fail, then implement. 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`; `/python-multiplatform-home` when - that directory exists. A prefix taken from a property goes through L-3's check (a catchable + that directory exists; and, inside a GraalVM native image only (`org.graalvm.nativeimage.imagecode` + is `runtime`; on a JVM the executable is `java`), `/python-multiplatform-home` + when that directory exists (#246). The plugin stages that one with `stageNativeImagePythonHome`, a + copy of the same pruned prefix into `/python-multiplatform-home`, + which does nothing unless `nativeImageDirectory` is set. A prefix taken from a property or a directory 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); From 8ef8f9220671275028df8c2595da2deadee9bc4e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:37:42 +0900 Subject: [PATCH 2/3] Test: Pin the prefix beside a native image and its staging task (#246) Library: a native image finds python-multiplatform-home beside its executable; a JVM ignores a directory beside java and never asks for the executable; no directory resolves to nothing; the environment still wins. Plugin: stageNativeImagePythonHome copies the pruned prefix into the native-image directory after stagePythonHome, and copies nothing when the directory is unset. --- .../gradle/PackagedPythonHomeTest.kt | 28 ++++++++++ .../env/PackagedPythonHomeTest.kt | 54 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt index 833ea52d..db7ecad4 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt @@ -101,6 +101,34 @@ class PackagedPythonHomeTest { assertTrue(sync.source.files.none { it.startsWith(fixture.prefix) }, "disabled packaging must copy nothing") } + // ---- beside a native image (issue #246) ---- + + @Test + fun aNativeImageDirectoryGetsThePrunedPrefixBesideTheExecutable() { + val fixture = Fixture() + val images = File(fixture.root, "native-image-out") + fixture.extension.nativeImageDirectory.set(images) + val sync = fixture.project.tasks.getByName("stageNativeImagePythonHome") as Sync + + assertEquals(File(images, PACKAGED_HOME_DIRECTORY), sync.destinationDir) + val dependencies = sync.taskDependencies.getDependencies(sync).map { it.name } + assertTrue("stagePythonHome" in dependencies, "the prefix must be staged first; got $dependencies") + val copied = sync.source.files.map { it.relativeTo(fixture.prefix).invariantSeparatorsPath }.toSet() + assertTrue(fixture.stdlibFile in copied, "the stdlib must be copied; got $copied") + assertTrue(fixture.libraryFile in copied, "the shared library must be copied; got $copied") + assertFalse(fixture.headerFile in copied, "headers are build-time only; got $copied") + assertFalse(fixture.bytecodeFile in copied, "__pycache__ is not copied; got $copied") + } + + @Test + fun withoutANativeImageDirectoryTheTaskCopiesNothing() { + val fixture = Fixture() + val sync = fixture.project.tasks.getByName("stageNativeImagePythonHome") as Sync + assertTrue(sync.source.files.none { it.startsWith(fixture.prefix) }, "an unset directory must copy nothing") + val dependencies = sync.taskDependencies.getDependencies(sync).map { it.name } + assertFalse("stagePythonHome" in dependencies, "an unset directory must not stage; got $dependencies") + } + /** * A project whose Gradle user home already holds a "staged" prefix, at exactly the path * `configurePythonHomeStaging` computes, so the wiring can be read without a download. diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PackagedPythonHomeTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PackagedPythonHomeTest.kt index f8ca69a8..ab3e351a 100644 --- a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PackagedPythonHomeTest.kt +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PackagedPythonHomeTest.kt @@ -40,6 +40,60 @@ class PackagedPythonHomeTest { // ---- resolution order ---- + // ---- beside a native-image executable (issue #246) ---- + + private val inNativeImage = PackagedPythonHome.NATIVE_IMAGE_CODE_PROPERTY to "runtime" + + @Test + fun aNativeImageFindsThePrefixBesideItsExecutable() { + // A native image has no java.home, no Compose resources directory and no launcher to set + // PYTHONHOME; the prefix ships beside the executable, as an iOS app's ships in its bundle. + val resolved = PackagedPythonHome.resolve( + environmentHome = null, + property = properties(inNativeImage), + isDirectory = { it == "/apps/demo/python-multiplatform-home" }, + executablePath = { "/apps/demo/demo" }, + ) + assertEquals("/apps/demo/python-multiplatform-home", resolved?.path) + } + + @Test + fun aJvmIgnoresADirectoryBesideItsExecutableAndNeverAsksForIt() { + // On a JVM the executable is `java`: a directory beside it belongs to the JDK, not to the app. + // And it is not even asked for: `ProcessHandle` does not exist on older Android. + var asked = false + val resolved = PackagedPythonHome.resolve( + environmentHome = null, + property = properties(), + isDirectory = { true }, + executablePath = { asked = true; "/jdk/bin/java" }, + ) + assertNull(resolved) + assertEquals(false, asked, "the executable must not be looked up outside a native image") + } + + @Test + fun aNativeImageWithNoPrefixBesideItResolvesToNothing() { + val resolved = PackagedPythonHome.resolve( + environmentHome = null, + property = properties(inNativeImage), + isDirectory = { false }, + executablePath = { "/apps/demo/demo" }, + ) + assertNull(resolved) + } + + @Test + fun anEnvironmentPythonHomeStillWinsInANativeImage() { + val resolved = PackagedPythonHome.resolve( + environmentHome = "/from/env", + property = properties(inNativeImage), + isDirectory = { true }, + executablePath = { "/apps/demo/demo" }, + ) + assertNull(resolved) + } + @Test fun anEnvironmentPythonHomeWinsAndNothingElseIsDone() { // CPython reads PYTHONHOME itself, and L-3 checks it; a second source would only be a From 30807ab63b5b5e6a7c012671130d54924b1f5654 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:37:42 +0900 Subject: [PATCH 3/3] Fix: Find and stage PYTHONHOME beside a native-image executable (#246) PackagedPythonHome.resolve takes the executable lazily and, inside a native image only (org.graalvm.nativeimage.imagecode = runtime), uses /python-multiplatform-home when it exists. The plugin's stageNativeImagePythonHome Sync copies the same pruned prefix there. Closes #246 --- .../gradle/PythonBindingsPlugin.kt | 23 +++++++++++++++ .../env/PackagedPythonHome.jvm.kt | 29 +++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt index 73b2342b..38bcb9fe 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt @@ -145,6 +145,14 @@ interface PythonBindingsExtension { */ val packagePythonHome: Property + /** + * SPEC L-9 (issue #246): the directory a GraalVM native image of this app is written to. When it + * is set, `stageNativeImagePythonHome` copies the same pruned prefix Compose packaging gets into + * `/python-multiplatform-home`, where the library finds it at run time beside the + * executable. Unset, the task copies nothing. + */ + val nativeImageDirectory: org.gradle.api.file.DirectoryProperty + /** CPython version to stage. Defaults to the one this library was built against. */ val pythonVersion: Property @@ -684,6 +692,21 @@ class PythonBindingsPlugin : Plugin { requireNoEmptyLibraries(destinationDir.resolve(PACKAGED_HOME_DIRECTORY)) } } + + // SPEC L-9 (issue #246): the same prefix beside a native image, wherever the app writes one. + val besideImage = project.provider { extension.nativeImageDirectory.isPresent } + project.tasks.register("stageNativeImagePythonHome", org.gradle.api.tasks.Sync::class.java) { + group = "python" + description = "Copies the CPython prefix beside a GraalVM native image (pythonBindings.nativeImageDirectory)." + dependsOn(java.util.concurrent.Callable { if (besideImage.get()) listOf(stage) else emptyList() }) + from(java.util.concurrent.Callable { if (besideImage.get()) stagedPrefix else emptyList() }) { + include(includes) + exclude(PACKAGED_PREFIX_EXCLUDES) + } + into(extension.nativeImageDirectory.dir(PACKAGED_HOME_DIRECTORY).orElse(project.layout.buildDirectory.dir("tmp/stageNativeImagePythonHome-unset"))) + onlyIf("pythonBindings.nativeImageDirectory is set") { besideImage.get() } + doLast { requireNoEmptyLibraries(destinationDir) } + } } /** diff --git a/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/env/PackagedPythonHome.jvm.kt b/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/env/PackagedPythonHome.jvm.kt index 4cc21bb9..805d696f 100644 --- a/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/env/PackagedPythonHome.jvm.kt +++ b/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/env/PackagedPythonHome.jvm.kt @@ -32,6 +32,12 @@ internal object PackagedPythonHome { /** `PACKAGED_HOME_DIRECTORY` in `python-multiplatform-gradle-plugin`; the two must agree. */ const val DIRECTORY_NAME = "python-multiplatform-home" + /** + * GraalVM sets this to `runtime` inside a native image (and to `buildtime` while building one); + * on a JVM it is absent. Read as a property, so nothing here depends on GraalVM's SDK. + */ + const val NATIVE_IMAGE_CODE_PROPERTY = "org.graalvm.nativeimage.imagecode" + /** A resolved prefix and a human-readable account of where it came from, for error messages. */ data class Resolved(val path: String, val source: String) @@ -40,6 +46,8 @@ internal object PackagedPythonHome { environmentHome = System.getenv("PYTHONHOME"), property = System::getProperty, isDirectory = { File(it).isDirectory }, + // A lambda: `ProcessHandle` is asked only inside a native image, never on Android or a JVM. + executablePath = { ProcessHandle.current().info().command().orElse(null) }, ) /** @@ -53,22 +61,31 @@ internal object PackagedPythonHome { * 3. `<`[COMPOSE_RESOURCES_PROPERTY]`>/`[DIRECTORY_NAME], **only if that directory exists**: * every Compose app has the property, including one whose build did not package a prefix, * and that app must behave exactly as it did before this existed. + * 4. Inside a GraalVM native image only ([NATIVE_IMAGE_CODE_PROPERTY] is `runtime`), + * `/`[DIRECTORY_NAME], **only if that directory exists** + * (issue #246). A native image has no `java.home`, no Compose resources directory and no + * launcher to set `PYTHONHOME`, so its prefix ships beside it, as an iOS app's ships in its + * bundle. On a JVM the executable is `java`, and a directory beside it is the JDK's. */ fun resolve( environmentHome: String?, property: (String) -> String?, isDirectory: (String) -> Boolean, + executablePath: () -> String? = { null }, ): Resolved? { if (!environmentHome.isNullOrBlank()) return null property(HOME_PROPERTY)?.takeIf { it.isNotBlank() }?.let { return Resolved(it, "system property $HOME_PROPERTY") } - val resources = property(COMPOSE_RESOURCES_PROPERTY)?.takeIf { it.isNotBlank() } ?: return null - val candidate = File(resources, DIRECTORY_NAME).path - return if (isDirectory(candidate)) { - Resolved(candidate, "$DIRECTORY_NAME under system property $COMPOSE_RESOURCES_PROPERTY") - } else { - null + property(COMPOSE_RESOURCES_PROPERTY)?.takeIf { it.isNotBlank() }?.let { resources -> + val candidate = File(resources, DIRECTORY_NAME).path + if (isDirectory(candidate)) { + return Resolved(candidate, "$DIRECTORY_NAME under system property $COMPOSE_RESOURCES_PROPERTY") + } } + if (property(NATIVE_IMAGE_CODE_PROPERTY) != "runtime") return null + val executable = executablePath() ?: return null + val candidate = File(File(executable).absoluteFile.parentFile, DIRECTORY_NAME).path + return if (isDirectory(candidate)) Resolved(candidate, "$DIRECTORY_NAME beside the native image $executable") else null } }