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: 5 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
`Provider<Directory>` carrying the extraction task (`cpythonIncludeDirectories` extension; see
`docs/platforms/python-version-acquisition.md` §7). `Status: partial` (only the configured flavour is
extracted) — `GP/CPythonIncludeDirectoriesTest.kt`.
- **T-4** `python-multiplatform` publishes the CPython version it embeds: extension `pythonMultiplatform`,
Gradle attributes `org.thisisthepy.python.version` / `org.thisisthepy.python.free-threaded` on every
consumable `*Elements` configuration, and the resource `META-INF/python-multiplatform/python.properties`
in the jar and AAR (see `docs/platforms/python-version-acquisition.md` "Published version") —
`GP/EmbeddedPythonVersionTest.kt`.

## 8. Measurement

Expand Down
30 changes: 30 additions & 0 deletions docs/platforms/python-version-acquisition.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,33 @@ The same extension exposes the library to link against (issue #56):
Android has no free-threaded build, so that combination throws. `isLinkAvailable(target, flavour)`
tests without throwing; the other flavour fails naming `-PpythonFreeThreaded`, as for `includeDir`.

## Published version

A consumer that builds against `python-multiplatform` must know which CPython it embeds (issue #61).
The answer is published three ways, all derived from `pythonVersion` / `pythonFreeThreaded` in
`gradle.properties`.

* **Extension** `pythonMultiplatform` (`python.multiplatform.gradle.EmbeddedPythonVersion`):
`pythonVersion` (`3.14.7`), `majorMinor` (`3.14`), `freeThreaded`.
* **Gradle attributes** on every consumable `*Elements` configuration (so they land in the published
module metadata): `org.thisisthepy.python.version` and `org.thisisthepy.python.free-threaded`
(both `String`; the latter is `"true"`/`"false"`).
* **Resource** `META-INF/python-multiplatform/python.properties` (`pythonVersion=`, `freeThreaded=`),
inside the desktop jar and the Android AAR's `classes.jar`.

```kotlin
// composite build (includeBuild): read the extension
evaluationDependsOn(":python-multiplatform")
val embedded = project(":python-multiplatform").extensions.getByType<EmbeddedPythonVersion>()
println(embedded.majorMinor)

// published artefact: read the resource from the classpath
val text = Thread.currentThread().contextClassLoader
.getResourceAsStream(EmbeddedPythonVersion.RESOURCE_PATH)!!.use { it.readBytes().decodeToString() }
val embedded = EmbeddedPythonVersion.parse(text)

// published artefact: require a matching variant through the attributes
configurations.named("desktopRuntimeClasspath") {
attributes.attribute(Attribute.of("org.thisisthepy.python.version", String::class.java), "3.14.7")
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package python.multiplatform.gradle

import org.gradle.api.Project

/**
* Which CPython a published `python-multiplatform` build embeds (issue #61). Published three ways so
* a consumer never has to guess:
*
* - as the project extension `pythonMultiplatform` (composite builds),
* - as the Gradle attributes [ATTRIBUTE_VERSION] / [ATTRIBUTE_FREE_THREADED] on every consumable
* `*Elements` configuration (published Gradle module metadata),
* - as the classpath resource [RESOURCE_PATH] (read from the jar/AAR, no Gradle needed).
*/
class EmbeddedPythonVersion(val pythonVersion: String, val freeThreaded: Boolean) {

/** `"3.14"` for `"3.14.7"`. */
val majorMinor: String
get() {
val parts = pythonVersion.split('.')
require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" }
return "${parts[0]}.${parts[1]}"
}

fun renderProperties(): String = "pythonVersion=$pythonVersion\nfreeThreaded=$freeThreaded\n"

/** Adds the two attributes to every consumable `*Elements` configuration, including ones created later. */
fun publishAttributes(project: Project) {
project.configurations
.matching { it.isCanBeConsumed && it.name.endsWith("Elements") }
.configureEach {
attributes.attribute(org.gradle.api.attributes.Attribute.of(ATTRIBUTE_VERSION, String::class.java), pythonVersion)
attributes.attribute(
org.gradle.api.attributes.Attribute.of(ATTRIBUTE_FREE_THREADED, String::class.java),
freeThreaded.toString(),
)
}
}

fun register(project: Project) {
project.extensions.add(EXTENSION_NAME, this)
publishAttributes(project)
}

companion object {
const val EXTENSION_NAME = "pythonMultiplatform"
const val ATTRIBUTE_VERSION = "org.thisisthepy.python.version"
const val ATTRIBUTE_FREE_THREADED = "org.thisisthepy.python.free-threaded"
const val RESOURCE_PATH = "META-INF/python-multiplatform/python.properties"

fun parse(text: String): EmbeddedPythonVersion {
val map = text.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && '=' in it }
.associate { it.substringBefore('=').trim() to it.substringAfter('=').trim() }
val version = requireNotNull(map["pythonVersion"]) { "python.properties has no pythonVersion" }
return EmbeddedPythonVersion(version, map["freeThreaded"]?.toBoolean() ?: false)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package python.multiplatform.gradle

import org.gradle.api.attributes.Attribute
import org.gradle.testfixtures.ProjectBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame

/** Issue #61: the embedded CPython version as extension, attributes and resource. */
class EmbeddedPythonVersionTest {

private fun attr(name: String) = Attribute.of(name, String::class.java)

@Test
fun propertiesRoundTrip() {
val v = EmbeddedPythonVersion("3.14.7", true)
val back = EmbeddedPythonVersion.parse(v.renderProperties())
assertEquals("3.14.7", back.pythonVersion)
assertEquals(true, back.freeThreaded)
assertEquals("pythonVersion=3.14.7\nfreeThreaded=true\n", v.renderProperties())
}

@Test
fun keysAreStable() {
assertEquals("org.thisisthepy.python.version", EmbeddedPythonVersion.ATTRIBUTE_VERSION)
assertEquals("org.thisisthepy.python.free-threaded", EmbeddedPythonVersion.ATTRIBUTE_FREE_THREADED)
assertEquals("META-INF/python-multiplatform/python.properties", EmbeddedPythonVersion.RESOURCE_PATH)
}

@Test
fun parseRejectsMissingVersion() {
assertFailsWith<IllegalArgumentException> { EmbeddedPythonVersion.parse("freeThreaded=false\n") }
}

@Test
fun majorMinor() {
assertEquals("3.14", EmbeddedPythonVersion("3.14.7", false).majorMinor)
assertFailsWith<IllegalArgumentException> { EmbeddedPythonVersion("3", false).majorMinor }
}

@Test
fun attributesLandOnConsumableElementsIncludingLaterOnes() {
val project = ProjectBuilder.builder().build()
val early = project.configurations.create("apiElements") { isCanBeConsumed = true }
EmbeddedPythonVersion("3.14.7", false).register(project)
val late = project.configurations.create("desktopRuntimeElements") { isCanBeConsumed = true }
for (c in listOf(early, late)) {
assertEquals("3.14.7", c.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_VERSION)))
assertEquals("false", c.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_FREE_THREADED)))
}
}

@Test
fun nonConsumableConfigurationsAreLeftAlone() {
val project = ProjectBuilder.builder().build()
EmbeddedPythonVersion("3.14.7", false).register(project)
// Only consumable `*Elements` configurations carry the attributes. Gradle's `create(name) {}`
// adds the configuration before running its block, so the decision is taken with the
// defaults; a configuration that is not named `*Elements` is never touched. (Every KMP/AGP
// `*Elements` configuration is consumable.)
val other = project.configurations.create("implementation") { isCanBeConsumed = true }
val resolvable = project.configurations.create("compileClasspath") { isCanBeConsumed = false }
assertNull(resolvable.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_VERSION)))
assertNull(other.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_VERSION)))
}

@Test
fun extensionIsRegisteredUnderItsName() {
val project = ProjectBuilder.builder().build()
val v = EmbeddedPythonVersion("3.14.7", false)
v.register(project)
assertEquals("pythonMultiplatform", EmbeddedPythonVersion.EXTENSION_NAME)
assertSame(v, project.extensions.getByName("pythonMultiplatform"))
}
}
25 changes: 25 additions & 0 deletions python-multiplatform/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.jetbrains.kotlin.konan.target.linker
import python.multiplatform.gradle.CPythonFlavour
import python.multiplatform.gradle.CPythonIncludeDirectories
import python.multiplatform.gradle.CPythonIncludeLayout
import python.multiplatform.gradle.EmbeddedPythonVersion
import python.multiplatform.gradle.GenerateWasmProxyExportsTask


Expand Down Expand Up @@ -638,6 +639,27 @@ extensions.add(
},
)

/**
* Public build output (issue #61): the CPython version this build embeds, as the `pythonMultiplatform`
* extension, as Gradle attributes on the published variants, and as a classpath resource
* (`META-INF/python-multiplatform/python.properties`) in the jar and the AAR. See
* `docs/platforms/python-version-acquisition.md` "Published version".
*/
val embeddedPythonVersion = EmbeddedPythonVersion(configuredPythonVersion, pythonFreeThreaded)
embeddedPythonVersion.register(project)

val generatePythonProperties by tasks.registering {
val outputDir = layout.buildDirectory.dir("generated/python-properties")
val content = embeddedPythonVersion.renderProperties()
inputs.property("content", content)
outputs.dir(outputDir)
doLast {
val file = outputDir.get().asFile.resolve(EmbeddedPythonVersion.RESOURCE_PATH)
file.parentFile.mkdirs()
file.writeText(content)
}
}

val androidBuildDir = "$projectDir/build/android"

// =================================================================================================
Expand Down Expand Up @@ -1225,6 +1247,7 @@ kotlin {
jvmTest.dependsOn(commonTest)
val desktopMain by getting {
resources.srcDirs("src/desktopMain/resources")
resources.srcDir(generatePythonProperties)
// `reachability-metadata.json` is generated, not checked in -- see
// GenerateReachabilityMetadata above. Wiring it as a resource srcDir (through the
// task's own output provider, so the dependency is inferred) is what puts it on the
Expand Down Expand Up @@ -2548,6 +2571,8 @@ android {
compileSdk = libs.versions.android.compileSdk.get().toInt()

sourceSets["main"].assets.srcDirs("src/androidMain/assets", "$androidBuildDir/assets")
// KMP androidMain resources do not reach the AAR's classes.jar; AGP's main resources do.
sourceSets["main"].resources.srcDir(generatePythonProperties)
sourceSets["androidTest"].assets.srcDirs("$androidBuildDir/assets")
sourceSets["debug"].jniLibs.srcDirs("src/androidMain/jniLibs",
"$androidBuildDir/jniLibs", "$androidBuildDir/debug/jniLibs")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package python.multiplatform.env

import python.multiplatform.BuildConfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

/** Issue #61: the published jar carries the CPython version it embeds, agreeing with BuildConfig. */
class PublishedPythonVersionResourceTest {
// Same path as resourcePath; that class lives in the build plugin and is
// not on the test classpath.
private val resourcePath = "META-INF/python-multiplatform/python.properties"

@Test
fun resourceMatchesBuildConfig() {
val stream = javaClass.classLoader.getResourceAsStream(resourcePath)
assertNotNull(stream, "${resourcePath} is not on the classpath")
val props = stream.use { it.readBytes().decodeToString() }.lines()
.filter { '=' in it }.associate { it.substringBefore('=').trim() to it.substringAfter('=').trim() }
assertEquals(BuildConfig.pythonVersion, props["pythonVersion"])
assertEquals(BuildConfig.pythonFreeThreaded, props["freeThreaded"].toBoolean())
}
}
Loading