diff --git a/docs/SPEC.md b/docs/SPEC.md index e0c47f2d9..b37f89e24 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -319,6 +319,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. declared type even when that type is nested in the object (`Alignment.End: Horizontal`, a class of the object's own module; elsewhere `androidx.compose.ui.Alignment.Horizontal`); the object's own type stays `Any` (#53, `GP/stubs/ObjectStubTest.kt`). An extension property whose receiver is a type nested in such an object is a `@property` of that nested class in the object's module (`Icons.Default: Filled`, `Filled.Add: ImageVector`, #68), not a marker comment in the package module. + A stub class lists **every** Kotlin supertype that has a stub class as a base, not only the first + (`Arrangement.HorizontalOrVertical(Horizontal, Vertical)`, so a `Vertical` slot accepts it): `Any` + and `java.lang.*` are dropped, and a base already implied by another listed base's known ancestry + is dropped, so the order is a valid Python MRO (#71, `GP/stubs/SupertypeStubTest.kt`, and the + `Column`/`Row` `SpaceBetween` lines of `tools/stubs/consumer.py`). - **B-8** CI generates the stubs over the Compose version the build resolves and publishes them (`.github/workflows/stubs.yml`): workflow artifact `kotlin-stubs` on every push to `develop`, with a README naming the Compose version and the commit, and `kotlin-stubs.zip` on every `v*` tag's release. diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt index 5acd28555..15263b0ec 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt @@ -506,6 +506,24 @@ private class StubRenderer(declarations: List) { .mapNotNull { d -> methodReceiver(d)?.let { it to d } } .groupBy({ it.first }, { it.second }) + /** + * Issue #71: every Kotlin supertype that has a stub class is a base, not just the first one + * (`Arrangement.HorizontalOrVertical` is both a `Horizontal` and a `Vertical`). The ancestry is + * nearest-first and transitive, so a base is dropped when another candidate's own known ancestry + * already contains it (Python would reject `class C(A, B)` with B a subclass of A as an + * inconsistent MRO, and the repeat adds nothing); what remains keeps the ancestry's order. + * `kotlin.Any` and `java.lang.*` are `object`, which every Python class already has. + */ + private fun stubBasesOf(qualifiedName: String, ctx: ModuleOut): List { + val candidates = supertypes[qualifiedName].orEmpty() + .filter { it != qualifiedName && it != "kotlin.Any" && !it.startsWith("java.lang.") && classRefOf(it) != null } + .distinct() + val kept = candidates.filter { base -> + candidates.none { other -> other != base && base in supertypes[other].orEmpty() && other !in supertypes[base].orEmpty() } + } + return kept.map { classReference(it, null, ctx) }.filter { it != ANY }.distinct() + } + private fun renderClass(qualifiedName: String) { val ref = resolveRef(qualifiedName) ?: return val ctx = out(ref.pkg) @@ -518,12 +536,7 @@ private class StubRenderer(declarations: List) { } else { "Kotlin: $qualifiedName" } - supertypes[qualifiedName] - ?.firstOrNull { it != qualifiedName && it != "kotlin.Any" && !it.startsWith("java.lang.") && classRefOf(it) != null } - ?.let { base -> - val reference = classReference(base, null, ctx) - if (reference != ANY) node.bases = reference - } + stubBasesOf(qualifiedName, ctx).takeIf { it.isNotEmpty() }?.let { node.bases = it.joinToString(", ") } constructorsByClass[qualifiedName]?.let { group -> group.forEachIndexed { index, d -> diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/SupertypeStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/SupertypeStubTest.kt new file mode 100644 index 000000000..910c7b743 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/SupertypeStubTest.kt @@ -0,0 +1,57 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #71: a stub class lists every Kotlin supertype that has a stub class, in a valid MRO. + * `Arrangement.HorizontalOrVertical` is both a `Horizontal` and a `Vertical`; listing only the first + * made mypy reject it in a `Vertical` slot. + */ +class SupertypeStubTest { + + private val pkg = "androidx.compose.foundation.layout" + private val arrangement = "$pkg.Arrangement" + + private fun producing(binding: String, type: String, supertypes: List) = DeclarationModel( + simpleName = binding.substringAfterLast('.'), + owner = binding.substringBeforeLast('.'), + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = KotlinTypeModel(type), + returnBoundaryTag = "OBJECT", + bindingName = binding, + returnSupertypes = supertypes, + ) + + private fun text(vararg d: DeclarationModel, module: String = arrangement) = + renderKotlinFqnStubs(d.toList()).getValue(module.replace('.', '/') + "/__init__.pyi") + + @Test + fun aClassListsEveryKotlinSupertypeThatHasAStubClass() { + val both = producing( + "$arrangement.SpaceBetween", "$arrangement.HorizontalOrVertical", + listOf("$arrangement.Horizontal", "$arrangement.Vertical", "kotlin.Any", "java.lang.Object"), + ) + val body = text(both) + assertTrue("class HorizontalOrVertical(Horizontal, Vertical):" in body, body) + assertTrue("class Horizontal:" in body, body) + assertTrue("class Vertical:" in body, body) + assertFalse("object" in body.substringAfter("class HorizontalOrVertical").substringBefore(":"), body) + } + + @Test + fun aBaseAnotherListedBaseAlreadyImpliesIsDroppedSoTheMroIsValid() { + // Sub is a Base and a Mid, and Mid is a Base: `class Sub(Base, Mid)` is an MRO error in Python. + val sub = producing("$arrangement.makeSub", "$arrangement.Sub", listOf("$arrangement.Base", "$arrangement.Mid")) + val mid = producing("$arrangement.makeMid", "$arrangement.Mid", listOf("$arrangement.Base")) + val body = text(sub, mid) + assertTrue("class Sub(Mid):" in body, body) + assertTrue("class Mid(Base):" in body, body) + assertFalse("class Sub(Base" in body, body) + } +} diff --git a/tools/stubs/consumer.py b/tools/stubs/consumer.py index 2a6fc976e..3111280c6 100644 --- a/tools/stubs/consumer.py +++ b/tools/stubs/consumer.py @@ -9,7 +9,7 @@ from typing import assert_type -from androidx.compose.foundation.layout import Column, Row, fillMaxWidth, padding__Dp +from androidx.compose.foundation.layout import Arrangement, Column, Row, fillMaxWidth, padding__Dp from androidx.compose.material.icons import Icons from androidx.compose.material3 import Checkbox, Icon, Text from androidx.compose.ui import Modifier @@ -43,6 +43,9 @@ def content() -> None: Column(modifier=chain, content=lambda scope: None) Row(content=lambda scope: None) +# `SpaceBetween` is a `HorizontalOrVertical`: a `Vertical` slot and a `Horizontal` slot both take it (#71). +Column(verticalArrangement=Arrangement.SpaceBetween, content=lambda scope: None) +Row(horizontalArrangement=Arrangement.SpaceBetween, content=lambda scope: None) # An extension property on a type nested in an object (issue #68): `Icons.Default` is `Icons.Filled`. # `assert_type`, not an annotation: an `Any` (the stub before #53/#67) would satisfy an annotation.