Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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<kotlin.Any?>(...)`), 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
7 changes: 7 additions & 0 deletions ksp-fixtures/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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(),
)
}
Expand Down
Loading
Loading