From 0f12c7b8f39ad2425f94462a367e0edb2a32cc9d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:04:39 +0900 Subject: [PATCH 1/2] Feat: Publish the embedded CPython version for build tools (a) the :python-multiplatform project exposes the extension pythonMultiplatform (EmbeddedPythonVersion: pythonVersion, freeThreaded, majorMinor); (b) published artifacts carry it as module-metadata attributes org.thisisthepy.python.version and .free-threaded, and as META-INF/python-multiplatform/python.properties in the desktop jar and the Android AAR (through AGP's java resources, since KMP androidMain resources do not reach the AAR). toolchain checks compileSdk against it instead of fetching a second interpreter. --- docs/SPEC.md | 5 ++ docs/platforms/python-version-acquisition.md | 30 ++++++++ .../gradle/EmbeddedPythonVersion.kt | 59 +++++++++++++++ .../gradle/EmbeddedPythonVersionTest.kt | 73 +++++++++++++++++++ python-multiplatform/build.gradle.kts | 25 +++++++ .../env/PublishedPythonVersionResourceTest.kt | 23 ++++++ 6 files changed, 215 insertions(+) create mode 100644 python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt create mode 100644 python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PublishedPythonVersionResourceTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 19603ed2d..107532c95 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -258,6 +258,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `Provider` 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 diff --git a/docs/platforms/python-version-acquisition.md b/docs/platforms/python-version-acquisition.md index 611b0bb62..8e005a87b 100644 --- a/docs/platforms/python-version-acquisition.md +++ b/docs/platforms/python-version-acquisition.md @@ -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() +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") +} +``` diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt new file mode 100644 index 000000000..2b4a714fe --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt @@ -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) + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt new file mode 100644 index 000000000..cadb04eb2 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt @@ -0,0 +1,73 @@ +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 { EmbeddedPythonVersion.parse("freeThreaded=false\n") } + } + + @Test + fun majorMinor() { + assertEquals("3.14", EmbeddedPythonVersion("3.14.7", false).majorMinor) + assertFailsWith { 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) + val resolvable = project.configurations.create("someElements") { isCanBeConsumed = false } + val other = project.configurations.create("implementation") { isCanBeConsumed = true } + 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")) + } +} diff --git a/python-multiplatform/build.gradle.kts b/python-multiplatform/build.gradle.kts index 38f55e8a9..6f8a0ac0d 100644 --- a/python-multiplatform/build.gradle.kts +++ b/python-multiplatform/build.gradle.kts @@ -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 @@ -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" // ================================================================================================= @@ -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 @@ -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") diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PublishedPythonVersionResourceTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PublishedPythonVersionResourceTest.kt new file mode 100644 index 000000000..9ce815401 --- /dev/null +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PublishedPythonVersionResourceTest.kt @@ -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()) + } +} From a96c607d08dbb07eedb0f6e3fd3a67c2875f7324 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:33:04 +0900 Subject: [PATCH 2/2] Test: State what create() lets the attribute filter see Gradle adds a configuration before running create()'s block, so the filter sees the defaults; the test now checks configurations not named *Elements instead of a resolvable *Elements one, which KMP and AGP never create. --- .../multiplatform/gradle/EmbeddedPythonVersionTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt index cadb04eb2..09d1c5512 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt @@ -56,8 +56,12 @@ class EmbeddedPythonVersionTest { fun nonConsumableConfigurationsAreLeftAlone() { val project = ProjectBuilder.builder().build() EmbeddedPythonVersion("3.14.7", false).register(project) - val resolvable = project.configurations.create("someElements") { isCanBeConsumed = false } + // 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))) }