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
7 changes: 7 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).

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