diff --git a/docs/SPEC.md b/docs/SPEC.md index c4315b0d..c6716249 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -267,6 +267,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `anExtensionIsAMethodOnItsReceiverAndTheChainComposes`) and against the real Compose jars through the artifact walker (`python-multiplatform-gradle-plugin/fixtures/artifact/.../WalkedArtifactComposeModifierTest.kt`); not asserted for KSP-generated proxies. + A proxy **class** written where an object value goes (`describeModifier(Modifier)`, + `Card(modifier=Modifier)`) is that type's registered empty value: for a type whose class is the + parameter's type or a subtype of it, passing the class == passing `Cls.empty()`; it equals omitting the + argument only where Kotlin's own default is that empty value. Without a registered factory the + `TypeError` of `empty()` (naming `register_empty`) propagates; a class of an unrelated type is refused; + an overload vote counts only the type match. `PM/commonTest/.../pythonx/PythonxAdapterTest.kt` + `aProxyClassPassedAsAnObjectArgumentIsItsEmptyValue`. - **U-8** A function on a Kotlin-named module carries Kotlin's surface: the Kotlin declaration name, keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted parameters, overload sets under the base name, and its signature as public metadata (`inspect.signature`, diff --git a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ProxyClassArgumentRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ProxyClassArgumentRenderTest.kt new file mode 100644 index 00000000..051d0a55 --- /dev/null +++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ProxyClassArgumentRenderTest.kt @@ -0,0 +1,73 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Issue #308: `Card(modifier=Modifier)` -- a proxy **class** written where an object value goes -- + * is the class's registered empty value, the same call as `Card(modifier=Modifier.empty())`. + * + * This test registers `Modifier`'s empty factory itself (the library names no Compose type), then + * renders `Card` three ways and requires the class spelling to draw exactly what the instance + * spelling draws, and something (the content is real, not an empty frame). + */ +class ProxyClassArgumentRenderTest { + + @BeforeTest + fun install() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aModifierClassPassedToCardIsTheEmptyModifier() { + val setup = """ + import python_multiplatform.binding as _pm_binding + from fixture.compose import emptyModifier + from androidx.compose.material3 import Card, Text + _pm_binding.register_empty('androidx.compose.ui.Modifier', 'fixture.compose.emptyModifier') + Modifier = type(emptyModifier()) + """.trimIndent() + val viaClass = inkOf(setup + "\nCard(modifier=Modifier, content=lambda scope: Text('hi'))") + val viaInstance = inkOf(setup + "\nCard(modifier=Modifier.empty(), content=lambda scope: Text('hi'))") + println("compose render: Card(modifier=Modifier) -> $viaClass px, Card(modifier=Modifier.empty()) -> $viaInstance px") + assertTrue(viaClass > 0, "Card(modifier=Modifier) drew nothing") + assertEquals(viaInstance, viaClass, "the class spelling must draw what the instance spelling draws") + } + + private fun inkOf(body: String): Int { + val scene = ImageComposeScene(width = 200, height = 80, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + var ink = 0 + for (y in 0 until bitmap.height) for (x in 0 until bitmap.width) if (bitmap.getColor(x, y) != 0) ink++ + return ink + } finally { + scene.close() + } + } +} diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/README.md b/python-multiplatform-compose/fixtures/notebook-e2e/README.md index 0a7ab678..e6e8a700 100644 --- a/python-multiplatform-compose/fixtures/notebook-e2e/README.md +++ b/python-multiplatform-compose/fixtures/notebook-e2e/README.md @@ -123,7 +123,7 @@ if (composing.isNotEmpty()) setComposingText(composing, 1) }`. Not exercised: th |---|---| | cells 2–3 (`print`, numpy) | not UI | | cell 5: `remember_saveable`, `DefaultCoroutineScope`, `MainCoroutineScope` | undecided in pythonx-compose (INTENT §4.1, SPEC §8); nothing provides them | -| cell 5/25/35/…: lower-case `modifier`, `modifier=modifier` | INTENT §5.4 replaces it with `modifier=Modifier`. The binder has no conversion for a proxy **class** passed as an argument (`Modifier.empty()` is used only for the class-spelling *method call*, `PythonxAdapter.kt` `_BoundMember`), so `Card(modifier=Modifier)` is not expected to work; the argument is left out, which is the same Kotlin default (`Modifier`). Not run to confirm. | +| cell 5/25/35/…: lower-case `modifier`, `modifier=modifier` | INTENT §5.4 replaces it with `modifier=Modifier`. A proxy **class** passed as an argument is that type's registered empty value (SPEC U-7, #308), so `Card(modifier=Modifier)` works once the host registers `Modifier`'s empty factory with `register_empty`; it is the same value as `Modifier.empty()`, and equals leaving the argument out because Kotlin's default for `modifier` is `Modifier`. | | cell 22/23/30/35: `corner_radius=` on `Button`; cell 25/40: on `Card` | Kotlin's counterpart is `shape=RoundedCornerShape(...)` from `androidx.compose.foundation.shape`, which this fixture does not walk (its package list is `:python-multiplatform-compose:fixtures:compose`'s). Adding `"androidx.compose.foundation.shape"` to `artifactIncludePackages` is the next step; not tried. | | cell 22/23/25/…: `color=` on `Button`/`Card` | written as Kotlin's `colors=ButtonColors(...)` / `CardColors(...)` constructors. `ButtonDefaults.buttonColors(...)`, the usual Kotlin spelling, is a `@Composable` object function, which the walker declines (SPEC B-1). | | cell 27: `help(DefaultIcons)` | prints documentation; nothing to assert on screen | 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 f512dd1c..baf6e08e 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 @@ -1574,6 +1574,15 @@ object PythonxAdapter { pass + class _EmptyArgument(int): + '''The handle of a proxy class's empty value, holding the owner that keeps it alive (#308).''' + + def __new__(cls, owner): + self = int.__new__(cls, owner._pm_handle) + self._owner = owner + return self + + def _box_scalar(value, strict): '''A Python scalar for a `kotlin.Any` slot, as the handle of the Kotlin box it means (#69). @@ -1680,6 +1689,25 @@ object PythonxAdapter { # `FunctionN` back out for Python to pass in again, so a handle is not the answer # here and saying so would send the caller looking for one. return _refuse(strict, 'expected a callable of ' + _simple_name(type_name)) + if isinstance(value, _ProxyMeta): + # A proxy CLASS written where a value goes (`Card(modifier=Modifier)`): the class + # stands for its registered empty value, exactly `Cls.empty()` (issue #308). A type + # that is not the slot's, or a subtype of it, is refused like any other mismatch. + # No factory registered: `empty()` raises its TypeError naming `register_empty`. + kind = value._kotlin_type_name + if type_name is not None and not _is_a(kind, type_name): + return _refuse( + strict, + 'expected ' + _simple_name(type_name) + ' but got the class ' + _simple_name(kind), + ) + if not strict: + # A trial bind only asks whether the type fits; it must not call Kotlin. + return _SCALAR_PENDING + # Reference convention: `empty()` returns an OWNED wrapper that releases its handle + # when collected. The handle goes to the boundary as a bare int, so the wrapper is + # pinned on an int subclass that lives in the argument tuple, i.e. for the call + # that tuple was built for (as `_BoxedArgument` does), and released after it. + return _EmptyArgument(value.empty()) handle = getattr(value, '_pm_handle', None) if handle is not None: declared = _declared_type_name(value) diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt index f17a7b0f..87df7bdd 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt @@ -489,6 +489,46 @@ class PythonxAdapterTest { ) } + /** + * Issue #308: a proxy CLASS written where an OBJECT value goes stands for its registered empty + * value, exactly `Cls.empty()` (SPEC U-7). (a) the class reaches Kotlin as the empty value, in a + * plain call and as the receiver in an overload vote; (b) a class with no factory registered + * propagates `empty()`'s TypeError, which names `register_empty`; (c) a class of an unrelated type + * is refused; (d) the vote still picks the overload by the other arguments. + */ + @Test + fun aProxyClassPassedAsAnObjectArgumentIsItsEmptyValue() = withAdapter { + Python3.exec( + """ + from androidx.compose.foundation.layout import padding, paddingValuesOf + from androidx.compose.ui import Modifier, emptyModifier, describeModifier + _pv = paddingValuesOf(2) + _pv_class = type(_pv) + _px = { + 'class': describeModifier(Modifier), + 'instance': describeModifier(Modifier.empty()), + 'voted': describeModifier(padding(Modifier, 16)), + } + try: + padding(emptyModifier(), _pv_class) + _px['nofactory'] = 'called' + except TypeError as e: + _px['nofactory'] = str(e) + try: + describeModifier(_pv_class) + _px['unrelated'] = 'called' + except TypeError as e: + _px['unrelated'] = str(e) + """.trimIndent(), + ) + + assertEquals(eval("_px['instance']"), eval("_px['class']")) + assertEquals("padding(16.0)", eval("_px['voted']")) + assertTrue(eval("_px['nofactory']").contains("register_empty"), eval("_px['nofactory']")) + val unrelated = eval("_px['unrelated']") + assertTrue(unrelated != "called" && !unrelated.contains("register_empty"), unrelated) + } + /** * The same chain under the Pythonic method names (issue #131): `fill_max_width` and `z_index` * are the Kotlin members `fillMaxWidth` and `zIndex`, from the class object and from an instance,