diff --git a/docs/SPEC.md b/docs/SPEC.md index 107532c95..ddaf6a2d4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -191,6 +191,20 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`, `ksp-fixtures/compose/.../MemberResolverComposeTest.kt`. + A resolver (or anything else) reads a member's declaration rows from the receiver type alone with + `python_multiplatform.describe_member(kotlin_type_name, kotlin_member_name)`: `describe()`'s + tuple-of-dicts for every extension overload or the property getter/setter that type's proxy serves + under that name, supertypes included, invoking nothing; `AttributeError` for a name it does not + serve (#54). `Status: implemented` — `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`. +- **U-10** A Kotlin property is an attribute of its receiver's proxy (#38): a member `val`/`var` of a + public class or interface reads (and, for a public setter, writes) as a Python `property`, an + extension property's getter reads the same way on its receiver (`Icons.Default.Add`), and both are + found on the type and then on every type the table says it is a. A property is never a module + attribute and makes no package. `None` written for a slot with no default is Kotlin's `null` for a + reference type; a Python object written into a `kotlin.Any?` slot is held by Kotlin as itself, and an + `int` is refused there (it would cross as a handle). `Status: implemented` — + `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`, + `MaterialIconsRenderTest.kt`. ## 6. Binding prebuilt libraries (Gradle plugin) @@ -199,6 +213,14 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. compiles — never by JVM name lookup. KSP and the walker share one Python namespace. `Status: implemented` on desktop — `GP/artifact/ArtifactScannerTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactTableTest.kt`, `WalkedArtifactPythonImportTest.kt`. + Beside functions and object constants it binds (#38): generic functions whose type parameters are + unbounded and not reified, each read as `kotlin.Any?` and written out at the call + (`mutableStateOf(...)`), a bounded or reified one still declined; member properties of + public classes and interfaces that cross as object handles, as `GETTER` entries `Owner.prop` and, + for a public setter with every class type parameter unbounded, `SETTER` entries `Owner.prop=`; and + top-level extension property getters (`pkg.prop`, receiver in `receiverTypeName`). Properties take no + part in overload naming or in a constructor's name check; a property key another binding already + holds is declined, and so is a property whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope). `Status: implemented` — `GP/artifact/PropertyBindingTest.kt`. - **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` — `GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's declarations are declined with reasons; no klib declaration is bound at run time yet. @@ -237,7 +259,9 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`, `tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in - its module is `Any`. + its module is `Any`. A property is a `@property` (with a setter for a `var`) of its receiver's stub + class, its docstring carrying its table key after `Kotlin property: `; where that class has no stub, + a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). - **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. @@ -284,10 +308,8 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **N-5** Native-image upcall verification as an automated test (U-6). `Status: planned`. - **N-6** Compose state created and written from Python through the binder: `androidx.compose.runtime.mutableStateOf(x)` callable, and `.value` of the returned `MutableState` - readable and writable on its proxy. Neither is bound today: the walker emits only `FUNCTION` and - `STATIC_GETTER` entries (no instance members), and declines every declaration whose signature - mentions a type parameter. B-8's test writes its root state through a fixture-local KSP-bound - setter instead. `Status: planned`. + readable and writable on its proxy. Implemented by #38 (B-1, U-10); B-8's test now writes its root + through `state.value = root` and the KSP-bound stand-in is gone. `Status: implemented`. --- diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt index a7d25cc77..461219005 100644 --- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt @@ -191,12 +191,31 @@ class WalkedArtifactStubTest { (functions + constants).map { "$module.$it" }.toList() } .toSet() + // A third shape (issue #38): a `GETTER`/`SETTER` is an attribute of its receiver's proxy, not of + // a module, so its stub is a `@property` in that receiver's stub class -- or, where the class + // has no stub, a comment saying so -- and either way it carries its table key after + // `Kotlin property: `. Matched by key, because the key is not derivable from where it sits. + val properties = generatedPaths() + .filter { it.endsWith(".pyi") } + .flatMap { path -> + Regex("""Kotlin property: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList() + } + .toSet() + val propertyKeys = UpcallTable.entries() + .filter { it.kind.name == "GETTER" || it.kind.name == "SETTER" } + .map { it.name } + .toSet() + // Not vacuous: `kotlin.text` is walked here, and `Regex.pattern` is a member `val` of a class + // that crosses as a handle. + assertTrue("kotlin.text.Regex.pattern" in propertyKeys, "no property was bound: $propertyKeys") + assertEquals(emptySet(), properties - propertyKeys, "a property stub names no property key") + assertEquals(emptySet(), propertyKeys - properties, "a property key has no stub") // 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 - stubbed, "callable but not stubbed") + assertEquals(emptySet(), installed - propertyKeys - stubbed, "callable but not stubbed") } } diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts index f81578c66..7141f92c2 100644 --- a/ksp-fixtures/compose/build.gradle.kts +++ b/ksp-fixtures/compose/build.gradle.kts @@ -97,6 +97,13 @@ pythonBindings { "androidx.compose.ui", // 5. `material.icons` -- `Icons.Default` and the icon properties behind it (issue #37). "androidx.compose.material.icons", + // 6. Compose state, from Python (issue #38): `mutableStateOf` (a generic function, its `T` + // read as `kotlin.Any?`) and the `value` property of `State`/`MutableState`. Classes, not + // the package: `androidx.compose.runtime` is the whole runtime, and this is the one + // facade and two interfaces `PythonContentRenderTest` writes its root through. + "androidx.compose.runtime.SnapshotStateKt", + "androidx.compose.runtime.State", + "androidx.compose.runtime.MutableState", ), ) // Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt deleted file mode 100644 index 1f432727c..000000000 --- a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt +++ /dev/null @@ -1,24 +0,0 @@ -package fixture.compose - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import python.multiplatform.ffi.PyObject - -/** - * The Python side of `PythonContentRenderTest`: a Compose [MutableState] that Python creates, holds - * and writes, standing in for what a Pythonic package (pythonx-compose's `@app`) does with its own. - * - * Bound by KSP as plain top-level functions (`fixture.compose.rootState`, `fixture.compose.writeRoot`) - * because the artefact walker binds static functions only, and `MutableState.value`'s setter is an - * instance method. What the entry point under test reads is the [MutableState] itself; how a package - * writes into it is that package's business, so this is the smallest writer there is. - * - * `root` is declared [PyObject] -- not `Any` -- so a Python function crosses as itself rather than - * being unwrapped as a Kotlin handle (`PythonProxySource.argValues`'s one exception). - */ -fun rootState(): MutableState = mutableStateOf(null) - -/** `state.value = root`, called from Python. An ordinary snapshot write; nothing else. */ -fun writeRoot(state: MutableState, root: PyObject) { - state.value = root -} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt index 8cdb12f9d..ccd99bc8f 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt @@ -15,12 +15,13 @@ import python.multiplatform.reflection.UpcallTable import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Issue #37: `material-icons-core` in the walk, so `Icon` has an `ImageVector` to draw -- **partly**: - * `Icons.Default` is bound, the icons themselves are not (see - * [iconsDefaultAddIsUnreachableBecauseTheWalkerBindsNoExtensionProperty]). + * Issue #37: `material-icons-core` in the walk, so `Icon` has an `ImageVector` to draw. `Icons.Default` + * is an object constant, and the icons themselves are extension properties on `Icons.Filled`, bound + * as getters since issue #38 ([iconsDefaultAddDrawsFromPythonExactlyAsFromKotlin]). * * The artifact is `org.jetbrains.compose.material:material-icons-core:1.7.3` -- the newest JetBrains * published (Maven Central), and the one Compose Multiplatform 1.11.x points to; its `ui` @@ -67,36 +68,53 @@ class MaterialIconsRenderTest { } /** - * **Not reached yet -- pinned, with the reason.** Issue #37's completion statement is - * `Icon(Icons.Default.Add, contentDescription=None)` from Python, and adding the artifact is not - * enough for it: `Icons.Default` is bound (above), but `Add` is a top-level **extension property**, - * `val Icons.Filled.Add: ImageVector` (`AddKt.getAdd(Icons$Filled)` in the jar), and the walker - * binds a file facade's *functions* only (`ArtifactScanner.scanClassNode` reads - * `kmPackage.functions`, never `kmPackage.properties`). So no binding has `Icons.Filled` as its - * receiver and the attribute is an `AttributeError`. + * **The render proof issue #37 asked for, closed by #38's extension-property getters.** `Add` is a + * top-level extension property, `val Icons.Filled.Add: ImageVector` (`AddKt.getAdd(Icons$Filled)` + * in the jar); the walker now binds it as a `GETTER` whose receiver is `Icons.Filled`, so + * `Icons.Default.Add` is an attribute read on the `Icons.Default` value, and `Icon` draws it. * - * The day the walker binds extension properties, this fails, and it should become the render - * proof: `Icon(androidx.compose.material.icons.Icons.Default.Add, contentDescription=None)` - * drawing the same ink as [theAddIconDrawsFromKotlin]. + * Exact ink, not `> 0`: the same `Icon(Icons.Default.Add, contentDescription = null)` drawn from + * Kotlin is the control ([theAddIconDrawsFromKotlin]), so a different vector, a different size or + * an empty box all fail this. `contentDescription=None` is a written `null` for a slot with no + * default, which the binding layer now passes rather than refusing as "no value". + * + * Red before #38: `Icons.Default.Add` raised `AttributeError` (no binding had `Icons.Filled` as its + * receiver), so the composition body failed before `Icon` was called. */ @Test - fun iconsDefaultAddIsUnreachableBecauseTheWalkerBindsNoExtensionProperty() { + fun iconsDefaultAddDrawsFromPythonExactlyAsFromKotlin() { + val kotlin = inkOfScene { Icon(Icons.Default.Add, contentDescription = null) } + val python = inkOfScene { + PythonComposition( + """ + import androidx + from androidx.compose.material3 import Icon + Icon(androidx.compose.material.icons.Icons.Default.Add, contentDescription=None) + """.trimIndent(), + ) + } + val blank = inkOfScene { PythonComposition("pass") } + println("compose render: Icon(Icons.Default.Add) Kotlin -> $kotlin px, Python -> $python px, empty -> $blank px") + assertEquals(0, blank, "an empty composition must draw nothing, or the measurement is not measuring") + assertTrue(kotlin > 0, "the control icon drew nothing") + assertEquals(kotlin, python, "Python's Icon(Icons.Default.Add) did not draw what Kotlin's does") + } + + /** What the walker bound for `Add`, described without being read: a `GETTER` on `Icons.Filled`. */ + @Test + fun iconsFilledAddIsAGetterOnItsReceiver() { Python3.exec( """ + import python_multiplatform as _mi_pm + _mi_d = _mi_pm.describe_member('androidx.compose.material.icons.Icons.Filled', 'Add') + assert len(_mi_d) == 1, _mi_d + assert _mi_d[0]['kind'] == 'GETTER', _mi_d + assert _mi_d[0]['name'] == 'androidx.compose.material.icons.filled.Add', _mi_d + assert _mi_d[0]['returns'] == 'androidx.compose.ui.graphics.vector.ImageVector', _mi_d + assert _mi_d[0]['receiver'] == 'androidx.compose.material.icons.Icons.Filled', _mi_d import androidx - import python_multiplatform.binding as _mi_b - _mi_filled = 'androidx.compose.material.icons.Icons.Filled' - _mi_default = androidx.compose.material.icons.Icons.Default - assert type(_mi_default)._kotlin_type_name == _mi_filled, type(_mi_default) - try: - _mi_default.Add - raise AssertionError('Icons.Default.Add is reachable now -- turn this pin into the render proof') - except AttributeError: - pass - _mi_on_filled = [d.kotlin_name for d in _mi_b._TABLE.values() if d.receiver_type_name == _mi_filled] - assert _mi_on_filled == [], repr(_mi_on_filled) - assert 'androidx.compose.material.icons.filled' not in _mi_b._BY_PACKAGE, \ - sorted(_mi_b._BY_PACKAGE['androidx.compose.material.icons.filled']) + _mi_add = androidx.compose.material.icons.Icons.Default.Add + assert type(_mi_add)._kotlin_type_name == 'androidx.compose.ui.graphics.vector.ImageVector', type(_mi_add) """.trimIndent(), ) } diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt index 567e6a208..95bf6abd4 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt @@ -26,8 +26,12 @@ import kotlin.test.assertTrue * **The host entry point** (python-multiplatform#18, for pythonx-compose#11's `@app`): the host says * *where* the Python root state lives, and from then on Python alone decides what is on screen. * - * The root lives in a Compose `MutableState` that Python created and holds ([rootState]); Python - * replaces the root by writing into it ([writeRoot]). The claim is that this write is *all* it takes: + * The root lives in a Compose `MutableState` that Python created and holds -- + * `androidx.compose.runtime.mutableStateOf(None)`, the walked Compose function itself, its `T` read as + * `kotlin.Any?` (issue #38) -- and Python replaces the root by writing its `value` property: + * `state.value = root`, the walked `MutableState.value` setter. No fixture function stands between + * Python and Compose any more (the KSP-bound `rootState`/`writeRoot` stand-ins are gone). The claim is + * that this write is *all* it takes: * the next frame shows the new root, and nothing on the Kotlin side was called to make it so. The * only thing the test does between the write and the frame is what a window's frame clock does on * its own -- deliver the snapshot's apply notification -- and the per-root call counters show that the @@ -64,7 +68,7 @@ class PythonContentRenderTest { */ @Test fun aRootWrittenFromPythonIsDrawnOnTheNextFrameWithoutAHostCall() { - Python3.exec("writeRoot(_root_state, _root_a)") + Python3.exec("_root_state.value = _root_a") val baseRoots = HandleTable.liveCount val baseInner = pyInt("sys.getrefcount(_inner)") @@ -85,7 +89,7 @@ class PythonContentRenderTest { assertEquals(1, pyInt("_calls['a']"), "an idle frame re-ran the root, so something polls") // Python replaces the root. Nothing on the Kotlin side is called. - Python3.exec("writeRoot(_root_state, _root_b)") + Python3.exec("_root_state.value = _root_b") inkB = inkOfImage(nextFrame(scene)) assertEquals(1, pyInt("_calls['b']"), "the root Python wrote was not composed on the next frame") assertEquals(1, pyInt("_calls['a']"), "the replaced root was composed again") @@ -94,7 +98,7 @@ class PythonContentRenderTest { assertTrue(heldInner > baseInner, "nothing held _inner while root B was composed: $baseInner -> $heldInner") // And back. - Python3.exec("writeRoot(_root_state, _root_a)") + Python3.exec("_root_state.value = _root_a") inkABack = inkOfImage(nextFrame(scene)) assertEquals(2, pyInt("_calls['a']"), "switching back did not compose root A again") assertEquals(1, pyInt("_calls['b']"), "root B ran after it was replaced") @@ -120,7 +124,7 @@ class PythonContentRenderTest { */ @Test fun theHostCanPassThePythonStateObjectAndDisposalReleasesWhatTheRootPassed() { - Python3.exec("writeRoot(_root_state, _root_b)") + Python3.exec("_root_state.value = _root_b") val baseRoots = HandleTable.liveCount val baseInner = pyInt("sys.getrefcount(_inner)") val state = Python3.import("__main__").getAttr("_root_state") @@ -148,6 +152,40 @@ class PythonContentRenderTest { assertEquals(0, PythonCallables.openScopeCount, "a callable scope was left open") } + /** + * The state is Compose's own, reached through the walk alone (issue #38): `mutableStateOf` is the + * walked generic function, and `value` is the walked `MutableState` property, read and written as a + * Python attribute -- the Python function written in is the very object read back. + * + * Red before the walker bound generics and properties: `mutableStateOf` was not in the table + * (`ImportError` at `ROOTS`, in `@BeforeTest`), and once it is, `_root_state.value` is an + * `AttributeError` until properties are served. + */ + @Test + fun theRootStateIsComposesOwnMutableStateAndValueIsItsWalkedProperty() { + Python3.exec( + """ + import python_multiplatform as _prs_pm + _prs = { + 'type': type(_root_state)._kotlin_type_name, + 'empty': _root_state.value is None, + 'kinds': [_d['kind'] for _d in _prs_pm.describe_member('androidx.compose.runtime.MutableState', 'value')], + 'factory': _prs_pm.describe(mutableStateOf)[0]['name'], + } + _root_state.value = _root_a + _prs['same'] = _root_state.value is _root_a + _root_state.value = None + _prs['cleared'] = _root_state.value is None + """.trimIndent(), + ) + assertEquals("androidx.compose.runtime.MutableState", pyStr("_prs['type']")) + assertEquals("True", pyStr("_prs['empty']")) + assertEquals("['GETTER', 'SETTER']", pyStr("_prs['kinds']")) + assertEquals("androidx.compose.runtime.mutableStateOf", pyStr("_prs['factory']")) + assertEquals("True", pyStr("_prs['same']"), "the root written into the state is not the one read back") + assertEquals("True", pyStr("_prs['cleared']")) + } + /** A Python callable passed directly is a root that never changes -- no state involved. */ @Test fun aPlainPythonCallableIsAFixedRoot() { @@ -203,7 +241,7 @@ class PythonContentRenderTest { */ val ROOTS = """ import sys - from fixture.compose import rootState, writeRoot + from androidx.compose.runtime import mutableStateOf from androidx.compose.foundation.layout import Column from androidx.compose.material3 import Text @@ -220,7 +258,7 @@ class PythonContentRenderTest { _calls['b'] += 1 Column(content=_inner) - _root_state = rootState() + _root_state = mutableStateOf(None) """.trimIndent() } } diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt index 0eb66027c..1e09d9c05 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt @@ -21,9 +21,9 @@ private const val UPCALL_TABLE = "python.multiplatform.reflection.UpcallTable" * * Mirrors `python.multiplatform.ksp.CallableEntryModel` field for field, minus what a jar cannot * answer: there is no `isSuspend` (a `suspend` function's JVM shape takes a `Continuation`, which - * [boundaryTypeOf] declines, so no suspending declaration ever reaches here) and no `kind` (every - * entry is a `FUNCTION`, because only statics are bound -- an instance method would need a - * `ReflectedClass` and a receiver handle, which is the next step and not this one). + * [boundaryTypeOf] declines, so no suspending declaration ever reaches here). [kind] is `FUNCTION` + * for every call, `STATIC_GETTER` for an object's constant, and `GETTER`/`SETTER` for a property read + * off an instance (issue #38) -- instance *methods* are still not bound. * * `Serializable`: [KlibScanWorkAction] hands one of these back across the `WorkerExecutor` isolation * boundary it runs [KlibScanner] behind. @@ -42,6 +42,11 @@ internal data class ArtifactCallable( /** * The Kotlin type this declaration extends, or `null` when it is not an extension function. * + * For a `GETTER`/`SETTER` (issue #38) it is the type the property is **read on** -- the owner of a + * member property, the receiver of an extension property -- which is what the binding layer + * keys a proxy's properties on. It is not a slot there (`CallableKind.GETTER.hasReceiver` puts + * the receiver in `args[0]` uncounted), so [renderEntry] writes `isExtension = false` for one. + * * Carried rather than recomputed because it is not recoverable from anything else here: the * receiver is [ArtifactCallable.paramTags]'s slot 0, and a tag says how a value is marshalled, * not what it is. `docs/archive/pythonx-adapter-design.md` §2.4 row 3 records this exact loss -- @@ -188,7 +193,7 @@ private fun renderEntry(entry: ArtifactCallable): String { | paramNames = listOf($paramNames), | paramTypeNames = listOf($paramTypeNames), | returnTypeName = ${renderedReturnTypeName(entry)?.quoted() ?: "null"}, - | isExtension = ${entry.receiverTypeName != null}, + | isExtension = ${entry.receiverTypeName != null && entry.kind == "FUNCTION"}, | receiverTypeName = ${entry.receiverTypeName?.quoted() ?: "null"}, | paramHasDefault = listOf($paramHasDefault), | callable = ${entry.lambdaBody}, diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt index dbe45fe38..d5e73e1e1 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt @@ -1,5 +1,6 @@ package python.multiplatform.gradle.artifact +import kotlin.metadata.ClassKind import kotlin.metadata.KmClassifier import kotlin.metadata.KmType import kotlin.metadata.KmVariance @@ -7,7 +8,12 @@ import kotlin.metadata.Visibility import kotlin.metadata.isNullable import kotlin.metadata.isSuspend import kotlin.metadata.isValue +import kotlin.metadata.isVar import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.metadata.jvm.fieldSignature +import kotlin.metadata.jvm.getterSignature +import kotlin.metadata.jvm.setterSignature +import kotlin.metadata.kind import kotlin.metadata.visibility import org.objectweb.asm.Opcodes import org.objectweb.asm.tree.ClassNode @@ -227,7 +233,15 @@ internal object ArtifactScanner { // // Defaults first: [disambiguateOverloads] renames entries, and the sibling test needs the // Kotlin name they still share. - return assignThunkIndices(disambiguateOverloads(applyDefaultOmission(dropCollidingConstructors(entries)))) + // + // Properties (issue #38) take no part in any of the four passes: a `GETTER`/`SETTER` is not a + // call anybody spells by name, it is served as an attribute of its receiver's proxy, so it + // neither claims a constructor's namespace (`TextStyle.color` must not cost `TextStyle(...)` + // its binding) nor joins an overload set. [settlePropertyNames] gives them the one check + // they do need, against the finished names of everything else. + val (properties, functions) = entries.partition { it.declaration.kind in PROPERTY_KINDS } + val settledFunctions = assignThunkIndices(disambiguateOverloads(applyDefaultOmission(dropCollidingConstructors(functions)))) + return (settledFunctions + settlePropertyNames(properties, settledFunctions)) // The ancestry the binding carries is the model's too (issue #31): copied at the end, from the // callable that was actually bound, so the two cannot be read differently. .map { candidate -> @@ -238,7 +252,8 @@ internal object ArtifactScanner { // unwritable name, an ambiguous sibling): the binding then requires every argument // while the stub would still offer `= ...`. One view, taken from the binding. val omittable = omittableSlotsOf(callable) - val receiverIndex = if (callable.receiverTypeName != null) 1 else 0 + // A property's receiver is not one of its slots (`CallableKind.GETTER.hasReceiver`). + val receiverIndex = if (callable.receiverTypeName != null && callable.kind !in PROPERTY_KINDS) 1 else 0 candidate.copy( declaration = candidate.declaration.copy( returnSupertypes = callable.returnSupertypes, @@ -274,6 +289,11 @@ internal object ArtifactScanner { * the surviving bound declaration its omittable defaults. */ private fun dropCollidingConstructors(candidates: List): List { + // A property never claims a constructor's name (issue #38): `TextStyle.color` is read off a + // `TextStyle` value, it is not a constant `TextStyle` publishes under its own name. [scan] + // already keeps them out of this pass; this keeps the rule true for any other caller. + val (properties, others) = candidates.partition { it.declaration.kind in PROPERTY_KINDS } + if (properties.isNotEmpty()) return dropCollidingConstructors(others) + properties val (bound, declined) = candidates.partition { it.callable != null } val byName = bound.groupBy { it.callable!!.name } val boundNames = byName.keys @@ -379,25 +399,30 @@ internal object ArtifactScanner { ownerNode = node, classpath = classpath, ) + constructorCandidates(metadata.kmClass, node, classpath) + - objectConstantCandidates(metadata.kmClass, node, classpath) - is KotlinClassMetadata.FileFacade -> kotlinCandidates( - owner = kotlinPackageNameOverrideOf(node) ?: packageNameOf(node.name), - ownerIsClass = false, - functions = functionsOf(metadata.kmPackage), - ownerNode = node, - classpath = classpath, - ) + objectConstantCandidates(metadata.kmClass, node, classpath) + + memberPropertyCandidates(metadata.kmClass, node, classpath) + is KotlinClassMetadata.FileFacade -> { + val owner = kotlinPackageNameOverrideOf(node) ?: packageNameOf(node.name) + kotlinCandidates( + owner = owner, + ownerIsClass = false, + functions = functionsOf(metadata.kmPackage), + ownerNode = node, + classpath = classpath, + ) + extensionPropertyCandidates(metadata.kmPackage, node, owner, classpath) + } is KotlinClassMetadata.MultiFileClassFacade -> metadata.partClassNames.flatMap { partBinaryName -> val partNode = classpath.classNode(partBinaryName) ?: return@flatMap emptyList() val partMetadata = kotlinClassMetadataOf(partNode) as? KotlinClassMetadata.MultiFileClassPart ?: return@flatMap emptyList() + val owner = kotlinPackageNameOverrideOf(partNode) ?: packageNameOf(partBinaryName) kotlinCandidates( - owner = kotlinPackageNameOverrideOf(partNode) ?: packageNameOf(partBinaryName), + owner = owner, ownerIsClass = false, functions = functionsOf(partMetadata.kmPackage), ownerNode = partNode, classpath = classpath, - ) + ) + extensionPropertyCandidates(partMetadata.kmPackage, partNode, owner, classpath) } // A lone part is package-private and never reaches here on its own (the `ACC_PUBLIC` // guard above already excluded it) -- only through the facade case, which fetches it by @@ -464,6 +489,10 @@ internal object ArtifactScanner { { it.overloadSuffix(includeReceiver = true, qualified = false) }, { it.overloadSuffix(includeReceiver = true, qualified = true) }, ) + // A property is never an overload (issue #38): it has no parameters to tell members apart by, + // and it is not reached by a name the dispatcher selects among. Passed through untouched. + val (properties, others) = candidates.partition { it.declaration.kind in PROPERTY_KINDS } + if (properties.isNotEmpty()) return disambiguateOverloads(others) + properties // A candidate the binder already declined has no name to be ambiguous about; it passes // through so that `docs/design/pyi-generation-design.md` §2.2's "what is declined stays visible" // survives this stage too. @@ -541,9 +570,27 @@ internal object ArtifactScanner { val bySignature = functions.associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } return ownerNode.methods .filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) && it.access.hasFlag(Opcodes.ACC_STATIC) } - .filter { !it.access.hasFlag(Opcodes.ACC_SYNTHETIC) && !it.access.hasFlag(Opcodes.ACC_BRIDGE) } + .filter { !it.access.hasFlag(Opcodes.ACC_BRIDGE) } .filter { !it.name.startsWith("<") && '$' !in it.name } .mapNotNull { method -> + if (method.access.hasFlag(Opcodes.ACC_SYNTHETIC)) { + // A synthetic method is never bound. One case still reaches the model, declined: + // a public `inline fun ` compiles to a synthetic stub that only throws + // (the body exists only inlined), and B-1 says a reified declaration is + // *declined*, which `docs/design/pyi-generation-design.md` §2.2 means visibly. + // Every other synthetic (`@Deprecated(level = HIDDEN)`, compiler helpers) stays + // out silently, as before issue #38. + val reified = bySignature[method.name to method.desc]?.takeIf { it.hasReifiedTypeParameter } + ?: return@mapNotNull null + return@mapNotNull declinedCandidate( + owner, + ownerIsClass, + reified, + classpath, + "a reified type parameter: its type argument is read at run time, so kotlin.Any? cannot stand for it", + isComposable(method), + ) + } val function = bySignature[method.name to method.desc] // A `suspend` declaration's JVM shape carries a trailing `Continuation`, so its // descriptor never matches the one metadata records and it would fall out here @@ -746,6 +793,374 @@ internal object ArtifactScanner { ) } + // -------------------------------------------------------------------------- properties (#38) + + /** + * A public class's or interface's own **member** properties, as `GETTER` entries -- and, for a + * `var` whose setter is public, `SETTER` entries -- under `Owner.prop` and `Owner.prop=`. + * + * ### Why these are entries of their own kind + * + * A property is read off a value, not called: `state.value`, `style.fontSize`. The walker used to + * bind statics only, so the one way to reach `MutableState.value` from Python was a hand-written + * top-level function per property (the compose fixture's `writeRoot`), which is the per-declaration + * wrapper `docs/archive/pythonx-adapter-design.md` §7 rules out. `CallableKind.GETTER`/`SETTER` already + * say exactly this shape -- `args[0]` is the receiver, resolved from the proxy's handle, and is not + * counted in `arity` -- so nothing about the boundary is new; only a producer was missing. + * + * The binding layer serves them as Python properties of the receiver's proxy + * (`PythonxAdapter._attach`); `receiverTypeName` is the type they are read on, which for a member + * property is the owner itself. + * + * ### What is bound + * + * - the class is Kotlin-public and is a `class`, `interface` or `enum class`. An `object`'s + * properties are already `STATIC_GETTER`s ([objectConstantCandidates]), and an annotation has + * no instances to read; + * - the class **crosses as an object handle**. A value class the boundary opens (`Dp`, `Meters`) + * reaches Python as its raw primitive, so no proxy of it can ever exist to read a property on; + * - every supertype of the class is on the walk's (= the consumer's compile) classpath + * ([unreachableSupertypeOf]); otherwise each property is declined with the missing name; + * - the property is public, declares no extension receiver of its own (a member extension needs + * two receivers), is not a `@Composable get()` ([constantsOf] gives the measured reason), and + * its accessor is a real, non-synthetic method (a `@Deprecated(level = HIDDEN)` one is + * synthetic, and source cannot name it); + * - its type resolves at the boundary, a class type parameter being read as `kotlin.Any?` + * ([substituteAnyFor]); a bounded one declines the property. + * + * A **setter** additionally needs a public setter and **every** class type parameter unbounded, + * because it writes through `Owner`, which is only within bounds then; a getter + * reads through `Owner<*, ...>`, which always is. + */ + private fun memberPropertyCandidates( + kmClass: kotlin.metadata.KmClass, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + ): List { + if (kmClass.visibility != Visibility.PUBLIC) return emptyList() + if (kmClass.kind !in PROPERTY_OWNER_KINDS) return emptyList() + if (kmClass.properties.isEmpty()) return emptyList() + val owner = binaryNameToQualified(ownerNode.name) + val ownerType = KmType().apply { classifier = KmClassifier.Class(ownerNode.name) } + val receiverBoundary = resolveKotlinBoundary(ownerType, classpath, BoundaryDirection.PARAMETER) ?: return emptyList() + if (receiverBoundary.boundary.tag != OBJECT_TAG) return emptyList() + val receiverModel = kotlinTypeModelOf(ownerType, classpath) ?: return emptyList() + + val typeParameterCount = kmClass.typeParameters.size + // Generic: the receiver is cast with its type arguments written out, which the boundary's + // own read expression (a bare class name) cannot do. A generic value class would need both, + // and nothing measured is one. + if (typeParameterCount > 0 && kmClass.isValue) return emptyList() + val unbounded = substitutableTypeParameterIds(kmClass.typeParameters) + val readReceiver = if (typeParameterCount == 0) receiverBoundary.boundary.read("args[0]") + else "(args[0] as $owner${List(typeParameterCount) { "*" }.joinToString(", ", "<", ">")})" + val writeReceiver = when { + typeParameterCount == 0 -> receiverBoundary.boundary.read("args[0]") + unbounded == null -> null + else -> "(args[0] as $owner${List(typeParameterCount) { "kotlin.Any?" }.joinToString(", ", "<", ">")})" + } + // Each class type parameter that may be read as `Any?`; a bounded one stays a type parameter, + // and a property mentioning it is declined by [substituteAnyFor] answering `null`. + val readableIds = kmClass.typeParameters.filter { parameter -> + substitutableTypeParameterIds(listOf(parameter)) != null + }.mapTo(HashSet()) { it.id } + // Every property is read through `(args[0] as Owner)`, which needs all of `Owner`'s + // supertypes on the consumer's compile classpath ([unreachableSupertypeOf]). + val receiverDecline = classpath.unreachableSupertypeOf(ownerNode.name) + ?.let { missing -> unreachableReceiverReason(missing, owner) } + + return kmClass.properties.flatMap { property -> + if (property.visibility != Visibility.PUBLIC) return@flatMap emptyList() + if (property.receiverParameterType != null) return@flatMap emptyList() + propertyCandidates( + receiverDecline = receiverDecline, + property = property, + ownerNode = ownerNode, + classpath = classpath, + key = "$owner.${property.name}", + modelOwner = owner, + ownerIsClass = true, + receiverTypeName = owner, + receiverModel = receiverModel, + typeIds = readableIds, + readAccess = { "$readReceiver.${writeParameterName(property.name)}" }, + writeAccess = writeReceiver?.let { receiver -> { "$receiver.${writeParameterName(property.name)}" } }, + imports = emptyList(), + ) + } + } + + /** + * A file facade's (or multi-file part's) **extension property getters** -- the scope addition to + * issue #38. `Icons.Default.Add` is the case: material-icons-core declares every icon as + * `val Icons.Filled.Add: ImageVector`, compiled to `AddKt.getAdd(Icons$Filled)`, and the walker + * read a facade's `functions` only, so nothing at all had `Icons.Filled` as its receiver. + * + * Bound as `GETTER` entries under the package-level Kotlin name (`...icons.filled.Add`), with the + * receiver's type as `receiverTypeName` -- which is all the binding layer needs to serve it as an + * attribute of the receiver's proxy, the same way it serves a member property. The read is + * `receiver.alias` after `import pkg.name as alias`, for the reason [candidateFromFunction] gives + * for an extension function: Kotlin has no fully-qualified spelling for one. + * + * Getters only, as decided: an extension `var` is still reached through nothing. Declined with a + * reason: a receiver that does not cross as an object handle (`val Int.dp` -- no proxy of an `Int` + * exists to read it on), a generic extension property (property syntax has no place to write a + * type argument), and a `@Composable get()`. + */ + private fun extensionPropertyCandidates( + kmPackage: kotlin.metadata.KmPackage, + ownerNode: ClassNode, + owner: String, + classpath: ArtifactClasspath, + ): List = kmPackage.properties.flatMap { property -> + if (property.visibility != Visibility.PUBLIC) return@flatMap emptyList() + val receiver = property.receiverParameterType ?: return@flatMap emptyList() + val key = "$owner.${property.name}" + val receiverModel = kotlinTypeModelOf(receiver, classpath) ?: return@flatMap emptyList() + val propertyModel = kotlinTypeModelOf(property.returnType, classpath) + fun declined(reason: String): List { + val returns = propertyModel ?: return emptyList() + return listOf( + Candidate( + callable = null, + declaration = DeclarationModel( + simpleName = property.name, + owner = owner, + ownerIsClass = false, + receiver = receiverModel, + parameters = emptyList(), + returnType = returns, + kind = GETTER_KIND, + declineReason = reason, + ), + ), + ) + } + if (property.typeParameters.isNotEmpty()) { + return@flatMap declined("a generic extension property: property syntax has no place to write its type argument") + } + val receiverBoundary = resolveKotlinBoundary(receiver, classpath, BoundaryDirection.PARAMETER) + ?: return@flatMap declined("no boundary type for receiver ${kotlinClassifierNameOf(receiver) ?: receiver.classifier}") + if (receiverBoundary.boundary.tag != OBJECT_TAG) { + return@flatMap declined("its receiver crosses as ${receiverBoundary.boundary.tag}, so no proxy of it exists to read the property on") + } + // The same rule a member property's owner gets: `receiver.alias` resolves against the + // receiver's member scope too, which needs every supertype of it. + val receiverName = (receiver.classifier as? KmClassifier.Class)?.name + if (receiverName != null) { + val missing = classpath.unreachableSupertypeOf(receiverName.replace('.', '$')) + if (missing != null) { + return@flatMap declined(unreachableReceiverReason(missing, receiverName.replace('/', '.'))) + } + } + val alias = "artifact_ext_" + key.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + propertyCandidates( + property = property, + ownerNode = ownerNode, + classpath = classpath, + key = key, + modelOwner = owner, + ownerIsClass = false, + receiverTypeName = kotlinClassifierNameOf(receiver) ?: return@flatMap emptyList(), + receiverModel = receiverModel, + typeIds = emptySet(), + readAccess = { "${receiverBoundary.boundary.read("args[0]")}.$alias" }, + writeAccess = null, + imports = listOf("import $owner.${property.name} as $alias"), + ) + } + + /** + * The `GETTER` (and, when [writeAccess] is given and the setter qualifies, the `SETTER`) for one + * property, or the declined model saying why there is none. + * + * @param key the getter's table key; the setter's is `key=`, the convention + * `PythonProxySource.setterFor` already reads. + * @param typeIds the type parameters [property]'s type may mention, each read as `kotlin.Any?`. + * @param readAccess the Kotlin expression that reads the property off `args[0]`. + * @param writeAccess the assignable expression a setter writes, or `null` for none. + * @param receiverDecline set when nothing may be read on the receiver at all + * ([unreachableSupertypeOf]); the property is then declined with it, model still built. + */ + private fun propertyCandidates( + property: kotlin.metadata.KmProperty, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + key: String, + modelOwner: String, + ownerIsClass: Boolean, + receiverTypeName: String, + receiverModel: KotlinTypeModel, + typeIds: Set, + readAccess: () -> String, + writeAccess: (() -> String)?, + imports: List, + receiverDecline: String? = null, + ): List { + if (!isWritableParameterName(property.name)) return emptyList() + val type = property.returnType.substituteAnyFor(typeIds) ?: return emptyList() + val typeModel = kotlinTypeModelOf(type, classpath) ?: return emptyList() + val getterModel = DeclarationModel( + simpleName = property.name, + owner = modelOwner, + ownerIsClass = ownerIsClass, + receiver = receiverModel, + receiverBoundaryTag = OBJECT_TAG, + parameters = emptyList(), + returnType = typeModel, + kind = GETTER_KIND, + ) + fun declined(reason: String) = listOf(Candidate(null, getterModel.copy(declineReason = reason))) + if (receiverDecline != null) return declined(receiverDecline) + + // The accessor has to be a method source can name. Looked up by the signature `@Metadata` + // records for it -- read for its flags, never called by it (AGENTS.md §12.4: the generated + // body is Kotlin source, and `kotlinc` resolves the property access itself). + val getter = property.getterSignature?.let { signature -> + ownerNode.methods.firstOrNull { it.name == signature.name && it.desc == signature.descriptor } + } + val field = property.fieldSignature?.let { signature -> + ownerNode.fields.firstOrNull { it.name == signature.name && it.desc == signature.descriptor } + } + when { + getter != null -> { + if (!getter.access.hasFlag(Opcodes.ACC_PUBLIC) || getter.access.hasFlag(Opcodes.ACC_SYNTHETIC)) { + return declined("its getter is not a public, non-synthetic method (hidden or deprecated)") + } + if (isComposable(getter)) { + return declined("a @Composable getter: its value exists only inside a composition") + } + } + // `@JvmField`: no accessor, a public field that source reads as the property. + field != null && field.access.hasFlag(Opcodes.ACC_PUBLIC) && !field.access.hasFlag(Opcodes.ACC_SYNTHETIC) -> Unit + else -> return declined("no public accessor or field carries it") + } + + val returns = resolveKotlinType(type, classpath, BoundaryDirection.RETURN) + ?: return declined("no boundary type for property type ${kotlinClassifierNameOf(type) ?: type.classifier}") + val typeName = kotlinClassifierNameOf(type) ?: return declined("unnameable property type") + val getterCandidate = Candidate( + callable = ArtifactCallable( + name = key, + arity = 0, + paramTags = emptyList(), + returnTag = returns.tag, + lambdaBody = "{ args -> ${returns.wrapReturn(readAccess())} }", + imports = imports, + receiverTypeName = receiverTypeName, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = typeName, + returnSupertypes = returnSupertypesOf(type, returns.tag, classpath), + paramHasDefault = emptyList(), + kind = GETTER_KIND, + ), + declaration = getterModel.copy(bindingName = key, returnBoundaryTag = returns.tag), + ) + + val setterCandidate = writeAccess?.let { write -> + if (!property.isVar) return@let null + if (property.setter?.visibility != Visibility.PUBLIC) return@let null + val setterMethod = property.setterSignature?.let { signature -> + ownerNode.methods.firstOrNull { it.name == signature.name && it.desc == signature.descriptor } + } + val writable = when { + setterMethod != null -> setterMethod.access.hasFlag(Opcodes.ACC_PUBLIC) && !setterMethod.access.hasFlag(Opcodes.ACC_SYNTHETIC) + field != null -> getter == null && !field.access.hasFlag(Opcodes.ACC_FINAL) + else -> false + } + if (!writable) return@let null + val value = resolveKotlinType(type, classpath, BoundaryDirection.PARAMETER) ?: return@let null + // Metadata records a setter's parameter only when the source wrote one; a default + // setter's is the compiler's ``, which no keyword can spell. + val parameterName = property.setterParameter?.name?.takeIf { isWritableParameterName(it) } ?: "value" + val setterKey = "$key=" + Candidate( + callable = ArtifactCallable( + name = setterKey, + arity = 1, + paramTags = listOf(value.tag), + returnTag = "UNIT", + // An assignment is not an expression; the lambda answers `Unit` after it. + lambdaBody = "{ args -> ${write()} = ${value.read("args[1]")}; Unit }", + imports = imports, + receiverTypeName = receiverTypeName, + paramNames = listOf(parameterName), + paramTypeNames = listOf(typeName), + returnTypeName = UNIT_TYPE_NAME, + paramHasDefault = listOf(false), + kind = SETTER_KIND, + ), + declaration = getterModel.copy( + kind = SETTER_KIND, + bindingName = setterKey, + parameters = listOf(DeclaredParameter(parameterName, typeModel, declaresDefault = false, boundaryTag = value.tag)), + returnType = KotlinTypeModel(UNIT_TYPE_NAME), + returnBoundaryTag = "UNIT", + ), + ) + } + return listOfNotNull(getterCandidate, setterCandidate) + } + + /** + * The one name check a property needs, run after every other name is final. + * + * A property's key is not chosen by [disambiguateOverloads] and must not collide with a key that + * was: `UpcallTable` refuses a duplicate name outright, and the binding layer would then hold two + * rows under one `_TABLE` key. So a property whose key another binding (or another property -- + * two extension properties of one name, on different receivers, in one package) already carries + * is declined, by name and with the reason, rather than shadowing or being shadowed. A setter + * whose getter was declined goes with it: a write-only property is not a Kotlin shape. + */ + private fun settlePropertyNames(properties: List, settled: List): List { + val taken = settled.mapNotNullTo(HashSet()) { it.callable?.name } + val counts = properties.mapNotNull { it.callable?.name }.groupingBy { it }.eachCount() + val firstPass = properties.map { candidate -> + val name = candidate.callable?.name ?: return@map candidate + if (name in taken || counts.getValue(name) > 1) { + candidate.copy( + callable = null, + declaration = candidate.declaration.copy( + bindingName = null, + declineReason = "property key $name collides with another binding", + ), + ) + } else { + candidate + } + } + val boundGetters = firstPass.mapNotNullTo(HashSet()) { it.callable?.takeIf { c -> c.kind == GETTER_KIND }?.name } + return firstPass.map { candidate -> + val callable = candidate.callable ?: return@map candidate + if (callable.kind == SETTER_KIND && callable.name.removeSuffix("=") !in boundGetters) { + candidate.copy( + callable = null, + declaration = candidate.declaration.copy(bindingName = null, declineReason = "its getter is not bound"), + ) + } else { + candidate + } + } + } + + /** + * Why a property is not read on a receiver whose ancestry the consumer's compile classpath does not + * fully carry. Declined rather than bound, because the alternative is not a failing entry but a + * generated file `kotlinc` rejects as a whole -- every other binding of the artefact with it. + */ + private fun unreachableReceiverReason(missing: String, receiver: String): String = + "its receiver $receiver has a supertype, $missing, that is not on the compile classpath, " + + "so generated Kotlin cannot read a member of it" + + internal const val GETTER_KIND = "GETTER" + internal const val SETTER_KIND = "SETTER" + + /** The two kinds [scan] keeps out of the function passes; see [settlePropertyNames]. */ + internal val PROPERTY_KINDS = setOf(GETTER_KIND, SETTER_KIND) + + private val PROPERTY_OWNER_KINDS = setOf(ClassKind.CLASS, ClassKind.INTERFACE, ClassKind.ENUM_CLASS) + private fun constructorCandidates( kmClass: kotlin.metadata.KmClass, ownerNode: ClassNode, @@ -920,6 +1335,10 @@ internal object ArtifactScanner { jvmMethodName: String, ownerInternalName: String, ): Candidate? { + // A composable's bounded type parameter is left exactly as it was before issue #38: its + // declared slots are typed from the erased descriptor where metadata names no class, and its + // call is bytecode, which needs no type argument (`composableDeclaredSlot`). Only an + // *unbounded* one is substituted, and that changes a slot's declared name, never its shape. val declaredCount = function.allParameterTypes.size val shape = ComposableShape.of(declaredCount, paramDescriptors) ?: return null if (shape.totalCount != paramDescriptors.size) return null @@ -1459,6 +1878,9 @@ internal object ArtifactScanner { val model = declarationModelOf(owner, ownerIsClass, function, classpath, isComposable) ?: return null val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) } val receiverIndex = if (function.isExtension) 1 else 0 + if (function.hasUnsubstitutableTypeParameters) { + return declined("a bounded or reified type parameter: kotlin.Any? cannot stand for it") + } // Read before `resolveKotlinType` is asked anything, because for a function-typed parameter // it is the one that would answer -- and would answer "no". See [functionSlotOrNull]. @@ -1498,12 +1920,17 @@ internal object ArtifactScanner { val imports = if (function.isExtension) listOf("import $owner.${function.kotlinName} as $alias") else emptyList() + // Issue #38: a generic declaration's type arguments are written, never inferred -- each one + // `kotlin.Any?` (see `substitutableTypeParameterIds`). `structuralEqualityPolicy()` has no + // argument to infer `T` from, and would not compile without them. + val typeArguments = typeArgumentsOf(function) + /** The call that passes everything, positionally -- byte for byte what this generator * emitted before defaults existed, and still the `else`-less first branch below. */ val call = if (function.isExtension) { - "${argumentExpressions.first()}.$alias(${argumentExpressions.drop(1).joinToString(", ")})" + "${argumentExpressions.first()}.$alias$typeArguments(${argumentExpressions.drop(1).joinToString(", ")})" } else { - "$owner.${function.kotlinName}(${argumentExpressions.joinToString(", ")})" + "$owner.${function.kotlinName}$typeArguments(${argumentExpressions.joinToString(", ")})" } val arity = resolvedParams.size @@ -1762,8 +2189,9 @@ internal object ArtifactScanner { val arguments = argumentExpressions.indices .filter { it >= receiverIndex && it !in omitted } .joinToString(", ") { "${writeParameterName(names[it])} = ${argumentExpressions[it]}" } - return if (function.isExtension) "${argumentExpressions.first()}.$alias($arguments)" - else "$owner.${function.kotlinName}($arguments)" + val typeArguments = typeArgumentsOf(function) + return if (function.isExtension) "${argumentExpressions.first()}.$alias$typeArguments($arguments)" + else "$owner.${function.kotlinName}$typeArguments($arguments)" } val last = (1 shl ordered.size) - 1 @@ -1788,6 +2216,10 @@ internal object ArtifactScanner { } } + /** `` for a generic declaration, one per type parameter; empty otherwise. */ + private fun typeArgumentsOf(function: ResolvedFunction): String = + if (function.typeArgumentCount == 0) "" else List(function.typeArgumentCount) { "kotlin.Any?" }.joinToString(", ", "<", ">") + /** Kotlin's hard keywords: a parameter declared with one carries backticks in source and none in * `@Metadata`, so a named argument written from the metadata name alone would not parse. */ private val KOTLIN_HARD_KEYWORDS = setOf( diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt index fe3d1d958..40e362cab 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt @@ -6,9 +6,13 @@ import kotlin.metadata.KmConstructor import kotlin.metadata.KmDeclarationContainer import kotlin.metadata.KmFunction import kotlin.metadata.KmType +import kotlin.metadata.KmTypeParameter +import kotlin.metadata.KmTypeProjection import kotlin.metadata.Visibility import kotlin.metadata.declaresDefaultValue +import kotlin.metadata.isDefinitelyNonNull import kotlin.metadata.isNullable +import kotlin.metadata.isReified import kotlin.metadata.isSuspend import kotlin.metadata.isValue import kotlin.metadata.jvm.JvmMethodSignature @@ -246,6 +250,13 @@ internal fun resolveKotlinBoundary( } else { nullablePrimitiveBoundaryTypeOf(classifier.name)?.let { return ResolvedBoundary(it, carrier = null) } if (kotlinPrimitiveBoundaryTypeOf(classifier.name) != null) return null + // `kotlin.Any?` -- what an unbounded type parameter is read as (issue #38, see + // [substituteAnyFor]), and the one built-in with no class file that is nonetheless a + // complete answer: anything crosses as it. A Kotlin object arrives as the instance its + // handle names, a Python object as a `PyObject` (`UpcallTrampoline.toKotlinObject`), and + // `None` as `null`. Nullable only: a non-null `kotlin.Any` stays declined, because nothing + // here has asked for it and `None` would then be a value the declaration refuses. + if (classifier.name == KOTLIN_ANY) return ResolvedBoundary(NULLABLE_ANY_BOUNDARY, carrier = null) } val info = if (type.isNullable) null else classpath.valueClassInfo(classifier.name) @@ -361,6 +372,45 @@ internal fun ArtifactClasspath.nameablePublicSupertypesOf(kotlinInternalName: St private const val JAVA_LANG_OBJECT = "java/lang/Object" +/** + * The first supertype of [binaryName] -- direct or inherited, nearest first -- that this walk cannot + * find as a class file, as a qualified name; `null` when every one is there. + * + * ### Why a receiver needs its whole ancestry + * + * Generated Kotlin that reads a member off a value (`(args[0] as Owner).prop`, or an extension + * property's `receiver.alias`) makes `kotlinc` resolve `Owner`'s member scope, and that scope is built + * from every supertype. One that is not on the consumer's compile classpath fails the whole generated + * file: "Cannot access 'androidx.lifecycle.ViewModelStoreOwner' which is a supertype of + * 'androidx.compose.ui.platform.DefaultArchitectureComponentsOwner'. Check your module classpath" + * (ui-desktop 1.11 depends on lifecycle-viewmodel with `implementation` scope, so a consumer's compile + * classpath -- which is what this walk is given -- does not carry it). Nothing `Owner` itself declares + * says so; only walking the chain does. + * + * The JDK's own classes (`java/`, `javax/`, `jdk/`, `sun/`) are never among the roots a walk is given + * and are always there to `kotlinc`, and neither is `kotlin/`: the standard library is on every + * Kotlin compile classpath, while a test fixture's directory walk does not carry it. + */ +internal fun ArtifactClasspath.unreachableSupertypeOf(binaryName: String): String? { + val start = classNode(binaryName) ?: return null + val visited = HashSet() + val pending = ArrayDeque() + pending.addLast(start) + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + val parents = listOfNotNull(node.superName) + node.interfaces.orEmpty() + for (parent in parents) { + if (!visited.add(parent)) continue + if (ALWAYS_ON_A_KOTLIN_COMPILE_CLASSPATH.any { parent.startsWith(it) }) continue + val parentNode = classNode(parent) ?: return parent.replace('/', '.').replace('$', '.') + pending.addLast(parentNode) + } + } + return null +} + +private val ALWAYS_ON_A_KOTLIN_COMPILE_CLASSPATH = listOf("java/", "javax/", "jdk/", "sun/", "kotlin/") + /** * The object-handle boundary type: `docs/design/kotlin-extensions-in-python.md` §6's "type gate", and the * second of the two things that independently held Compose at zero. @@ -427,8 +477,13 @@ private fun objectBoundaryTypeOrNull(type: KmType, classpath: ArtifactClasspath) * exists for, and one that surfaces as a compile failure of the *generated* file rather than as * anything the generator could notice. */ -private fun renderKotlinTypeName(type: KmType, classpath: ArtifactClasspath): String? { +private fun renderKotlinTypeName(type: KmType, classpath: ArtifactClasspath, isArgument: Boolean = false): String? { val classifier = type.classifier as? KmClassifier.Class ?: return null + // A type *argument* may be `kotlin.Any` -- which is what an unbounded type parameter becomes + // (issue #38): `mutableStateOf`'s `policy: SnapshotMutationPolicy` is written + // `SnapshotMutationPolicy`. Only as an argument: at the top level a `kotlin.Any` + // slot is [resolveKotlinBoundary]'s own decision, and it opens the nullable one only. + if (isArgument && classifier.name == KOTLIN_ANY) return "kotlin.Any" + if (type.isNullable) "?" else "" if (!classpath.isNameablePublicClass(classifier.name)) return null val base = classifier.name.replace('/', '.') if (type.arguments.isEmpty()) return base + if (type.isNullable) "?" else "" @@ -436,11 +491,91 @@ private fun renderKotlinTypeName(type: KmType, classpath: ArtifactClasspath): St for (argument in type.arguments) { val argumentType = argument.type // A star projection is spellable as-is; anything else has to be a nameable type. - rendered += if (argumentType == null) "*" else renderKotlinTypeName(argumentType, classpath) ?: return null + rendered += if (argumentType == null) "*" else renderKotlinTypeName(argumentType, classpath, isArgument = true) ?: return null } return "$base<${rendered.joinToString(", ")}>" + if (type.isNullable) "?" else "" } +/** `kotlin.Any`, as `KmClassifier.Class` spells it. */ +internal const val KOTLIN_ANY = "kotlin/Any" + +/** What a `kotlin.Any?` slot crosses as: an object handle, cast to nothing narrower. */ +private val NULLABLE_ANY_BOUNDARY = BoundaryType("OBJECT", "(%s as kotlin.Any?)", "(%s)") + +/** + * Which of [parameters] may be read as `kotlin.Any?` -- every one of them, or `null` when even one + * may not (issue #38). + * + * ### The rule + * + * An **unbounded, non-reified** type parameter is substituted by `kotlin.Any?`, and the generated + * call names it explicitly (`mutableStateOf(value = ...)`), so Kotlin never has to infer + * it -- `structuralEqualityPolicy()` has nothing to infer `T` from and would not compile otherwise. + * That is exact rather than approximate: an unbounded `T` admits `Any?`, so the call is one Kotlin + * itself would accept, and the value Python hands in reaches the declaration unchanged. + * + * A **bounded** parameter (`T : Comparable`, `R : Any`) is declined: `Any?` is not within its + * bound and nothing narrower is known. A **reified** one is declined because its type argument is + * *read* at run time (`typeOf()`, `is T`), so `Any?` would change what the body does, not just + * what it is called with. + * + * `T : Any?` written out is the same as no bound, and is treated so. + */ +internal fun substitutableTypeParameterIds(parameters: List): Set? { + val ids = HashSet() + for (parameter in parameters) { + if (parameter.isReified) return null + val bounded = parameter.upperBounds.any { bound -> + (bound.classifier as? KmClassifier.Class)?.name != KOTLIN_ANY || !bound.isNullable + } + if (bounded) return null + ids += parameter.id + } + return ids +} + +/** + * [this] with every type parameter in [ids] replaced by `kotlin.Any?`, or `null` when it mentions a + * type parameter outside [ids] (a bounded one, or one this declaration does not own). + * + * Returns [this] itself when there is nothing to replace, so the common, non-generic signature is not + * copied. A replaced type keeps its own nullability, `suspend`-ness and arguments; a definitely-non-null + * `T & Any` is declined, since `Any?` is exactly what it excludes. + */ +internal fun KmType.substituteAnyFor(ids: Set): KmType? { + if (!mentionsTypeParameter()) return this + val own = classifier + if (own is KmClassifier.TypeParameter) { + if (own.id !in ids || isDefinitelyNonNull) return null + return nullableAnyType() + } + if (outerType != null) return null + val copy = KmType() + copy.classifier = own + copy.isNullable = isNullable + copy.isSuspend = isSuspend + for (projection in arguments) { + val argument = projection.type + copy.arguments += if (argument == null) { + projection + } else { + KmTypeProjection(projection.variance, argument.substituteAnyFor(ids) ?: return null) + } + } + return copy +} + +private fun KmType.mentionsTypeParameter(): Boolean = + classifier is KmClassifier.TypeParameter || + arguments.any { it.type?.mentionsTypeParameter() == true } || + outerType?.mentionsTypeParameter() == true + +/** A fresh `kotlin.Any?`. */ +internal fun nullableAnyType(): KmType = KmType().apply { + classifier = KmClassifier.Class(KOTLIN_ANY) + isNullable = true +} + /** Whether generated Kotlin in another module may write this classifier's name: it has to exist as * a class file this walk can see, be JVM-public, and -- when it is Kotlin -- be Kotlin-public too * (`internal` is JVM-public and is not a name anybody outside the module may say). */ @@ -527,6 +662,30 @@ internal data class ResolvedFunction( * from a declaration that was never declared. */ val isSuspend: Boolean = false, + /** + * How many type arguments a call has to write, each of them `kotlin.Any?` (issue #38): the + * declaration's own type parameters, every one unbounded and non-reified, already substituted + * out of [allParameterTypes], [receiverType] and [returnType] by [resolvedFunctionOrNull]. `0` + * for a declaration that is not generic -- and for one whose type parameters cannot be + * substituted, which keeps its type parameters and is declined downstream exactly as before. + */ + val typeArgumentCount: Int = 0, + /** + * The declaration has a type parameter that cannot be read as `kotlin.Any?` -- bounded or + * reified (issue #38). Carried rather than left to fall out of an unnameable type, because a + * reified parameter need not appear in the signature at all (`typeNameOf(): String`) and such a + * declaration would otherwise look non-generic and be called with no type argument. + */ + val hasUnsubstitutableTypeParameters: Boolean = false, + /** + * One of the declaration's type parameters is `reified`. Carried apart from + * [hasUnsubstitutableTypeParameters] because `kotlinc` compiles such a function to an + * `ACC_SYNTHETIC` method (a stub that only throws -- the real body exists only inlined), which + * `ArtifactScanner.kotlinCandidates` would otherwise drop with every other synthetic before any + * model is built. This is how it is declined *visibly* instead (B-1: "a bounded or reified one + * still declined"). + */ + val hasReifiedTypeParameter: Boolean = false, ) /** The name given to the extension-receiver slot. Deliberately not a Python identifier: a receiver @@ -606,13 +765,24 @@ internal fun functionsOf(container: KmDeclarationContainer): List + val params = declaredParams.map { it.substituteAnyFor(ids) ?: return@let null } + val returns = function.returnType.substituteAnyFor(ids) ?: return@let null + params to returns + } + val allParams = substituted?.first ?: declaredParams + val receiver = if (declaredReceiver != null) allParams.first() else null return ResolvedFunction( kotlinName = function.name, isExtension = receiver != null, allParameterTypes = allParams, - returnType = function.returnType, + returnType = substituted?.second ?: function.returnType, jvmSignature = signature, receiverType = receiver, allParameterNames = (if (receiver != null) listOf(RECEIVER_PARAMETER_NAME) else emptyList()) + @@ -620,6 +790,9 @@ private fun resolvedFunctionOrNull(function: KmFunction): ResolvedFunction? { allParameterDefaults = (if (receiver != null) listOf(false) else emptyList()) + function.valueParameters.map { it.declaresDefaultValue }, isSuspend = function.isSuspend, + typeArgumentCount = if (substituted != null) function.typeParameters.size else 0, + hasUnsubstitutableTypeParameters = function.typeParameters.isNotEmpty() && substituted == null, + hasReifiedTypeParameter = function.typeParameters.any { it.isReified }, ) } diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt index 7e5bccacd..14fbbbe47 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt @@ -80,6 +80,12 @@ internal data class DeclarationModel( val parameterNamesKnown: Boolean = true, val isComposable: Boolean = false, val isSuspend: Boolean = false, + /** + * `FUNCTION`, `STATIC_GETTER` (an object's constant), or `GETTER`/`SETTER` (issue #38): a + * property read off an instance, whose [receiver] is the type it is read on -- the owner of a + * member property, the receiver of an extension property -- and whose [owner] is the class or + * package that declares it. A `SETTER`'s one parameter is the value written. + */ val kind: String = "FUNCTION", /** * What the declared return type **is a**, nearest first, as qualified Kotlin names -- the 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 3c3c27dc0..19dec4916 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 @@ -43,6 +43,16 @@ import python.multiplatform.gradle.model.ValueClassModel * each table-key spelling (`padding__Dp`) are attributes, because the runtime indexes both. * The module-level `def`s stay, with the receiver as the first positional-only parameter. * + * ### Properties (issue #38) + * + * A `GETTER`/`SETTER` key is not a module attribute: the binding layer serves it as a Python + * `property` of the proxy of the type it is read on (`receiver`). So it is stubbed there, as + * `@property` (plus `@.setter` for a `var`), and each accessor's docstring carries its table key + * after [PROPERTY_MARKER] -- the one place a test can match a key to its stub. A property whose + * receiver has no stub class (its name is taken by a function or a module of its package, so the + * type is annotated `Any`) cannot be written anywhere true; it gets a comment carrying the same + * marker in the receiver package's module, saying so, rather than vanishing. + * * ### Overloads * * An overload set is `@overload`ed under its base name, **in table-key order**: the artefact table is @@ -62,6 +72,11 @@ private const val RECEIVER_NAME = "receiver" internal fun renderKotlinFqnStubs(declarations: List): Map = StubRenderer(declarations.filter { it.bindingName != null && !it.isSuspend }).render() +/** What precedes a property accessor's table key in its stub; see this file's KDoc. */ +internal const val PROPERTY_MARKER = "Kotlin property: " + +private val PROPERTY_KINDS = setOf("GETTER", "SETTER") + /** Python's hard keywords (`keyword.kwlist`): none of these can be a parameter name. */ private val PYTHON_KEYWORDS = setOf( "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", @@ -136,7 +151,21 @@ private fun classRefOf(qualifiedName: String): ClassRef? { private enum class Role { PARAM, RESULT } -private class StubRenderer(private val bound: List) { +private class StubRenderer(declarations: List) { + + /** Everything that is a module attribute; properties are attributes of a proxy instead. */ + private val bound: List = declarations.filter { it.kind !in PROPERTY_KINDS } + + /** receiver type -> property name -> (getter, setter). A setter never stands without its getter. */ + private val properties: Map>> = run { + val accessors = declarations.filter { it.kind in PROPERTY_KINDS && it.receiver != null } + val setters = accessors.filter { it.kind == "SETTER" }.associateBy { it.bindingName!!.removeSuffix("=") } + accessors.filter { it.kind == "GETTER" } + .groupBy { it.receiver!!.qualifiedName } + .mapValues { (_, getters) -> + getters.sortedBy { it.simpleName }.associate { it.simpleName to (it to setters[it.bindingName]) } + } + } // ------------------------------------------------------------------------ what is taken @@ -445,6 +474,54 @@ private class StubRenderer(private val bound: List) { } node.members += "$attribute: $TYPING.ClassVar[$protocol]" } + properties[qualifiedName].orEmpty().forEach { (name, accessors) -> + val (getter, setter) = accessors + if (!isIdentifier(name) || name in node.children || name in attributes) { + node.members += propertyNotStubbed(getter, setter, "'$name' cannot be written as an attribute here") + return@forEach + } + node.members += renderProperty(name, getter, setter, ctx) + } + } + + private fun renderProperty(name: String, getter: DeclarationModel, setter: DeclarationModel?, ctx: ModuleOut): String = buildString { + appendLine("@property") + appendLine("def $name(self) -> ${result(getter, ctx)}:") + appendLine(" \"\"\"$PROPERTY_MARKER${getter.bindingName} (${propertySignatureOf(getter)})\"\"\"") + append(" ...") + if (setter != null) { + val parameter = setter.parameters.single() + appendLine() + appendLine("@$name.setter") + appendLine("def $name(self, value: ${annotate(parameter.type, parameter.boundaryTag, Role.PARAM, ctx)}) -> None:") + appendLine(" \"\"\"$PROPERTY_MARKER${setter.bindingName}\"\"\"") + append(" ...") + } + } + + private fun propertyNotStubbed(getter: DeclarationModel, setter: DeclarationModel?, why: String): String = + listOfNotNull(getter, setter).joinToString("\n") { "# $PROPERTY_MARKER${it.bindingName} is not stubbed: $why" } + + private fun propertySignatureOf(getter: DeclarationModel): String { + val type = getter.returnType.qualifiedName + if (getter.returnType.isNullable) "?" else "" + return "${getter.receiver!!.qualifiedName}.${getter.simpleName}: $type" + } + + /** + * Every receiver that has properties gets its stub class -- even one no signature mentions, since + * a value of it can still reach Python (a `kotlin.Any?` result, a supertype's slot). One whose + * class cannot be stubbed gets the marker comment instead; see this file's KDoc. + */ + private fun registerPropertyOwners() { + properties.forEach { (receiver, byName) -> + val ref = classRefOf(receiver) + val home = ref?.pkg ?: receiver.substringBeforeLast('.', "") + val ctx = out(home) + if (ref != null && classReference(receiver, null, ctx) != ANY) return@forEach + byName.values.forEach { (getter, setter) -> + ctx.defs += propertyNotStubbed(getter, setter, "${receiver.substringAfterLast('.')} has no stub class (its name is taken in its module)") + } + } } private fun renderClasses() { @@ -471,6 +548,7 @@ private class StubRenderer(private val bound: List) { fun render(): Map { renderModuleDefs() + registerPropertyOwners() renderClasses() return modules.filterValues { it.defs.isNotEmpty() || it.classes.isNotEmpty() } .mapValues { (_, ctx) -> diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt index bc16c5ba2..d6f04774d 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt @@ -183,12 +183,27 @@ class FunctionSlotBindingTest { val pointerInput = uiDeclarations.filter { it.simpleName == "pointerInput" } assertTrue(pointerInput.isNotEmpty(), "pointerInput was not walked at all") // Through Compose 1.6 every overload's `block` was a suspend lambda. From Compose 1.8 the keyed - // overloads take a `PointerInputEventHandler` (a fun interface) instead and decline earlier, on - // their `Any?` keys; only the key-less overload still carries the suspend lambda. All of them - // must stay declined, and the suspend one must say why. + // overloads take a `PointerInputEventHandler` (a fun interface) instead; only the key-less + // overload still carries the suspend lambda. Until issue #38 the keyed ones also declined, on + // their `Any?` keys -- an incidental reason, not this test's subject: #38 (SPEC B-1/U-10) makes + // `kotlin.Any?` cross as an object handle, so a keyed overload, whose `block` is an + // interface-typed object slot and not a function slot at all, now binds. What this test is + // about still holds without exception: no overload with a function-typed slot binds, and + // every overload that does not bind says why. assertTrue( - pointerInput.all { it.bindingName == null && it.declineReason != null }, - "every pointerInput overload must decline with a reason: ${pointerInput.map { it.declineReason }}", + pointerInput.all { declaration -> + if (declaration.bindingName == null) { + declaration.declineReason != null + } else { + declaration.parameters.none { it.type.qualifiedName.startsWith("kotlin.Function") } + } + }, + "a pointerInput overload with a function-typed slot bound, or one declined silently: " + + "${pointerInput.map { it.bindingName to it.declineReason }}", + ) + assertTrue( + pointerInput.none { it.bindingName == null && it.declineReason == null }, + "every declined pointerInput overload must say why: ${pointerInput.map { it.declineReason }}", ) assertTrue( pointerInput.any { it.declineReason?.contains("suspend") == true }, diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt new file mode 100644 index 000000000..92b4b99d2 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt @@ -0,0 +1,235 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Issue #38 over `PropertyFixtures.kt`: unbounded generic functions bind with their type parameters + * read as `kotlin.Any?`, member properties of public classes bind as `GETTER`/`SETTER` entries, and + * top-level extension properties bind as `GETTER`s. + * + * ### Red before the implementation + * + * Every positive assertion here fails against a walker that reads no `kmClass.properties` and no + * `kmPackage.properties` (no `GETTER` entry exists at all -- `single { ... }` throws "Collection + * contains no element matching") and that declines every type parameter (`identity` and `emptyBox` + * absent). The negative assertions (`largest`, `typeNameOf`, `squared`, the private and internal + * properties, `Registry`, `Grams`) already hold before it, and are what must *keep* holding. + */ +class PropertyBindingTest { + + private val fixtureClasses: File + get() { + val location = Class.forName("fixture.artifactproperty.Counter").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isDirectory, "expected a directory of .class files: $it") } + } + + private val entries by lazy { + ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + } + + private fun entry(name: String): ArtifactCallable = + assertNotNull(entries.singleOrNull { it.name == name }, "$name is not bound; bound: ${entries.map { it.name }}") + + // ------------------------------------------------------------------------------ generics + + @Test + fun anUnboundedTypeParameterIsReadAsNullableAnyAndWrittenOutAtTheCall() { + val identity = entry("fixture.artifactproperty.identity") + assertEquals(listOf("OBJECT"), identity.paramTags) + assertEquals(listOf("kotlin.Any"), identity.paramTypeNames) + assertEquals("kotlin.Any", identity.returnTypeName) + assertEquals("OBJECT", identity.returnTag) + assertEquals( + "{ args -> (fixture.artifactproperty.identity((args[0] as kotlin.Any?))) }", + identity.lambdaBody, + ) + } + + /** `structuralEqualityPolicy()`'s shape: nothing to infer `T` from, so the type argument is the only way it compiles. */ + @Test + fun aTypeParameterNothingCanInferIsStillWrittenOut() { + val emptyBox = entry("fixture.artifactproperty.emptyBox") + assertEquals(0, emptyBox.arity) + assertEquals("fixture.artifactproperty.Box", emptyBox.returnTypeName) + assertEquals("{ (fixture.artifactproperty.emptyBox()) }", emptyBox.lambdaBody) + } + + @Test + fun aBoundedOrReifiedTypeParameterIsStillDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.largest" !in names, names.toString()) + assertTrue("fixture.artifactproperty.typeNameOf" !in names, names.toString()) + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + val reified = declarations.single { it.simpleName == "typeNameOf" } + assertEquals(null, reified.bindingName) + assertTrue(reified.declineReason!!.contains("reified"), reified.declineReason) + } + + // ---------------------------------------------------------------------- member properties + + @Test + fun aMemberValAndVarBindAsGetterAndSetter() { + val count = entry("fixture.artifactproperty.Counter.count") + assertEquals("GETTER", count.kind) + assertEquals(0, count.arity) + assertEquals("fixture.artifactproperty.Counter", count.receiverTypeName) + assertEquals("INT", count.returnTag) + assertEquals("{ args -> ((args[0] as fixture.artifactproperty.Counter).count).toLong() }", count.lambdaBody) + + val setCount = entry("fixture.artifactproperty.Counter.count=") + assertEquals("SETTER", setCount.kind) + assertEquals(1, setCount.arity) + assertEquals(listOf("INT"), setCount.paramTags) + assertEquals(listOf("kotlin.Int"), setCount.paramTypeNames) + assertEquals("UNIT", setCount.returnTag) + assertEquals( + "{ args -> (args[0] as fixture.artifactproperty.Counter).count = (args[1] as Long).toInt(); Unit }", + setCount.lambdaBody, + ) + + assertEquals("GETTER", entry("fixture.artifactproperty.Counter.name").kind) + assertTrue(entries.none { it.name == "fixture.artifactproperty.Counter.name=" }, "a val has no setter") + assertEquals(listOf("STRING"), entry("fixture.artifactproperty.Counter.note=").paramTags, "a nullable String var") + } + + @Test + fun aPropertyNobodyOutsideMayWriteHasNoSetterAndOneNobodyMayReadIsAbsent() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.Counter.guarded" in names, names.toString()) + assertTrue("fixture.artifactproperty.Counter.guarded=" !in names, "a private setter was bound") + assertTrue(names.none { it.endsWith(".hidden") || it.endsWith(".internalProp") }, names.toString()) + } + + /** A property is not a constant the class publishes under its name: the constructor survives it. */ + @Test + fun aPropertyDoesNotCostItsClassItsConstructor() { + assertEquals(2, entry("fixture.artifactproperty.Counter").arity) + } + + /** `MutableState.value`: read through `Box<*>`, written through `Box`. */ + @Test + fun aGenericVarIsReadThroughAStarAndWrittenThroughAny() { + val content = entry("fixture.artifactproperty.Box.content") + assertEquals("kotlin.Any", content.returnTypeName) + assertEquals("{ args -> ((args[0] as fixture.artifactproperty.Box<*>).content) }", content.lambdaBody) + assertEquals( + "{ args -> (args[0] as fixture.artifactproperty.Box).content = (args[1] as kotlin.Any?); Unit }", + entry("fixture.artifactproperty.Box.content=").lambdaBody, + ) + assertEquals("GETTER", entry("fixture.artifactproperty.Box.size").kind) + assertEquals("GETTER", entry("fixture.artifactproperty.Holder.held").kind, "an interface's abstract val") + } + + @Test + fun aBoundedClassTypeParameterDeclinesWhatMentionsItAndEverySetter() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.Bounded.item" !in names, names.toString()) + assertTrue("fixture.artifactproperty.Bounded.plain" in names, names.toString()) + assertTrue("fixture.artifactproperty.Bounded.plain=" !in names, names.toString()) + } + + @Test + fun anObjectsAndAnOpenedValueClasssPropertiesAreNotGetters() { + assertEquals("STATIC_GETTER", entry("fixture.artifactproperty.Registry.size").kind) + assertTrue(entries.none { it.name.startsWith("fixture.artifactproperty.Grams.") }, entries.map { it.name }.toString()) + } + + // ------------------------------------------------------------------- extension properties + + /** `Icons.Filled.Add`'s shape. */ + @Test + fun anExtensionPropertyBindsAsAGetterOnItsReceiver() { + val doubled = entry("fixture.artifactproperty.doubled") + assertEquals("GETTER", doubled.kind) + assertEquals(0, doubled.arity) + assertEquals("fixture.artifactproperty.Counter", doubled.receiverTypeName) + assertEquals( + listOf("import fixture.artifactproperty.doubled as artifact_ext_fixture_artifactproperty_doubled"), + doubled.imports, + ) + assertEquals( + "{ args -> ((args[0] as fixture.artifactproperty.Counter).artifact_ext_fixture_artifactproperty_doubled).toLong() }", + doubled.lambdaBody, + ) + assertEquals("GETTER", entry("fixture.artifactproperty.mirrored").kind) + assertTrue(entries.none { it.name == "fixture.artifactproperty.mirrored=" }, "an extension var's setter was bound") + } + + @Test + fun anExtensionPropertyOnAPrimitiveOrGenericIsDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.squared" !in names, names.toString()) + assertTrue("fixture.artifactproperty.contentOrNull" !in names, names.toString()) + val squared = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + .single { it.simpleName == "squared" } + assertTrue(squared.declineReason!!.contains("receiver"), squared.declineReason) + } + + // ----------------------------------------------------- a receiver the classpath cannot see + + /** + * The `:ksp-fixtures:compose` compile failure on ui-desktop 1.11's + * `DefaultArchitectureComponentsOwner`: its supertypes live in lifecycle jars that the consumer's + * compile classpath does not carry, so `(args[0] as Owner).lifecycle` does not compile. Declined + * with a reason naming the missing supertype -- for a member property and an extension property + * alike -- and bound once the classpath carries it, so the decline is about the classpath and not + * the class. + * + * Red before the fix: `Detached.level` and `reach` are bound (the first assertion fails). + */ + @Test + fun aReceiverWithASupertypeOffTheClasspathHasItsPropertiesDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.Detached.level" !in names, names.toString()) + assertTrue("fixture.artifactproperty.reach" !in names, names.toString()) + + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + val level = declarations.single { it.owner == "fixture.artifactproperty.Detached" && it.simpleName == "level" } + val reach = declarations.single { it.simpleName == "reach" } + listOf(level, reach).forEach { declined -> + assertEquals(null, declined.bindingName, declined.toString()) + assertEquals("GETTER", declined.kind, declined.toString()) + val reason = assertNotNull(declined.declineReason, declined.toString()) + assertTrue("org.objectweb.asm.Opcodes" in reason && "classpath" in reason, reason) + } + + val asm = File(org.objectweb.asm.Opcodes::class.java.protectionDomain.codeSource.location.toURI()) + val withSupertype = ArtifactScanner.scanJar( + fixtureClasses, + includePrefixes = listOf("fixture.artifactproperty"), + classpath = listOf(fixtureClasses, asm), + ).map { it.name } + assertTrue("fixture.artifactproperty.Detached.level" in withSupertype, withSupertype.toString()) + assertTrue("fixture.artifactproperty.reach" in withSupertype, withSupertype.toString()) + } + + // -------------------------------------------------------------------------- the fragment + + /** A property is not an extension slot at the boundary: `isExtension = false`, receiver still named. */ + @Test + fun aRenderedGetterIsAGetterKindWithItsReceiverNamed() { + val source = renderArtifactFragmentSource( + ArtifactFragment("F", "m", listOf(entry("fixture.artifactproperty.Counter.count="))), + ) + assertTrue("kind = python.multiplatform.reflection.CallableKind.SETTER" in source, source) + assertTrue("isExtension = false" in source, source) + assertTrue("receiverTypeName = \"fixture.artifactproperty.Counter\"" in source, source) + } + + /** The model and the table agree for properties too. */ + @Test + fun everyBoundPropertyHasAModelOfItsKind() { + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + val byKey = declarations.filter { it.bindingName != null }.associateBy { it.bindingName!! } + entries.filter { it.kind == "GETTER" || it.kind == "SETTER" }.forEach { callable -> + val model = assertNotNull(byKey[callable.name], "no model for ${callable.name}") + assertEquals(callable.kind, model.kind, callable.name) + assertEquals(callable.receiverTypeName, model.receiver?.qualifiedName, callable.name) + } + assertEquals(listOf("value"), byKey.getValue("fixture.artifactproperty.Counter.count=").parameters.map { it.name }) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt new file mode 100644 index 000000000..794a1edcb --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt @@ -0,0 +1,89 @@ +// Fixture, not production source: compiled with this module's tests so `PropertyBindingTest` can walk +// it as bytecode plus `@Metadata`, the same way `ValueClassFixtures.kt` is walked (issue #38). +@file:Suppress("unused", "UNCHECKED_CAST", "UNUSED_PARAMETER") + +package fixture.artifactproperty + +/** An ordinary class: a `val`, a `var`, a nullable `var`, and two properties nobody outside may name. */ +class Counter(var count: Int, val name: String) { + var note: String? = null + private var hidden: Int = 0 + internal val internalProp: Int = 1 + + /** A `var` whose setter is not public: the getter is bound, the setter is not. */ + var guarded: Int = 0 + private set +} + +/** `MutableState`'s shape: a generic `var` of the class's own unbounded type parameter. */ +class Box(var content: T) { + val size: Int get() = 1 +} + +/** A bounded class type parameter: `Owner` is not within bounds, so a property mentioning `T` + * is declined, and no setter is bound for anything (it would write through `Owner`). */ +class Bounded(var item: T) { + var plain: Int = 0 +} + +/** `State`'s shape: an interface with an abstract `val` of its unbounded type parameter. */ +interface Holder { + val held: T +} + +/** An `object`'s properties are `STATIC_GETTER`s already, never `GETTER`s. */ +object Registry { + val size: Int = 3 +} + +/** A value class the boundary opens: it reaches Python as its raw primitive, so no proxy of it exists. */ +@JvmInline +value class Grams(val amount: Double) + +// ------------------------------------------------------------------------------------- generics + +/** Unbounded: bound, with `T` read as `kotlin.Any?` and written out at the call. */ +fun identity(value: T): T = value + +/** Nothing to infer `T` from: only an explicit type argument makes the generated call compile. */ +fun emptyBox(): Box = Box(null as T) + +/** Bounded: `Any?` is not within `Comparable`, so this stays declined. */ +fun > largest(a: T, b: T): T = if (a > b) a else b + +/** Reified: the type argument is read at run time, so substituting it would change the body. */ +inline fun typeNameOf(): String = T::class.simpleName ?: "?" + +// -------------------------------------------------------------------------- extension properties + +/** `Icons.Filled.Add`'s shape: a top-level extension property on an object-handle receiver. */ +val Counter.doubled: Int get() = count * 2 + +/** A receiver that crosses as a primitive: there is no proxy of an `Int` to read it on. */ +val Int.squared: Int get() = this * this + +/** An extension `var`: its getter is bound, its setter is not (getters only, as decided). */ +var Counter.mirrored: Int + get() = count + set(value) { + count = value + } + +/** Generic: property syntax has no place to write a type argument. */ +val Box.contentOrNull: T? get() = content + +// ------------------------------------------------------------- a receiver the classpath cannot see + +/** + * `androidx.compose.ui.platform.DefaultArchitectureComponentsOwner`'s shape: a public class with a + * supertype (`org.objectweb.asm.Opcodes`, standing in for lifecycle's `ViewModelStoreOwner`) that the + * walk's classpath -- this fixture directory alone -- does not carry. Generated Kotlin reading + * `level` off it would not compile ("Cannot access ... which is a supertype of ..."), so its + * properties are declined; given a classpath that carries the supertype, they bind. + */ +class Detached : org.objectweb.asm.Opcodes { + val level: Int = 1 +} + +/** The same receiver, read through an extension property. */ +val Detached.reach: Int get() = level diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PropertyStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PropertyStubTest.kt new file mode 100644 index 000000000..137a326c2 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PropertyStubTest.kt @@ -0,0 +1,137 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #38: a `GETTER`/`SETTER` is stubbed where the binding layer serves it -- as a `@property` of + * its receiver's stub class -- and never as a module attribute. + * + * Red before the implementation: the renderer treated a property row like any other, so + * `MutableState.value` became `def value()` in a module `androidx/compose/runtime/MutableState`, and + * the `Kotlin property:` marker existed nowhere. + */ +class PropertyStubTest { + + private val runtime = "androidx.compose.runtime" + private val mutableState = KotlinTypeModel("$runtime.MutableState") + private val any = KotlinTypeModel("kotlin.Any", isNullable = true) + + private val factory = DeclarationModel( + simpleName = "mutableStateOf", + owner = runtime, + ownerIsClass = false, + receiver = null, + parameters = listOf(DeclaredParameter("value", any, declaresDefault = false, boundaryTag = "OBJECT")), + returnType = mutableState, + returnBoundaryTag = "OBJECT", + bindingName = "$runtime.mutableStateOf", + ) + + private val getter = DeclarationModel( + simpleName = "value", + owner = "$runtime.MutableState", + ownerIsClass = true, + receiver = mutableState, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = any, + returnBoundaryTag = "OBJECT", + bindingName = "$runtime.MutableState.value", + kind = "GETTER", + ) + + private val setter = getter.copy( + kind = "SETTER", + bindingName = "$runtime.MutableState.value=", + parameters = listOf(DeclaredParameter("value", any, declaresDefault = false, boundaryTag = "OBJECT")), + returnType = KotlinTypeModel("kotlin.Unit"), + returnBoundaryTag = "UNIT", + ) + + @Test + fun aVarIsAPropertyWithASetterOnItsReceiversClass() { + val files = renderKotlinFqnStubs(listOf(factory, getter, setter)) + assertEquals(setOf("androidx/compose/runtime/__init__.pyi"), files.keys, "a property made a module of its own") + val stub = files.getValue("androidx/compose/runtime/__init__.pyi") + assertTrue("class MutableState:" in stub, stub) + assertTrue(" @property\n def value(self) -> _t.Any | None:" in stub, stub) + assertTrue(" @value.setter\n def value(self, value: _t.Any | None) -> None:" in stub, stub) + assertTrue("Kotlin property: androidx.compose.runtime.MutableState.value " in stub, stub) + assertTrue("Kotlin property: androidx.compose.runtime.MutableState.value=" in stub, stub) + assertFalse(Regex("^def value", RegexOption.MULTILINE).containsMatchIn(stub), "a property became a module function") + } + + @Test + fun aValHasNoSetter() { + val stub = renderKotlinFqnStubs(listOf(factory, getter)).getValue("androidx/compose/runtime/__init__.pyi") + assertTrue("@property" in stub, stub) + assertFalse("setter" in stub, stub) + } + + /** + * An extension property is stubbed on its receiver's class too, not in the package that declares + * it -- `Icons.Filled.Add` is read off `Icons.Default`, never imported from `icons.filled`. + */ + @Test + fun anExtensionPropertyIsStubbedOnItsReceiver() { + val receiver = KotlinTypeModel("p.Counter") + val doubled = DeclarationModel( + simpleName = "doubled", + owner = "p.ext", + ownerIsClass = false, + receiver = receiver, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = KotlinTypeModel("kotlin.Int"), + returnBoundaryTag = "INT", + bindingName = "p.ext.doubled", + kind = "GETTER", + ) + val files = renderKotlinFqnStubs(listOf(doubled)) + assertEquals(setOf("p/__init__.pyi"), files.keys) + val stub = files.getValue("p/__init__.pyi") + assertTrue("class Counter:" in stub, stub) + assertTrue(" def doubled(self) -> int:" in stub, stub) + assertTrue("Kotlin property: p.ext.doubled " in stub, stub) + } + + /** + * A receiver whose name its own module already spends on a function has no stub class to put + * the property in; the key is still accounted for, as a comment, rather than silently absent. + */ + @Test + fun aPropertyWhoseReceiverHasNoStubClassIsStillAccountedFor() { + val receiver = KotlinTypeModel("p.Counter") + val constructorLike = DeclarationModel( + simpleName = "Counter", + owner = "p", + ownerIsClass = false, + receiver = null, + parameters = emptyList(), + returnType = receiver, + returnBoundaryTag = "OBJECT", + bindingName = "p.Counter", + ) + val count = DeclarationModel( + simpleName = "count", + owner = "p.Counter", + ownerIsClass = true, + receiver = receiver, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = KotlinTypeModel("kotlin.Int"), + returnBoundaryTag = "INT", + bindingName = "p.Counter.count", + kind = "GETTER", + ) + val stub = renderKotlinFqnStubs(listOf(constructorLike, count)).getValue("p/__init__.pyi") + assertTrue(Regex("^# Kotlin property: p\\.Counter\\.count is not stubbed", RegexOption.MULTILINE).containsMatchIn(stub), stub) + assertFalse("class Counter" in stub, stub) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt index fab8de6f9..b85782048 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt @@ -259,6 +259,25 @@ object PythonxAdapter { _PACKAGES_SEEN = set() _CHILDREN = {} # kotlin package -> the Kotlin names of its direct child packages/objects _SUPERTYPES = {} # kotlin type name -> the types it is a, nearest first + _PROPERTIES = {} # kotlin type a property is read on -> {kotlin name -> _Property} + + + # The two row kinds that are attributes of a *value* rather than names in a package (issue #38): + # `Owner.prop` (and `Owner.prop=` for a `var`), and an extension property's `pkg.prop`. Neither + # is indexed by package -- `MutableState.value` is not a module, and `Icons.Filled.Add` is read + # off `Icons.Default`, not imported -- so both live in `_PROPERTIES`, keyed by the type the + # row's `receiver_type_name` says they are read on. + _PROPERTY_KINDS = ('GETTER', 'SETTER') + + + class _Property: + '''One Kotlin property of one type: its getter row and, for a `var`, its setter row.''' + + __slots__ = ('getter', 'setter') + + def __init__(self): + self.getter = None + self.setter = None # What `ArtifactRendering.SUPERTYPE_SEPARATOR` writes between a declared return type and its @@ -310,6 +329,7 @@ object PythonxAdapter { _PACKAGES_SEEN.clear() _CHILDREN.clear() _SUPERTYPES.clear() + _PROPERTIES.clear() present = set() for row in rows: kotlin_name = row[0] @@ -326,6 +346,20 @@ object PythonxAdapter { # disagreement would be a walker bug rather than something to reconcile here. if decl.return_supertypes and decl.return_type_name not in _SUPERTYPES: _SUPERTYPES[decl.return_type_name] = decl.return_supertypes + if decl.kind in _PROPERTY_KINDS: + # A property is served on its receiver's proxy, never from a package (see + # `_PROPERTY_KINDS`). A row with no receiver has nowhere to be served, and is + # still described by name through `_TABLE`. + if decl.receiver_type_name: + name = decl.leaf[:-1] if decl.kind == 'SETTER' and decl.leaf.endswith('=') else decl.leaf + prop = _PROPERTIES.setdefault(decl.receiver_type_name, {}).get(name) + if prop is None: + prop = _PROPERTIES[decl.receiver_type_name][name] = _Property() + if decl.kind == 'GETTER': + prop.getter = decl + else: + prop.setter = decl + continue _index(_BY_PACKAGE.setdefault(decl.package, {}), decl) if decl.is_extension and decl.receiver_type_name: _index(_BY_RECEIVER.setdefault(decl.receiver_type_name, {}), decl) @@ -339,6 +373,11 @@ object PythonxAdapter { _CHILDREN.setdefault('.'.join(segments[:count - 1]), set()).add(segments[count - 1]) for gone in [name for name in _TABLE if name not in present]: del _TABLE[gone] + # A setter whose getter is not in the table is not a property Python can read; the walker + # never emits one, and a hand-written table that does gets nothing rather than half. + for by_name in _PROPERTIES.values(): + for name in [name for name, prop in by_name.items() if prop.getter is None]: + del by_name[name] # Here rather than at the end of this file: `_PACKAGES_SEEN` is empty until this # function has run, and a module `PythonProxySource` created before it will never reach # the finder -- CPython answers from `sys.modules` first. Called on every table change @@ -976,11 +1015,10 @@ object PythonxAdapter { cached = _MEMBER_ALIASES.get(key) if cached is not None: return cached - members = _BY_RECEIVER.get(cls._kotlin_type_name, {}) - names = tuple(members) + names = _member_names(cls._kotlin_type_name) for resolver in tuple(_MEMBER_RESOLVERS): target, keywords = _resolver_answer(resolver(cls._kotlin_type_name, name, names)) - if target and target in members and not target.startswith('_'): + if target and target in names and not target.startswith('_'): _MEMBER_ALIASES[key] = (target, keywords) return _MEMBER_ALIASES[key] return None @@ -996,7 +1034,7 @@ object PythonxAdapter { cached = _MEMBER_KEYWORDS.get(key) if cached is not None: return cached - names = tuple(_BY_RECEIVER.get(type_name, {})) + names = _member_names(type_name) found = {} for resolver in tuple(_MEMBER_RESOLVERS): target, keywords = _resolver_answer(resolver(type_name, name, names)) @@ -1101,12 +1139,123 @@ object PythonxAdapter { return "" + def _served_types(type_name): + '''[type_name], then every type it is a, nearest first -- the order a member is looked up in. + + The ancestry is the one the table carries (`_SUPERTYPES`, read off the jar by the walker), + so a `MutableState` proxy finds what is declared on `State` and a `BitmapPainter` proxy + what extends `Painter`, and nothing here has to know a hierarchy it cannot see. + ''' + return (type_name,) + tuple(_SUPERTYPES.get(type_name, ())) + + + def _member(type_name, name): + '''What the Kotlin name [name] means on a value of [type_name]: a `_Property`, a list of + extension decls, or `None`. The nearest type that has the name wins, and on one type a + property wins over an extension function of the same name -- Kotlin resolves a member + before an extension, and a property is the member here.''' + for candidate in _served_types(type_name): + prop = _PROPERTIES.get(candidate, {}).get(name) + if prop is not None: + return prop + decls = _BY_RECEIVER.get(candidate, {}).get(name) + if decls: + return decls + return None + + + def _member_names(type_name): + '''Every Kotlin member name a value of [type_name] is served under, supertypes included.''' + names = [] + seen = set() + for candidate in _served_types(type_name): + for table in (_PROPERTIES, _BY_RECEIVER): + for name in table.get(candidate, {}): + if name not in seen: + seen.add(name) + names.append(name) + return tuple(names) + + + def _member_rows(type_name, name): + '''The table rows [name] stands for on a value of [type_name], or `None` -- reading nothing. + + What `python_multiplatform.describe_member` asks (issue #54). The same lookup `_attach` + serves by, so a description and the attribute it describes cannot disagree: a property + answers its getter row and, for a `var`, its setter row; an extension answers every + declaration its name stands for, an overload set's base name included. + ''' + found = _member(type_name, name) + if found is None: + return None + if isinstance(found, _Property): + return tuple(decl.row for decl in (found.getter, found.setter) if decl is not None) + return tuple(decl.row for decl in found) + + + def _receiver_handle(receiver, name): + handle = getattr(receiver, '_pm_handle', None) + if handle is None: + raise TypeError(name + ' is read on a Kotlin value, and ' + repr(receiver) + ' holds none') + return handle + + + def _read_property(decl, receiver): + '''One `GETTER` read: the receiver's handle in `args[0]`, nothing else (`CallableKind.GETTER`).''' + return _wrap( + _boundary()['invoke'](decl.bound_handle(), (_receiver_handle(receiver, decl.kotlin_name),)), + decl.return_type_name if decl.return_tag == 'OBJECT' else None, + ) + + + def _write_property(decl, receiver, value): + '''One `SETTER` write: the receiver's handle, then the value, coerced as any argument is.''' + name = decl.kotlin_name[:-1] if decl.kotlin_name.endswith('=') else decl.kotlin_name + tag = decl.param_tags[0] if decl.param_tags else 'OBJECT' + type_name = decl.param_type_names[0] if decl.param_type_names else None + try: + coerced = _coerce(value, tag, type_name, decl, 0, True) + except _Mismatch as mismatch: + raise TypeError(name + ' = ...: ' + str(mismatch)) from None + _boundary()['invoke'](decl.bound_handle(), (_receiver_handle(receiver, name), coerced)) + + + def _property_of(prop, name): + '''A Python `property` over one Kotlin property: read-only for a `val`, writable for a `var`. + + A data descriptor, so it answers on the instance before `__getattr__` is ever asked; every + read and write re-enters Kotlin, because a property's value is the Kotlin object's to + change (`MutableState.value` is the case that motivated this). + ''' + getter = prop.getter + + def fget(self): + return _read_property(getter, self) + + fset = None + if prop.setter is not None: + setter = prop.setter + + def fset(self, value): + _write_property(setter, self, value) + + doc = 'Kotlin property ' + getter.kotlin_name + (' (var)' if fset is not None else ' (val)') + return property(fget, fset, None, doc) + + def _attach(cls, name): - '''Installs one extension as a method on [cls], under its Kotlin name, and reports whether there was one.''' - decls = _BY_RECEIVER.get(cls._kotlin_type_name, {}).get(name) - if not decls: + '''Installs one Kotlin member on [cls] under its Kotlin name, and reports whether there was one. + + An extension function becomes a `_Hybrid` method; a property (issue #38) becomes a Python + `property`. Both are looked up on [cls]'s type and then on each type it is a (`_member`). + ''' + found = _member(cls._kotlin_type_name, name) + if found is None: return False - setattr(cls, name, _Hybrid(_callable_for(name, decls), name)) + if isinstance(found, _Property): + setattr(cls, name, _property_of(found, name)) + else: + setattr(cls, name, _Hybrid(_callable_for(name, found), name)) cls._kotlin_attached.append(name) return True @@ -1119,8 +1268,10 @@ object PythonxAdapter { release = _boundary()['release'] - def __init__(self, handle): - self._pm_handle = handle + def __init__(self, handle, _set=object.__setattr__): + # Past `__setattr__` below: `_pm_handle` is never a Kotlin name, and construction is + # the hot path the proxy's cost table prices. + _set(self, '_pm_handle', handle) def __del__(self, _release=release): # The other half of `HandleTable`'s contract, and the thing @@ -1143,6 +1294,20 @@ object PythonxAdapter { return _aliased(getattr(self, resolved[0]), resolved[1]) return getattr(self, name) + def __setattr__(self, name, value, _set=object.__setattr__): + # A Kotlin property is installed on the class lazily, by `_attach`, the first time it is + # *read* (`__getattr__`). A write that comes first would otherwise find no descriptor + # and land in the instance dict -- Kotlin never told, and every later read answered + # from that dict instead of Kotlin (issue #38: `state.value = 7` was "accepted"). + # So a write attaches first; `object.__setattr__` then goes through the `property` + # (its setter, or Python's own refusal for a `val`). A name no Kotlin member has is + # stored on the instance as before. + if not name.startswith('_'): + cls = type(self) + if name not in cls.__dict__: + _attach(cls, name) + _set(self, name, value) + def __repr__(self): return '<' + _simple_name(kotlin_type_name) + ' handle=' + repr(self._pm_handle) + '>' @@ -1161,6 +1326,7 @@ object PythonxAdapter { '__init__': __init__, '__del__': __del__, '__getattr__': __getattr__, + '__setattr__': __setattr__, '__repr__': __repr__, 'empty': empty, '_kotlin_type_name': kotlin_type_name, @@ -1181,8 +1347,40 @@ object PythonxAdapter { # --------------------------------------------------------------------------- calling + # What an unbounded Kotlin type parameter is read as (issue #38): `mutableStateOf`'s `value`, + # `MutableState.value`. The one OBJECT slot a Python object can fill as itself. + _KOTLIN_ANY = 'kotlin.Any' + + # The tags whose Kotlin side is a reference, so `None` can reach it as `null`. A number or a + # Boolean cannot: `TypeTag.INT` narrows a `Long`, and the walker declines a nullable one. + _NULLABLE_TAGS = ('STRING', 'BYTES', 'OBJECT') + + def _coerce(value, tag, type_name, decl, slot, strict): '''One argument on its way in. Returns the marshalled value, or raises when `strict`.''' + if value is None: + # `None` written for a slot that has no default to fall back on: Kotlin's `null`, which + # a nullable declaration takes (`Icon(..., contentDescription=None)`) and a non-null + # one refuses itself. A defaulted slot never gets here -- `_bind` reads `None` there as + # "leave it to Kotlin's default". + if tag in _NULLABLE_TAGS: + return None + return _refuse(strict, 'None is not a ' + _simple_name(type_name or tag)) + if tag == 'OBJECT' and type_name == _KOTLIN_ANY and not isinstance(value, _ValueProxy): + # `Any?` holds whatever it is given. A proxy is a Kotlin object and goes as its handle; + # any other Python object goes as itself, and Kotlin keeps it as a `PyObject` + # (`UpcallTrampoline.toKotlinObject`) -- which is how a Python root function lands in a + # Compose `MutableState`. An `int` cannot: the boundary reads every int in an OBJECT + # slot as a handle, so `mutableStateOf(0)` would name some other Kotlin object. + handle = getattr(value, '_pm_handle', None) + if handle is not None: + return handle + if isinstance(value, int): + return _refuse( + strict, + 'an int cannot be stored in a Kotlin Any?: it would cross as an object handle', + ) + return value if isinstance(value, _ValueProxy): if type_name is not None and value.kotlin_type_name != type_name: return _refuse( @@ -1429,7 +1627,9 @@ object PythonxAdapter { omitted = 0 for index in range(declared): value = slots[index] - if value is _NO_MATCH or value is None or value is KOTLIN_DEFAULT: + # `None` means "Kotlin's default" only where there is one; written for a slot that has + # none it is a value -- `null` -- and `_coerce` decides whether the slot takes it. + if value is _NO_MATCH or value is KOTLIN_DEFAULT or (value is None and decl.omittable(index)): if not decl.omittable(index): missing = decl.param_names[index] if decl.param_names else 'argument ' + str(index) return _refuse(strict, 'no value for ' + missing) @@ -1495,17 +1695,14 @@ object PythonxAdapter { # `docs/archive/pythonx-adapter-design.md` §4.5 -- metadata carries the flag and never the # expression, so this is the only place the default value can come from. # - # It costs nothing that was previously possible, and the reason is `_coerce` rather - # than the walker: `_coerce` refuses `None` for every tag it knows -- 'expected a - # str' for STRING, 'expected a bool' for BOOLEAN, a handle or a callable for OBJECT - # -- so no `None` a caller wrote in a slot has ever reached Kotlin as a *value*. - # `resolveKotlinType` narrows it further by declining a nullable number, a nullable - # Boolean and a nullable value class outright; a nullable `String`/`ByteArray` is - # bound (`nullablePrimitiveBoundaryTypeOf`, which is what makes `Modifier.clickable` - # reachable) and is the one case where "omitted" is the only way to spell `null` - # from `pythonx`. That is exact for a slot whose Kotlin default *is* `null`, which - # every such slot measured so far has, and it is a refusal rather than a wrong - # value for one that is not. + # Only for a slot that **has** a default. Written for a slot without one, `None` + # is a value -- Kotlin's `null` -- and `_coerce` passes it for a reference tag + # (STRING, BYTES, OBJECT: `Icon(..., contentDescription=None)`) and refuses it for + # a number or a Boolean. `resolveKotlinType` declines a nullable number, Boolean + # and value class outright, so a defaulted nullable `String`/`ByteArray`/object is + # the one case where "omitted" and `null` share a spelling here; that is exact for + # a slot whose Kotlin default *is* `null`, which every such slot measured so far + # has, and it is a refusal rather than a wrong value for one that is not. slots[index] = None defaults_used += 1 continue diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt index 23605a453..812ebacad 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt @@ -25,6 +25,7 @@ import python.multiplatform.reflection.ExposedCallable * | `KOTLIN_DEFAULT` | the `inspect.Parameter.default` of a parameter whose Kotlin declaration has a default. Passing it (or leaving the parameter out) means "Kotlin's own default" | * | `describe(fn)` | the declaration(s) behind a binder-made callable: a tuple of dicts, one per overload (see [SOURCE]) | * | `describe(module, name)` | the declaration(s) a bound name of a Kotlin-named module stands for, read from the table and never by reading the name | + * | `describe_member(type, name)` | the declaration(s) a member name of a Kotlin type stands for on its proxy -- an extension function or a property, supertypes included -- read from the table | * | `kotlin_function(raw, row)` | what [PythonProxySource] wraps each rendered function in: keyword arguments by Kotlin name, omitted defaults, and the metadata above | * | `signature_of(rows)` | the `inspect.Signature` for one declaration (`(*args, **kwargs)` for an overload set) | * @@ -65,6 +66,26 @@ import python.multiplatform.reflection.ExposedCallable * argument is unchanged. So `describe(androidx.compose.ui.Alignment, 'End')['returns']` is * `'androidx.compose.ui.Alignment.Horizontal'`. * + * ### Members of a type: `describe_member(kotlin_type_name, kotlin_member_name)` + * + * What a member resolver needs to build a keyword map, and what `describe(module, name)` cannot + * give it: an extension is declared in *its* package, which is not the receiver's, so the receiver's + * type is the only key a resolver holds (issue #54). The answer has `describe()`'s shape -- a tuple of + * dicts, one per declaration -- and is exactly what the proxy of that type serves under that name: + * + * - an **extension function** answers every declaration the name stands for on that type -- for an + * overload set's base name every candidate, for an explicit `name__Types` spelling that one; + * `describe_member('androidx.compose.ui.Modifier', 'padding')` lists the `padding` overloads with + * their Kotlin parameter names; + * - a **property** (issue #38) answers its getter (`kind: 'GETTER'`, no parameters, `returns` its + * type) and, for a `var`, its setter (`kind: 'SETTER'`, one parameter, the value written). A member + * property and an extension property look the same here; `receiver` is the type it is read on; + * - a name the type does not have is looked up on each type the table says it **is a**, nearest + * first -- the same lookup the proxy uses, so a description cannot disagree with the attribute; + * - nothing is invoked: no getter runs, no handle is resolved; + * - a name served on neither the type nor any supertype, or any name while the binding layer is not + * installed (it is what serves members on a proxy), raises `AttributeError`. + * * ### Modules list and serve their children * * A Kotlin-named module's `dir()` lists its bound declarations, the receiver types under it, and @@ -183,6 +204,7 @@ object KotlinSurface { _NAME, _ARITY, _KIND, _SUSPEND, _NAMES, _TAGS, _TYPES, _RTAG, _RTYPE, _EXT, _RECV, _DEFAULTS = range(12) _RECEIVER = '' + _PROPERTY_KINDS = ('GETTER', 'SETTER') _COMPOSER = '${'$'}composer' _FUNCTION_PREFIX = 'kotlin.Function' _COMPOSABLE_MARK = '@Composable' @@ -243,7 +265,9 @@ object KotlinSurface { 'name': row[_NAME], 'kind': row[_KIND], 'suspend': bool(row[_SUSPEND]), - 'receiver': row[_RECV] if row[_EXT] else None, + # A property's receiver is not one of its slots (`CallableKind.GETTER`), but it is + # still the type the property is read on -- the one fact a reader of a property needs. + 'receiver': row[_RECV] if row[_EXT] or row[_KIND] in _PROPERTY_KINDS else None, 'returns': _declared_return(row[_RTYPE]), 'composable': _COMPOSER in names, 'content': last['name'] if last is not None and last['composable_lambda'] else None, @@ -301,6 +325,26 @@ object KotlinSurface { return described[0] if len(described) == 1 else described + def describe_member(kotlin_type_name, kotlin_member_name): + '''The Kotlin declaration(s) the member [kotlin_member_name] of [kotlin_type_name] stands for. + + A tuple of dicts, `describe()`'s shape: every overload of an extension function, or a + property's getter (and setter, for a `var`). Looked up on the type and then on every type + the table says it is a, nearest first -- exactly what a proxy of that type serves under + that name. Invokes nothing. `AttributeError` for a name the type is not served under. + ''' + if not isinstance(kotlin_type_name, str) or not isinstance(kotlin_member_name, str): + raise TypeError('describe_member(kotlin_type_name, kotlin_member_name) takes two str') + binding = _sys.modules.get(BINDING_MODULE) + rows = binding._member_rows(kotlin_type_name, kotlin_member_name) if binding is not None else None + if not rows: + raise AttributeError( + "Kotlin type '" + kotlin_type_name + "' has no member named '" + kotlin_member_name + "'" + + ('' if binding is not None else ' (the binding layer, which serves members, is not installed)') + ) + return tuple(_describe_row(row) for row in rows) + + _GENERIC = _inspect.Signature([ _inspect.Parameter('args', _inspect.Parameter.VAR_POSITIONAL), _inspect.Parameter('kwargs', _inspect.Parameter.VAR_KEYWORD), diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PropertyShapedFragment.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PropertyShapedFragment.kt new file mode 100644 index 000000000..6ad14e3a3 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PropertyShapedFragment.kt @@ -0,0 +1,199 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.PyObject +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.FunctionTableFragment +import python.multiplatform.reflection.TypeTag + +/** + * A table shaped the way the artefact walker shapes **properties** (issue #38), backed by Kotlin this + * repository owns -- the [ComposeShapedFragment] argument for why a stand-in rather than the jars. + * + * | entry | shape it restates | + * |---|---| + * | `mutableStateOf` | a generic function with `T` read as `kotlin.Any?`: `value` is an `OBJECT` slot declared `kotlin.Any`, `policy` a defaulted one; the return carries its ancestry (`MutableState<:State`) | + * | `MutableState.value` / `MutableState.value=` | a member `var`: a `GETTER` (receiver in `args[0]`, arity 0) and a `SETTER` (receiver, then the value) | + * | `State.value` | the same property declared on the supertype, as the real `State` declares it | + * | `propshape.Base.label` | a member `val` declared **only** on a supertype, so a subtype proxy reaches it by ancestry alone | + * | `propshape.boost` | an extension *function* on that supertype, for the same lookup | + * | `Icons.Default` + `icons.filled.Add` | an object's constant, and a top-level **extension property** on the type that constant is (`AddKt.getAdd(Icons$Filled)`) | + * | `describeState` / `describeVector` / `describeBase` | read back what Kotlin actually holds, since a proxy alone cannot say | + * + * [calls] records every Kotlin-side invocation, which is how a test tells "described without reading" + * apart from "read". + */ +object PropertyShapedFragment : FunctionTableFragment { + + override val moduleName: String = "test_property_shaped" + + val calls: MutableList = mutableListOf() + + const val STATE = "androidx.compose.runtime.State" + const val MUTABLE_STATE = "androidx.compose.runtime.MutableState" + private const val POLICY = "androidx.compose.runtime.SnapshotMutationPolicy" + private const val ANY = "kotlin.Any" + const val BASE = "propshape.Base" + const val DERIVED = "propshape.Derived" + const val FILLED = "androidx.compose.material.icons.Icons.Filled" + private const val IMAGE_VECTOR = "androidx.compose.ui.graphics.vector.ImageVector" + + override fun entries(): List = listOf( + ExposedCallable( + name = "androidx.compose.runtime.mutableStateOf", + arity = 2, + paramTypes = listOf(TypeTag.OBJECT, TypeTag.OBJECT), + returnType = TypeTag.OBJECT, + paramNames = listOf("value", "policy"), + paramTypeNames = listOf(ANY, POLICY), + returnTypeName = "$MUTABLE_STATE<:$STATE", + paramHasDefault = listOf(false, true), + ) { args -> + calls += "mutableStateOf" + StubMutableState(args[0]) + }, + ExposedCallable( + name = "$STATE.value", + arity = 0, + paramTypes = emptyList(), + returnType = TypeTag.OBJECT, + kind = CallableKind.GETTER, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = ANY, + receiverTypeName = STATE, + ) { args -> + calls += "State.value" + (args[0] as StubMutableState).value + }, + ExposedCallable( + name = "$MUTABLE_STATE.value", + arity = 0, + paramTypes = emptyList(), + returnType = TypeTag.OBJECT, + kind = CallableKind.GETTER, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = ANY, + receiverTypeName = MUTABLE_STATE, + ) { args -> + calls += "MutableState.value" + (args[0] as StubMutableState).value + }, + ExposedCallable( + name = "$MUTABLE_STATE.value=", + arity = 1, + paramTypes = listOf(TypeTag.OBJECT), + returnType = TypeTag.UNIT, + kind = CallableKind.SETTER, + paramNames = listOf("value"), + paramTypeNames = listOf(ANY), + returnTypeName = "kotlin.Unit", + receiverTypeName = MUTABLE_STATE, + paramHasDefault = listOf(false), + ) { args -> + calls += "MutableState.value=" + (args[0] as StubMutableState).value = args[1] + Unit + }, + ExposedCallable( + name = "androidx.compose.runtime.describeState", + arity = 1, + paramTypes = listOf(TypeTag.OBJECT), + returnType = TypeTag.STRING, + paramNames = listOf("state"), + paramTypeNames = listOf(STATE), + returnTypeName = "kotlin.String", + ) { args -> + when (val held = (args[0] as StubMutableState).value) { + null -> "null" + is PyObject -> "python" + is StubBase -> "kotlin:" + held.label + else -> "kotlin:other" + } + }, + ExposedCallable( + name = "propshape.makeDerived", + arity = 1, + paramTypes = listOf(TypeTag.STRING), + returnType = TypeTag.OBJECT, + paramNames = listOf("label"), + paramTypeNames = listOf("kotlin.String"), + returnTypeName = "$DERIVED<:$BASE", + ) { args -> StubBase(args[0] as String) }, + ExposedCallable( + name = "$BASE.label", + arity = 0, + paramTypes = emptyList(), + returnType = TypeTag.STRING, + kind = CallableKind.GETTER, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = "kotlin.String", + receiverTypeName = BASE, + ) { args -> + calls += "Base.label" + (args[0] as StubBase).label + }, + ExposedCallable( + name = "propshape.boost", + arity = 2, + paramTypes = listOf(TypeTag.OBJECT, TypeTag.INT), + returnType = TypeTag.STRING, + paramNames = listOf("", "times"), + paramTypeNames = listOf(BASE, "kotlin.Int"), + returnTypeName = "kotlin.String", + isExtension = true, + receiverTypeName = BASE, + paramHasDefault = listOf(false, false), + ) { args -> + calls += "boost" + (args[0] as StubBase).label.repeat((args[1] as Long).toInt()) + }, + ExposedCallable( + name = "androidx.compose.material.icons.Icons.Default", + arity = 0, + paramTypes = emptyList(), + returnType = TypeTag.OBJECT, + kind = CallableKind.STATIC_GETTER, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = FILLED, + ) { StubIconSet("Filled") }, + ExposedCallable( + name = "androidx.compose.material.icons.filled.Add", + arity = 0, + paramTypes = emptyList(), + returnType = TypeTag.OBJECT, + kind = CallableKind.GETTER, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = IMAGE_VECTOR, + receiverTypeName = FILLED, + ) { args -> + calls += "Add" + StubImageVector((args[0] as StubIconSet).style + ".Add") + }, + ExposedCallable( + name = "androidx.compose.ui.graphics.vector.describeVector", + arity = 1, + paramTypes = listOf(TypeTag.OBJECT), + returnType = TypeTag.STRING, + paramNames = listOf("vector"), + paramTypeNames = listOf(IMAGE_VECTOR), + returnTypeName = "kotlin.String", + ) { args -> (args[0] as StubImageVector).name }, + ) +} + +/** What `mutableStateOf` hands back here: one slot Kotlin and Python both read and write. */ +class StubMutableState(var value: Any?) + +/** `propshape.Derived`, whose only property is declared on `propshape.Base`. */ +class StubBase(val label: String) + +/** `Icons.Filled`: the object an icon extension property is read on. */ +class StubIconSet(val style: String) + +/** What an icon property returns. */ +class StubImageVector(val name: String) diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxPropertyTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxPropertyTest.kt new file mode 100644 index 000000000..9b5131983 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxPropertyTest.kt @@ -0,0 +1,311 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.ffi.upcall.publishesProxyEntryPoints +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.bindUpcallOrNull +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertTrue + +/** + * Issue #38's binding-layer half, and issue #54: a Kotlin **property** is an attribute of the proxy of + * the type it is read on, and `python_multiplatform.describe_member` describes what a proxy serves + * under a name without serving it. + * + * The table is [PropertyShapedFragment] (beside [ComposeShapedFragment], for `Modifier.padding`). + * + * ### Red before the implementation, and what each red says + * + * Every test here is red against a binding layer that has no `_PROPERTIES`: `state.value` is an + * `AttributeError` ("no Kotlin extension named value"), `Icons.Default.Add` likewise, and + * `describe_member` does not exist (`AttributeError: module 'python_multiplatform' has no attribute + * 'describe_member'`). A *regression* reads differently: a value that comes back wrong, or a Kotlin + * call where the test counts none. + */ +class PythonxPropertyTest { + + @BeforeTest + fun install() { + UpcallTable.install(listOf(ComposeShapedFragment, PropertyShapedFragment)) + ComposeShapedFragment.calls.clear() + PropertyShapedFragment.calls.clear() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + /** + * The completion statement of #38 in the binding layer's terms: Python creates a state, reads its + * `value`, writes a Python function into it, and Kotlin holds that very function. + */ + @Test + fun aMemberVarIsReadAndWrittenAsAnAttribute() = withAdapter { + Python3.exec( + """ + from androidx.compose.runtime import mutableStateOf, describeState + _pp_state = mutableStateOf(None) + def _pp_root(): + return 'root' + _pp = { + 'type': type(_pp_state)._kotlin_type_name, + 'initial': _pp_state.value, + 'held_initially': describeState(_pp_state), + } + _pp_state.value = _pp_root + _pp['read_back_is_same'] = _pp_state.value is _pp_root + _pp['held'] = describeState(_pp_state) + _pp_state.value = None + _pp['cleared'] = _pp_state.value + """.trimIndent(), + ) + assertEquals("androidx.compose.runtime.MutableState", eval("_pp['type']")) + assertEquals("None", eval("repr(_pp['initial'])")) + assertEquals("null", eval("_pp['held_initially']")) + assertEquals("True", eval("repr(_pp['read_back_is_same'])"), "the Python object did not round-trip through Kotlin") + assertEquals("python", eval("_pp['held']"), "Kotlin does not hold the Python function itself") + assertEquals("None", eval("repr(_pp['cleared'])")) + // The nearest declaration serves: `MutableState.value`, not the supertype's `State.value`. + assertEquals( + listOf("mutableStateOf", "MutableState.value", "MutableState.value=", "MutableState.value", "MutableState.value=", "MutableState.value"), + PropertyShapedFragment.calls, + ) + } + + /** A Kotlin object written into an `Any?` goes as its handle, and Kotlin holds the object. */ + @Test + fun aKotlinValueWrittenIntoAnAnySlotIsTheKotlinObject() = withAdapter { + Python3.exec( + """ + from androidx.compose.runtime import mutableStateOf, describeState + from propshape import makeDerived + _pp_state = mutableStateOf(makeDerived('d')) + _pp = {'held': describeState(_pp_state)} + """.trimIndent(), + ) + assertEquals("kotlin:d", eval("_pp['held']")) + } + + /** + * An `int` would cross an `OBJECT` slot as a handle and name some other Kotlin object; refused + * with a reason rather than stored. + */ + @Test + fun anIntIsRefusedForAnAnySlot() = withAdapter { + Python3.exec( + """ + from androidx.compose.runtime import mutableStateOf + _pp_state = mutableStateOf(None) + _pp = {} + for _pp_label, _pp_call in (('call', lambda: mutableStateOf(7)), ('write', lambda: setattr(_pp_state, 'value', 7))): + try: + _pp_call() + _pp[_pp_label] = 'accepted' + except TypeError as _pp_e: + _pp[_pp_label] = str(_pp_e) + """.trimIndent(), + ) + assertTrue("int" in eval("_pp['call']"), eval("_pp['call']")) + assertTrue("int" in eval("_pp['write']"), eval("_pp['write']")) + } + + /** A `val` has no setter: writing it is Python's own refusal, and nothing reaches Kotlin. */ + @Test + fun aValIsReadOnly() = withAdapter { + Python3.exec( + """ + from propshape import makeDerived + _pp_d = makeDerived('d') + _pp = {'label': _pp_d.label} + try: + _pp_d.label = 'x' + _pp['write'] = 'accepted' + except AttributeError: + _pp['write'] = 'AttributeError' + """.trimIndent(), + ) + assertEquals("d", eval("_pp['label']")) + assertEquals("AttributeError", eval("_pp['write']")) + assertEquals(listOf("Base.label"), PropertyShapedFragment.calls) + } + + /** + * Declared on the supertype only: `Derived` has neither `label` nor `boost` of its own, and the + * table's ancestry for it (`Derived<:Base`, carried on `makeDerived`'s return) is what serves both. + */ + @Test + fun aSupertypesPropertyAndExtensionAreServedOnTheSubtype() = withAdapter { + Python3.exec( + """ + from propshape import makeDerived + _pp_d = makeDerived('ab') + _pp = {'type': type(_pp_d)._kotlin_type_name, 'label': _pp_d.label, 'boost': _pp_d.boost(2)} + """.trimIndent(), + ) + assertEquals(PropertyShapedFragment.DERIVED, eval("_pp['type']")) + assertEquals("ab", eval("_pp['label']")) + assertEquals("abab", eval("_pp['boost']")) + } + + /** + * `Icons.Default.Add`: an object's constant, then an **extension property** read off it -- the + * material-icons shape (`AddKt.getAdd(Icons$Filled)`). + */ + @Test + fun anExtensionPropertyIsAValueOfItsReceiver() = withAdapter { + Python3.exec( + """ + import androidx + from androidx.compose.ui.graphics.vector import describeVector + _pp_add = androidx.compose.material.icons.Icons.Default.Add + _pp = {'type': type(_pp_add)._kotlin_type_name, 'name': describeVector(_pp_add)} + """.trimIndent(), + ) + assertEquals("androidx.compose.ui.graphics.vector.ImageVector", eval("_pp['type']")) + assertEquals("Filled.Add", eval("_pp['name']")) + } + + /** + * A property is served on a value, never from a package: `MutableState` is not a module because + * `MutableState.value` is bound, and `icons.filled` is not one because `Add` is. + */ + @Test + fun aPropertyMakesNoPackage() = withAdapter { + Python3.exec( + """ + import importlib + _pp = {} + for _pp_name in ('androidx.compose.runtime.MutableState', 'androidx.compose.material.icons.filled'): + try: + importlib.import_module(_pp_name) + _pp[_pp_name] = 'imported' + except ImportError: + _pp[_pp_name] = 'ImportError' + """.trimIndent(), + ) + assertEquals("ImportError", eval("_pp['androidx.compose.runtime.MutableState']")) + assertEquals("ImportError", eval("_pp['androidx.compose.material.icons.filled']")) + } + + // ------------------------------------------------------------------- describe_member (#54) + + /** Issue #54's completion statement: the overloads of an extension, with their Kotlin parameter names. */ + @Test + fun describeMemberListsAnExtensionsOverloadsByTheReceiverType() = withAdapter { + Python3.exec( + """ + import python_multiplatform as _pp_pm + _pp_all = _pp_pm.describe_member('androidx.compose.ui.Modifier', 'padding') + _pp_one = _pp_pm.describe_member('androidx.compose.ui.Modifier', 'padding__Dp_Dp') + _pp = { + 'all': sorted((_d['name'], tuple(_p['name'] for _p in _d['parameters'])) for _d in _pp_all), + 'one': [(_d['name'], _d['receiver']) for _d in _pp_one], + } + """.trimIndent(), + ) + assertEquals( + "[('androidx.compose.foundation.layout.padding__Dp', ('all',)), " + + "('androidx.compose.foundation.layout.padding__Dp_Dp', ('horizontal', 'vertical')), " + + "('androidx.compose.foundation.layout.padding__Dp_Dp_Dp_Dp', ('start', 'top', 'end', 'bottom')), " + + "('androidx.compose.foundation.layout.padding__PaddingValues', ('paddingValues',))]", + eval("repr(_pp['all'])"), + ) + assertEquals( + "[('androidx.compose.foundation.layout.padding__Dp_Dp', 'androidx.compose.ui.Modifier')]", + eval("repr(_pp['one'])"), + ) + assertEquals(emptyList(), ComposeShapedFragment.calls, "describing invoked Kotlin") + } + + /** A property answers its getter and, for a `var`, its setter -- and reads nothing. */ + @Test + fun describeMemberDescribesAPropertyWithoutReadingIt() = withAdapter { + Python3.exec( + """ + import python_multiplatform as _pp_pm + def _pp_shape(rows): + return [ + (_d['name'], _d['kind'], _d['receiver'], _d['returns'], + tuple((_p['name'], _p['type']) for _p in _d['parameters'])) + for _d in rows + ] + _pp = { + 'var': _pp_shape(_pp_pm.describe_member('androidx.compose.runtime.MutableState', 'value')), + 'inherited': _pp_shape(_pp_pm.describe_member('propshape.Derived', 'label')), + 'extension_property': _pp_shape(_pp_pm.describe_member('androidx.compose.material.icons.Icons.Filled', 'Add')), + } + """.trimIndent(), + ) + assertEquals( + "[('androidx.compose.runtime.MutableState.value', 'GETTER', 'androidx.compose.runtime.MutableState', 'kotlin.Any', ()), " + + "('androidx.compose.runtime.MutableState.value=', 'SETTER', 'androidx.compose.runtime.MutableState', 'kotlin.Unit', " + + "(('value', 'kotlin.Any'),))]", + eval("repr(_pp['var'])"), + ) + assertEquals( + "[('propshape.Base.label', 'GETTER', 'propshape.Base', 'kotlin.String', ())]", + eval("repr(_pp['inherited'])"), + ) + assertEquals( + "[('androidx.compose.material.icons.filled.Add', 'GETTER', 'androidx.compose.material.icons.Icons.Filled', " + + "'androidx.compose.ui.graphics.vector.ImageVector', ())]", + eval("repr(_pp['extension_property'])"), + ) + assertEquals(emptyList(), PropertyShapedFragment.calls, "describing a property read it") + } + + /** A name the type is not served under -- on itself or any supertype -- is an `AttributeError`. */ + @Test + fun describeMemberRefusesANameTheTypeDoesNotHave() = withAdapter { + Python3.exec( + """ + import python_multiplatform as _pp_pm + _pp = {} + for _pp_type, _pp_name in ( + ('androidx.compose.ui.Modifier', 'nonesuch'), + ('androidx.compose.runtime.State', 'setValue'), + ('no.such.Type', 'padding'), + ): + try: + _pp_pm.describe_member(_pp_type, _pp_name) + _pp[_pp_name] = 'described' + except AttributeError: + _pp[_pp_name] = 'AttributeError' + """.trimIndent(), + ) + assertEquals("AttributeError", eval("_pp['nonesuch']")) + assertEquals("AttributeError", eval("_pp['setValue']")) + assertEquals("AttributeError", eval("_pp['padding']")) + } + + // ------------------------------------------------------------------------------- helpers + + private inline fun withAdapter(block: () -> Unit) = PythonTestFixture.withInterpreter { + assertTrue(bindUpcallOrNull(ComposeShapedFragment.EMPTY_MODIFIER), "the fixture table is not installed") + if (!publishesProxyEntryPoints) { + val refusal = assertFails { PythonxAdapter.install(COMPOSE_SHAPED_RAW_VALUE_CLASSES) } + assertTrue( + refusal.message?.contains("raw upcall entry points are not bound") == true, + "a target with no proxy bootstrap must fail the adapter's own guard: $refusal", + ) + return@withInterpreter + } + PythonxAdapter.install(COMPOSE_SHAPED_RAW_VALUE_CLASSES) + Python3.exec( + "import python_multiplatform.binding as _pm_binding\n" + + "_pm_binding.register_empty('androidx.compose.ui.Modifier', " + + "'${ComposeShapedFragment.EMPTY_MODIFIER}')", + ) + block() + } + + private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() +}