From a681e0b1701e8781831a31f178347be84f748e3b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:01:52 +0900 Subject: [PATCH] Feat: Feed PythonStubsTask output to toolchain's typedpythonStubs configuration When a configuration named typedpythonStubs exists (toolchain's plugin creates it), every stubs task's output directory is added to it with the task dependency attached, in either plugin application order. No compile-time dependency on toolchain; nothing is created when the configuration is absent. Red: 3 of 4 failed. Green: 4/4, full plugin suite 151/0. Mutation (wiring call removed): 3 fail again. Closes #27 --- docs/SPEC.md | 5 ++ .../gradle/PythonBindingsPlugin.kt | 17 ++++ .../gradle/TypedPythonStubsWiringTest.kt | 82 +++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 1be46730c..d870b926a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -216,6 +216,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. nothing under `pythonx` and renames nothing; a Pythonic stub product belongs to pythonx-compose. `Status: partial` — `GP/stubs/PyiRenderingTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`; handle-returning stubs are not wrapped. +- **B-8** When a configuration named `typedpythonStubs` exists (toolchain's plugin creates it), every + stubs task's output directory is added to it, with the task dependency travelling with the files, in + either plugin application order. Without that configuration nothing happens and none is created. + `Status: implemented` — `GP/TypedPythonStubsWiringTest.kt` (a `ProjectBuilder` project; the toolchain + side that consumes the configuration is toolchain#23 and is not exercised here). ## 7. Threading and builds 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 bcc2bcad4..763352822 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 @@ -21,6 +21,9 @@ private const val OPTION_EXCLUDE_PACKAGES = "python.multiplatform.excludePackage private const val ROLE_LIBRARY = "library" private const val ROLE_APP = "app" +/** Created by toolchain's plugin (toolchain#23); referenced by name only. */ +private const val TYPEDPYTHON_STUBS_CONFIGURATION = "typedpythonStubs" + /** * Which KSP configurations the processor belongs on. * @@ -495,6 +498,20 @@ class PythonBindingsPlugin : Plugin { outputDirectory.set(project.layout.buildDirectory.dir("generated/pythonStubs/$sourceSetName")) } project.tasks.matching { it.name == "prepareKotlinIdeaImport" }.configureEach { dependsOn(task) } + feedTypedPythonStubs(project, task) + } + + /** + * Adds [task]'s output to toolchain's `typedpythonStubs` configuration, by name only -- there is + * no compile-time dependency on toolchain. `matching` + `configureEach` covers a configuration + * that already exists and one created later, so plugin application order does not matter; if it + * never appears, nothing happens. The provider is `flatMap`ped from the task, so resolving the + * configuration runs the task. + */ + private fun feedTypedPythonStubs(project: Project, task: org.gradle.api.tasks.TaskProvider) { + project.configurations.matching { it.name == TYPEDPYTHON_STUBS_CONFIGURATION }.configureEach { + project.dependencies.add(name, project.files(task.flatMap { it.outputDirectory })) + } } /** diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt new file mode 100644 index 000000000..69916da83 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt @@ -0,0 +1,82 @@ +package python.multiplatform.gradle + +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * SPEC B-8 (python-multiplatform#27): the stubs task output reaches toolchain's `typedpythonStubs` + * configuration by name, with the task dependency attached, whichever side appears first. + */ +class TypedPythonStubsWiringTest { + + private val configurationName = "typedpythonStubs" + + private fun projectWith(sourceSets: List): Project { + val project = ProjectBuilder.builder().build() + val container = project.container(DummySourceSet::class.java) { name -> DummySourceSet(name, project) } + sourceSets.forEach { container.create(it) } + project.extensions.add("kotlin", DummyKotlinExt(container)) + sourceSets.forEach { project.configurations.create("${it}Classpath") } + return project + } + + private fun wire(project: Project, sourceSets: List) { + val extension = project.extensions.create("pythonBindingsUnderTest", PythonBindingsExtension::class.java) + extension.artifactIncludePackages.set(listOf("junit.runner")) + extension.artifactTargets.set(sourceSets.associate { "${it}Classpath" to it }) + PythonBindingsPlugin().configureArtifactBindings(project, extension) + } + + private fun stubsDir(project: Project, sourceSet: String) = + project.layout.buildDirectory.dir("generated/pythonStubs/$sourceSet").get().asFile + + @Test + fun aConfigurationThatExistsBeforeTheWiringReceivesEverySourceSetsStubs() { + val sets = listOf("desktopMain", "androidNativeArm64Main") + val project = projectWith(sets) + project.configurations.create(configurationName) + wire(project, sets) + + val files = project.configurations.getByName(configurationName).files + sets.forEach { assertTrue(stubsDir(project, it) in files, "$it missing from $files") } + } + + @Test + fun aConfigurationCreatedAfterTheWiringReceivesTheStubsToo() { + val sets = listOf("desktopMain", "androidNativeArm64Main") + val project = projectWith(sets) + wire(project, sets) + project.configurations.create(configurationName) + + val files = project.configurations.getByName(configurationName).files + sets.forEach { assertTrue(stubsDir(project, it) in files, "$it missing from $files") } + } + + @Test + fun resolvingTheConfigurationDependsOnTheStubsTask() { + val project = projectWith(listOf("desktopMain")) + val configuration = project.configurations.create(configurationName) + wire(project, listOf("desktopMain")) + + val deps = configuration.buildDependencies.getDependencies(null) + val names = deps.map { it.name } + assertTrue( + names.any { it.startsWith("generatePythonStubs") }, + "the task must travel with the files; dependencies were $names", + ) + } + + @Test + fun withoutTheConfigurationNothingIsCreatedAndNothingFails() { + val project = projectWith(listOf("desktopMain")) + wire(project, listOf("desktopMain")) + + assertNull(project.configurations.findByName(configurationName)) + assertFalse(project.tasks.names.isEmpty()) + } +}