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
18 changes: 18 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,24 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
statics, abstract classes (no constructor), nested classes. Not exposed: annotation classes,
generic declarations, data-class synthetics. `Status: implemented` on desktop,
`GeneratedDeclarationKindsTest.kt`.
**A companion object is a value** (#322): the public companion object of a public class is the value
`Owner.Companion` (`Owner.<CompanionName>` for a named companion), read as an attribute of the owner's
proxy class or of its module. When the companion lists the owner as a direct supertype in its Kotlin
metadata (`Modifier.Companion : Modifier`) the value is an `Owner` proxy instance (`isinstance` holds),
otherwise a proxy of the companion's own type. Each read returns a new handle to the same Kotlin object. Two proxies are `==` when they hold the same
Kotlin object (reference identity) and hash by its identity hash; `is` is not guaranteed; Kotlin
`equals` and `hashCode` are not used (value equality is not provided). On wasmJs, which has no
identity hash, the hash is the object's `hashCode()`, so there a mutable object that overrides it
should not be used as a dict key or set member. A non-public companion
binds nothing; a generic owner is named without type arguments. A class-level constant of the
companion is reachable through the proxy class (`Owner.Default`). `register_empty(type, factory)`
takes the type as its Kotlin name or its proxy class, and the factory as a Kotlin function name (string)
or a Python callable returning an owned proxy (`register_empty(Modifier, lambda: Modifier.Companion)`);
both forms work. `Status: implemented` on desktop,
`GP/artifact/CompanionHandleBindingTest.kt` (walker), `GP/stubs/CompanionHandleStubTest.kt` (stub),
`PM/desktopTest/.../pythonx/CompanionHandleTest.kt` (runtime),
`python-multiplatform-gradle-plugin/fixtures/artifact/.../WalkedArtifactCompanionHandleTest.kt`,
`python-multiplatform-compose/fixtures/compose/.../ProxyClassArgumentRenderTest.kt`.
- **U-5** `suspend` functions are awaitable from Python (`await g.greetNow(1)`); a function that never
suspends completes without a Future; cancelling the Python future cancels the Kotlin coroutine.
`Status: implemented` on desktop and Kotlin/Native, `PM/desktopTest/.../upcall/AsyncUpcallDeliveryTest.kt`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ class ProxyClassArgumentRenderTest {
assertEquals(viaInstance, viaClass, "the class spelling must draw what the instance spelling draws")
}

/**
* Issue #322: the empty value is `Modifier.Companion` itself, read from Python (no Kotlin
* `emptyModifier` helper): `register_empty(Modifier, lambda: Modifier.Companion)`.
*/
@Test
fun aModifierClassPassedToCardIsTheCompanionRegisteredFromPython() {
val setup = """
import python_multiplatform.binding as _pm_binding
from androidx.compose.ui import Modifier
from androidx.compose.material3 import Card, Text
_pm_binding.register_empty(Modifier, lambda: Modifier.Companion)
""".trimIndent()
val viaClass = inkOf(setup + "\nCard(modifier=Modifier, content=lambda scope: Text('hi'))")
val withoutModifier = inkOf(setup + "\nCard(content=lambda scope: Text('hi'))")
println("compose render: Card(modifier=Modifier) -> $viaClass px, Card() -> $withoutModifier px")
assertTrue(viaClass > 0, "Card(modifier=Modifier) drew nothing")
assertEquals(withoutModifier, viaClass, "the class spelling must draw what Card() draws")
}

private fun inkOf(body: String): Int {
val scene = ImageComposeScene(width = 200, height = 80, density = Density(1f)) {
PythonComposition(body)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ pythonBindings {
// so it can only ever be held in Python as a boxed handle (issue #168).
"androidx.compose.ui.unit.TextUnitKt",
"androidx.compose.ui.unit.TextUnitType",
// `Modifier` is an interface whose companion is itself a `Modifier` (issue #322): the
// companion handle `Modifier.Companion` is what `WalkedArtifactCompanionHandleTest` reads.
"androidx.compose.ui.Modifier",
),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package fixture.artifact

import python.multiplatform.ffi.Python3
import python.multiplatform.ffi.pythonx.PythonxAdapter
import python.multiplatform.ffi.upcall.PythonProxySource
import python.multiplatform.ffi.upcall.UpcallBootstrap
import python.multiplatform.generated.FunctionTable
import python.multiplatform.generated.artifacts.ArtifactTable
import python.multiplatform.reflection.UpcallTable
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue

/**
* Issue #322 (SPEC U-4, B-1): `Modifier.Companion` from the real Compose jars is a `Modifier` from
* Python. The walker binds the companion object itself (`androidx.compose.ui.Modifier.Companion`,
* generated Kotlin `Modifier.Companion`); the proxy class serves it as an attribute.
*
* Red before the change: no `androidx.compose.ui.Modifier.Companion` key is bound, so the attribute
* read raises AttributeError.
*/
class WalkedArtifactCompanionHandleTest {

@BeforeTest
fun install() {
Python3.initialize(silent = true)
UpcallTable.clear()
UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
PythonProxySource.install()
PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
}

@AfterTest
fun cleanup() {
UpcallTable.clear()
}

@Test
fun theCompanionKeyIsBound() {
assertTrue(UpcallTable.resolve("androidx.compose.ui.Modifier.Companion").isValid)
}

@Test
fun modifierCompanionIsAModifierAndTwoReadsCompareEqual() {
Python3.exec(
"""
from androidx.compose.ui import Modifier
from fixture.artifact import isTheEmptyModifier
_a = Modifier.Companion
_b = Modifier.Companion
assert isinstance(_a, Modifier), type(_a)
assert _a == _b, 'two reads of Modifier.Companion are not =='
assert isTheEmptyModifier(_a), 'Modifier.Companion is not Compose\'s empty Modifier'
assert not hasattr(Modifier, 'companion'), 'a lower-case companion alias must not exist'
""".trimIndent(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,23 @@ class WalkedArtifactStubTest {
assertEquals(emptySet(), members - memberKeys, "a member stub names no member key")
assertEquals(emptySet(), memberKeys - members, "a member key has no stub")

// A sixth shape (issue #322): a companion handle (`pkg.Owner.Companion`) is a class attribute of its
// owner's stub class, or a comment saying why not; either way it carries its key after
// `Kotlin companion: `. Matched by key, like a property.
val companions = generatedPaths()
.filter { it.endsWith(".pyi") }
.flatMap { path ->
Regex("""Kotlin companion: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList()
}
.toSet()
assertEquals(emptySet(), companions - installed, "a companion stub names no table key")

// The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding
// layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key.
val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") }
.filter { it !in installed }.toSet()
assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable")
assertEquals(emptySet(), installed - propertyKeys - memberKeys - stubbed - constructors, "callable but not stubbed")
assertEquals(emptySet(), installed - propertyKeys - memberKeys - stubbed - constructors - companions, "callable but not stubbed")
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ internal data class Candidate(
*/
val plan: ArtifactScanner.DefaultOmissionPlan? = null,
val isConstructor: Boolean = false,
/**
* True only for the candidate that publishes a companion object itself (`Owner.Companion`). It
* shares the owner's name as a prefix, which the constructor-collision rule would otherwise
* read as a constant hiding the constructor; the owner stays constructible beside its companion.
*/
val isCompanionHandle: Boolean = false,
)

internal object ArtifactScanner {
Expand Down Expand Up @@ -342,7 +348,8 @@ internal object ArtifactScanner {
for (candidate in bound) {
if (!candidate.isConstructor) continue
val name = candidate.callable!!.name
if (boundNames.any { it != name && it.startsWith("$name.") }) {
val others = bound.filter { !it.isCompanionHandle }.map { it.callable!!.name }
if (others.any { it != name && it.startsWith("$name.") }) {
dropReasons.putIfAbsent(candidate, "constructor name collides with a constant this class publishes under its own name")
}
}
Expand Down Expand Up @@ -767,10 +774,12 @@ internal object ArtifactScanner {
// never read as an entry. It is loaded from here instead, by the name the outer class's own
// metadata gives it -- which is also why the bound name can drop `Companion` while the
// generated call keeps it.
var companionHandle: Candidate? = null
val companionProperties = kmClass.companionObject?.let { companionName ->
val companionNode = classpath.classNode(ownerNode.name + "\$" + companionName) ?: return@let null
val companionMetadata = kotlinClassMetadataOf(companionNode) as? KotlinClassMetadata.Class ?: return@let null
if (companionMetadata.kmClass.visibility != Visibility.PUBLIC) return@let null
companionHandle = companionHandleCandidate(qualified, ownerNode, companionName, companionMetadata.kmClass, classpath)
constantsOf(
companionMetadata.kmClass,
companionNode,
Expand All @@ -780,7 +789,71 @@ internal object ArtifactScanner {
)
}.orEmpty()

return ownProperties + companionProperties
return ownProperties + companionProperties + listOfNotNull(companionHandle)
}

/**
* The companion object itself, bound as `Owner.<CompanionName>` (a zero-argument getter).
*
* The generated body names the companion in Kotlin source (`Owner.Companion`), which kotlinc
* compiles to a field read; no JVM member is looked up by name. The value is typed as the owner
* when the companion lists the owner as a direct supertype (`Modifier.Companion : Modifier`), so
* it is an instance of the owner's proxy; otherwise as the companion's own type. A generic owner
* is named without type arguments.
*/
private fun companionHandleCandidate(
qualified: String,
ownerNode: ClassNode,
companionName: String,
companionClass: kotlin.metadata.KmClass,
classpath: ArtifactClasspath,
): Candidate {
val ownerMetadataName = ownerNode.name.replace('$', '.')
val companionMetadataName = "$ownerMetadataName.$companionName"
val ownerIsSupertype = companionClass.supertypes.any {
(it.classifier as? KmClassifier.Class)?.name == ownerMetadataName
}
val typeName = (if (ownerIsSupertype) ownerMetadataName else companionMetadataName).replace('/', '.')
val name = "$qualified.$companionName"
val boundary = BoundaryType("OBJECT", "(%s as $typeName)", "(%s)")
val supertypes = classpath.nameablePublicSupertypesOf(
if (ownerIsSupertype) ownerMetadataName else companionMetadataName,
)
return Candidate(
callable = ArtifactCallable(
name = name,
arity = 0,
paramTags = emptyList(),
returnTag = boundary.tag,
lambdaBody = "{ ${boundary.wrapReturn(name)} }",
returnTypeName = typeName,
returnSupertypes = supertypes,
paramNames = emptyList(),
paramTypeNames = emptyList(),
paramHasDefault = emptyList(),
kind = "STATIC_GETTER",
),
declaration = DeclarationModel(
simpleName = companionName,
owner = qualified,
ownerIsClass = true,
receiver = null,
parameters = emptyList(),
returnType = python.multiplatform.gradle.model.KotlinTypeModel(
qualifiedName = typeName,
isNullable = false,
arguments = emptyList(),
valueClass = null,
),
isComposable = false,
isSuspend = false,
kind = "STATIC_GETTER",
bindingName = name,
returnBoundaryTag = boundary.tag,
isCompanionHandle = true,
),
isCompanionHandle = true,
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ internal data class DeclarationModel(
* the `Any` rule.
*/
val isConstructor: Boolean = false,
/**
* The declaration is a companion object itself (`Owner.Companion`): [owner] is the class and
* [simpleName] the companion's name. A stub renders it as a class attribute of the owner's stub
* class rather than as a module attribute, which would turn the owner into a module and drop
* its class stub.
*/
val isCompanionHandle: Boolean = false,
) : Serializable

/** @param name `null` when the producer could not read one (§3.2); never a synthesised `arg0`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ internal const val CONSTRUCTOR_MARKER = "Kotlin constructor: "
*/
internal const val METHOD_MARKER = "Kotlin member: "

/**
* What precedes a companion handle's table key (`pkg.Owner.Companion`, issue #322) in the docstring of
* the class attribute it is stubbed as, or in the comment that says why there is none.
*/
internal const val COMPANION_MARKER = "Kotlin companion: "

private val PROPERTY_KINDS = setOf("GETTER", "SETTER")
private const val METHOD_KIND = "METHOD"

Expand Down Expand Up @@ -201,7 +207,13 @@ private enum class Role { PARAM, RESULT }
private class StubRenderer(declarations: List<DeclarationModel>) {

/** Everything that is a module attribute; properties and member functions are attributes of a proxy instead. */
private val bound: List<DeclarationModel> = declarations.filter { it.kind !in PROPERTY_KINDS && it.kind != METHOD_KIND }
private val bound: List<DeclarationModel> =
declarations.filter { it.kind !in PROPERTY_KINDS && it.kind != METHOD_KIND && !it.isCompanionHandle }

/** owner class -> its companion object handles (`Owner.Companion`), each a class attribute of the owner's stub. */
private val companionHandles: Map<String, List<DeclarationModel>> = declarations
.filter { it.isCompanionHandle && it.bindingName != null }
.groupBy { it.owner }

/** receiver type -> its bound member functions (issue #215), each a method of the receiver's stub class. */
private val methods: Map<String, List<DeclarationModel>> = declarations
Expand Down Expand Up @@ -694,6 +706,17 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
}
stubBasesOf(qualifiedName, ctx).takeIf { it.isNotEmpty() }?.let { node.bases = it.joinToString(", ") }

// The companion object, a class attribute (`Modifier.Companion`). Typed as the owner when the
// companion is one (`Companion: Modifier`), else as the companion's own type.
companionHandles[qualifiedName].orEmpty().forEach { d ->
val name = d.simpleName
if (!isIdentifier(name) || name in node.children) {
node.members += "# $COMPANION_MARKER${d.bindingName} is not stubbed: '$name' cannot be written as an attribute here"
return@forEach
}
node.members += "$name: ${result(d, ctx)}\n\"\"\"$COMPANION_MARKER${d.bindingName} (${kotlinSignatureOf(d)})\"\"\""
}

constructorsByClass[qualifiedName]?.let { group ->
group.forEachIndexed { index, d ->
node.members += if (group.size == 1) {
Expand Down Expand Up @@ -849,6 +872,26 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
}
}

/**
* Every owner with a companion handle gets its stub class. One whose name is a module (it also
* publishes companion constants) cannot have a class, so the handle is a module attribute there,
* as the constants are; one whose class is taken by a function gets a comment instead.
*/
private fun registerCompanionOwners() {
companionHandles.forEach { (owner, handles) ->
val ref = classRefOf(owner)
val home = ref?.pkg ?: owner.substringBeforeLast('.', "")
val ctx = out(home)
if (ref != null && classReference(owner, null, ctx) != ANY) return@forEach
if (owner in moduleNames) {
val moduleCtx = out(owner)
handles.forEach { moduleCtx.defs += renderDef(it, moduleCtx) }
} else {
handles.forEach { ctx.defs += "# $COMPANION_MARKER${it.bindingName} is not stubbed: ${owner.substringAfterLast('.')} has no stub class" }
}
}
}

private fun renderClasses() {
val done = mutableSetOf<String>()
while (true) {
Expand All @@ -875,6 +918,7 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
renderModuleDefs()
registerPropertyOwners()
registerMethodOwners()
registerCompanionOwners()
renderClasses()
return modules.filterValues { it.defs.isNotEmpty() || it.classes.isNotEmpty() }
.mapValues { (_, ctx) ->
Expand Down
Loading