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
6 changes: 5 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`; `<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
that directory exists; and, inside a GraalVM native image only (`org.graalvm.nativeimage.imagecode`
is `runtime`; on a JVM the executable is `java`), `<directory of the executable>/python-multiplatform-home`
when that directory exists (#246). The plugin stages that one with `stageNativeImagePythonHome`, a
copy of the same pruned prefix into `<pythonBindings.nativeImageDirectory>/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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ interface PythonBindingsExtension {
*/
val packagePythonHome: Property<Boolean>

/**
* 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
* `<this>/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<String>

Expand Down Expand Up @@ -684,6 +692,21 @@ class PythonBindingsPlugin : Plugin<Project> {
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<Any>() }) {
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) }
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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) },
)

/**
Expand All @@ -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 of [executablePath]>/`[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
}
}