From 60dd9db9b9ad39050e32cf551707ae8551a4eed4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:11:14 +0900 Subject: [PATCH 1/3] Docs: An Any slot boxes Python scalars and reads them back (SPEC U-10, #69) --- docs/SPEC.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 6ea3e21b3..cff02dd4f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -201,10 +201,18 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. 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` — + reference type; a Kotlin proxy written into a `kotlin.Any?` slot is the Kotlin object, and any other + Python object is held by Kotlin as itself. A Python **scalar** written into a `kotlin.Any`/`kotlin.Any?` + slot — a function argument or a property write — is boxed into the Kotlin type it means (#69): `bool` + → `kotlin.Boolean` (checked before `int`); `int` → `kotlin.Int` when it fits in 32 bits, else + `kotlin.Long`, and outside 64 bits refused with a reason; `float` → `kotlin.Double`; `str` → + `kotlin.String`. `None` is `null` for `Any?`; for a non-null `Any` it is Kotlin's own refusal (the + table does not carry nullability). Reading a `kotlin.Any`/`kotlin.Any?` value back — a property, a + function result, or a callback argument — gives the Python scalar for a Kotlin `Int`/`Long`/`Short`/`Byte` + (`int`), `Double`/`Float` (`float`), `Boolean` (`bool`), `String`/`Char` (`str`), and a proxy for any + other Kotlin object. `Status: implemented` — `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`, - `MaterialIconsRenderTest.kt`. + `AnySlotScalarRenderTest.kt`, `MaterialIconsRenderTest.kt`. ## 6. Binding prebuilt libraries (Gradle plugin) From a7cab8a94f34877c2c4c6376ed65894fbc741cdc Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:11:14 +0900 Subject: [PATCH 2/3] Test: Scalars round-trip through Any slots; a counter state recomposes from Python (#69) --- .../compose/AnySlotScalarRenderTest.kt | 137 ++++++++++++++ .../ffi/pythonx/PropertyShapedFragment.kt | 36 +++- .../ffi/pythonx/PythonxPropertyTest.kt | 172 ++++++++++++++++-- 3 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt new file mode 100644 index 000000000..80b873ad3 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt @@ -0,0 +1,137 @@ +package fixture.compose + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import python.multiplatform.compose.PythonContent +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.HandleTable +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 #69 against the real Compose runtime: a counter declared from Python as + * `mutableStateOf(0)` -- the walked generic function, its `T` read as `kotlin.Any?` -- holds a Kotlin + * `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked + * `MutableState.value` getter and setter) recomposes the root that read it. + * + * Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the + * clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so + * a frame can be attributed. + * + * ### Red before #69, and what that red says + * + * The first statement, `mutableStateOf(0)`, raised `TypeError: mutableStateOf(value: Any): an int + * cannot be stored in a Kotlin Any?: it would cross as an object handle`. A *regression* reads + * differently: a root that is not composed again after the write (the state was not a snapshot + * state, or the read was not observed), or a Kotlin value that is a `Long` rather than an `Int`. + */ +class AnySlotScalarRenderTest { + + @BeforeTest + fun installBothProducers() { + 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() { + Python3.exec( + """ + for _asr_name in ('_asr_counter', '_asr_root', '_asr_seen'): + globals().pop(_asr_name, None) + """.trimIndent(), + ) + UpcallTable.clear() + } + + @Test + fun aCounterDeclaredAsMutableStateOfZeroAndIncrementedFromPythonRecomposes() { + Python3.exec( + """ + from androidx.compose.runtime import mutableStateOf + from androidx.compose.material3 import Text + + _asr_counter = mutableStateOf(0) + _asr_seen = [] + + def _asr_root(): + _asr_n = _asr_counter.value + _asr_seen.append(_asr_n) + Text('count ' + str(_asr_n)) + """.trimIndent(), + ) + assertEquals("0", pyStr("repr(_asr_counter.value)")) + assertEquals("int", pyStr("type(_asr_counter.value).__name__")) + assertEquals(0, kotlinValue(), "Kotlin does not hold the Int 0") + + val root = Python3.import("__main__").getAttr("_asr_root") + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(root = root) + } + try { + scene.render() + assertEquals("[0]", pyStr("repr(_asr_seen)"), "the root was not composed once with the initial count") + + // An idle frame is not a reason to compose again: nothing here polls. + scene.render() + assertEquals("[0]", pyStr("repr(_asr_seen)"), "an idle frame re-ran the root") + + Python3.exec("_asr_counter.value = _asr_counter.value + 1") + assertEquals(1, kotlinValue(), "the increment is not a Kotlin Int 1") + nextFrame(scene) + assertEquals("[0, 1]", pyStr("repr(_asr_seen)"), "the increment did not recompose the root") + + Python3.exec("_asr_counter.value = _asr_counter.value + 1") + nextFrame(scene) + assertEquals("[0, 1, 2]", pyStr("repr(_asr_seen)"), "the second increment did not recompose the root") + assertTrue( + pyStr("all(type(_asr_x) is int for _asr_x in _asr_seen)") == "True", + "a count read during composition was not a Python int: ${pyStr("repr([type(_asr_x) for _asr_x in _asr_seen])")}", + ) + } finally { + scene.close() + root.close() + } + assertEquals(2, kotlinValue()) + } + + /** + * What the Kotlin `MutableState` behind the Python proxy holds, read on the Kotlin side: the + * issue's rule is `kotlin.Int` for a value that fits in 32 bits, and an `Int` is not equal to a + * `Long` of the same value, so this tells the two apart. + */ + private fun kotlinValue(): Any? { + val raw = pyStr("_asr_counter._pm_handle").toLong() + val state = HandleTable.resolveRaw(raw) as MutableState<*> + return state.value + } + + private fun nextFrame(scene: ImageComposeScene) { + Snapshot.sendApplyNotifications() + scene.render() + } + + private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() + } + + private companion object { + /** `Py_eval_input`. */ + const val PY_EVAL_INPUT = 258 + } +} 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 index 6ad14e3a3..4090d3e62 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PropertyShapedFragment.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PropertyShapedFragment.kt @@ -18,7 +18,8 @@ import python.multiplatform.reflection.TypeTag * | `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 | + * | `describeState` / `describeVector` / `describeBase` | read back what Kotlin actually holds, since a proxy alone cannot say -- for a scalar (#69), its Kotlin class | + * | `propshape.stateHolding` | a state Kotlin filled with a boxed scalar of the named kind, for the read-back direction of #69 | * * [calls] records every Kotlin-side invocation, which is how a test tells "described without reading" * apart from "read". @@ -109,9 +110,42 @@ object PropertyShapedFragment : FunctionTableFragment { null -> "null" is PyObject -> "python" is StubBase -> "kotlin:" + held.label + // Issue #69: a Python scalar written into the `Any?` slot is a Kotlin box, and its + // Kotlin class is what this reports -- `Int` and `Long` are different answers. + is Int -> "Int:$held" + is Long -> "Long:$held" + is Double -> "Double:$held" + is Boolean -> "Boolean:$held" + is String -> "String:$held" else -> "kotlin:other" } }, + ExposedCallable( + name = "propshape.stateHolding", + arity = 1, + paramTypes = listOf(TypeTag.STRING), + returnType = TypeTag.OBJECT, + paramNames = listOf("kind"), + paramTypeNames = listOf("kotlin.String"), + returnTypeName = "$MUTABLE_STATE<:$STATE", + ) { args -> + // A state whose value Kotlin put there itself, so the read-back direction of #69 is + // tested for every boxed type Kotlin can hold, not only those Python can write. + StubMutableState( + when (val kind = args[0] as String) { + "Int" -> 7 + "Long" -> 1L shl 40 + "Short" -> 300.toShort() + "Byte" -> (-5).toByte() + "Double" -> 2.5 + "Float" -> 1.5f + "Boolean" -> true + "String" -> "text" + "Char" -> 'c' + else -> error("no Kotlin value of kind $kind") + }, + ) + }, ExposedCallable( name = "propshape.makeDerived", arity = 1, 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 index 9b5131983..119338b2a 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxPropertyTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxPropertyTest.kt @@ -89,32 +89,148 @@ class PythonxPropertyTest { from propshape import makeDerived _pp_state = mutableStateOf(makeDerived('d')) _pp = {'held': describeState(_pp_state)} + # Read back through the same `Any?` (#69): a Kotlin object that is not a scalar is still a + # proxy over its handle, and writing that proxy on is the same Kotlin object again. + _pp_back = _pp_state.value + _pp['back_is_proxy'] = getattr(_pp_back, '_pm_handle', None) is not None + _pp['passed_on'] = describeState(mutableStateOf(_pp_back)) """.trimIndent(), ) assertEquals("kotlin:d", eval("_pp['held']")) + assertEquals("True", eval("repr(_pp['back_is_proxy'])"), "a Kotlin object read from Any? is not a proxy") + assertEquals("kotlin:d", eval("_pp['passed_on']")) } + // ------------------------------------------------------------- scalars in an Any? slot (#69) + // + // Red before #69, and what each red says: every write below raised `TypeError: ... an int cannot + // be stored in a Kotlin Any?: it would cross as an object handle` (a `bool` is an `int` there + // too), and a Kotlin-held scalar read back came out as a `kotlin.Any` proxy over a handle rather + // than as the Python value. A *regression* reads differently: the right Python value with the + // wrong Kotlin class behind it (`Long:7` where `Int:7` is expected), or a handle count that + // does not come back to its baseline. + /** - * An `int` would cross an `OBJECT` slot as a handle and name some other Kotlin object; refused - * with a reason rather than stored. + * Each scalar written through an `Any?` **function** slot (`mutableStateOf(value)`) is the Kotlin + * box the issue names, and reads back as the Python value and type it was. */ @Test - fun anIntIsRefusedForAnAnySlot() = withAdapter { + fun aScalarPassedToAnAnySlotIsAKotlinBoxAndReadsBackAsItself() = withAdapter { + Python3.exec(SCALARS + "\n" + + """ + from androidx.compose.runtime import mutableStateOf, describeState + _pp = {} + for _pp_label, _pp_value in _pp_scalars: + _pp_state = mutableStateOf(_pp_value) + _pp_back = _pp_state.value + _pp[_pp_label] = (describeState(_pp_state), type(_pp_back).__name__, _pp_back == _pp_value) + """.trimIndent(), + ) + assertScalarRoundTrips() + } + + /** The same rules through a `var` **property** write (`state.value = x`), starting from `null`. */ + @Test + fun aScalarWrittenToAnAnyPropertyIsAKotlinBoxAndReadsBackAsItself() = withAdapter { + Python3.exec(SCALARS + "\n" + + """ + from androidx.compose.runtime import mutableStateOf, describeState + _pp = {} + _pp_state = mutableStateOf(None) + for _pp_label, _pp_value in _pp_scalars: + _pp_state.value = _pp_value + _pp_back = _pp_state.value + _pp[_pp_label] = (describeState(_pp_state), type(_pp_back).__name__, _pp_back == _pp_value) + """.trimIndent(), + ) + assertScalarRoundTrips() + } + + /** An `int` no Kotlin integer holds is refused with a reason, through both spellings. */ + @Test + fun anIntOutside64BitsIsRefusedForAnAnySlot() = 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) + for _pp_label, _pp_value in (('above', 2**63), ('below', -2**63 - 1)): + for _pp_how, _pp_call in ( + ('call', lambda: mutableStateOf(_pp_value)), + ('write', lambda: setattr(_pp_state, 'value', _pp_value)), + ): + try: + _pp_call() + _pp[_pp_label + '_' + _pp_how] = 'accepted' + except TypeError as _pp_e: + _pp[_pp_label + '_' + _pp_how] = str(_pp_e) + _pp['still_empty'] = _pp_state.value is None + """.trimIndent(), + ) + for (key in listOf("above_call", "above_write", "below_call", "below_write")) { + val message = eval("_pp['$key']") + assertTrue("64 bits" in message, "$key: $message") + } + assertEquals("True", eval("repr(_pp['still_empty'])"), "a refused write reached Kotlin") + } + + /** + * The read-back direction on values **Kotlin** put there: every boxed type the issue lists comes + * out as the Python scalar, including the ones Python never writes (`Short`, `Byte`, `Float`, `Char`). + */ + @Test + fun aKotlinScalarReadFromAnAnySlotIsThePythonScalar() = withAdapter { + Python3.exec( + """ + from propshape import stateHolding + _pp = {} + for _pp_kind in ('Int', 'Long', 'Short', 'Byte', 'Double', 'Float', 'Boolean', 'String', 'Char'): + _pp_back = stateHolding(_pp_kind).value + _pp[_pp_kind] = (type(_pp_back).__name__, repr(_pp_back)) + """.trimIndent(), + ) + assertEquals("('int', '7')", eval("repr(_pp['Int'])")) + assertEquals("('int', '1099511627776')", eval("repr(_pp['Long'])")) + assertEquals("('int', '300')", eval("repr(_pp['Short'])")) + assertEquals("('int', '-5')", eval("repr(_pp['Byte'])")) + assertEquals("('float', '2.5')", eval("repr(_pp['Double'])")) + assertEquals("('float', '1.5')", eval("repr(_pp['Float'])")) + assertEquals("('bool', 'True')", eval("repr(_pp['Boolean'])")) + assertEquals("('str', \"'text'\")", eval("repr(_pp['String'])")) + assertEquals("('str', \"'c'\")", eval("repr(_pp['Char'])")) + } + + /** + * Boxing and unboxing root nothing that outlives the statement: the box a write takes, and the + * handle a scalar read crosses as, are both given back. Counted at both ends -- the state's own + * proxy holds one root, and that one must still be there. + */ + @Test + fun boxingAScalarLeavesNoHandleRooted() = withAdapter { + Python3.exec( + """ + from androidx.compose.runtime import mutableStateOf + _pp_state = mutableStateOf(None) """.trimIndent(), ) - assertTrue("int" in eval("_pp['call']"), eval("_pp['call']")) - assertTrue("int" in eval("_pp['write']"), eval("_pp['write']")) + val baseline = HandleTable.liveCount + Python3.exec( + """ + for _pp_i in range(20): + _pp_state.value = _pp_i + _pp_state.value = _pp_state.value + 2**40 + _pp_state.value = float(_pp_i) + _pp_state.value = (_pp_i % 2 == 0) + _pp_state.value = str(_pp_i) + _pp_last = _pp_state.value + _pp_other = mutableStateOf(_pp_i) + _pp_seen = _pp_other.value + del _pp_other + """.trimIndent(), + ) + assertEquals("'19'", eval("repr(_pp_last)")) + assertEquals("19", eval("repr(_pp_seen)")) + assertEquals(baseline, HandleTable.liveCount, "a box or an unboxed read left a handle rooted") } /** A `val` has no setter: writing it is Python's own refusal, and nothing reaches Kotlin. */ @@ -308,4 +424,38 @@ class PythonxPropertyTest { } private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() + + /** What [SCALARS] must read back as: the Kotlin box Kotlin holds, the Python type, and equality. */ + private fun assertScalarRoundTrips() { + val expected = listOf( + "true_" to "('Boolean:true', 'bool', True)", + "false_" to "('Boolean:false', 'bool', True)", + "int" to "('Int:7', 'int', True)", + "zero" to "('Int:0', 'int', True)", + "negative" to "('Int:-3', 'int', True)", + "int_max" to "('Int:2147483647', 'int', True)", + "int_min" to "('Int:-2147483648', 'int', True)", + "long" to "('Long:2147483648', 'int', True)", + "long_negative" to "('Long:-2147483649', 'int', True)", + "long_max" to "('Long:9223372036854775807', 'int', True)", + "long_min" to "('Long:-9223372036854775808', 'int', True)", + "float" to "('Double:2.5', 'float', True)", + "str" to "('String:hi', 'str', True)", + ) + for ((label, shape) in expected) { + assertEquals(shape, eval("repr(_pp['$label'])"), label) + } + } + + private companion object { + /** Every scalar #69 names, at each edge of the `Int`/`Long` choice. */ + val SCALARS = """ + _pp_scalars = ( + ('true_', True), ('false_', False), + ('int', 7), ('zero', 0), ('negative', -3), ('int_max', 2**31 - 1), ('int_min', -2**31), + ('long', 2**31), ('long_negative', -2**31 - 1), ('long_max', 2**63 - 1), ('long_min', -2**63), + ('float', 2.5), ('str', 'hi'), + ) + """.trimIndent() + } } From 0a91265c9bbcc223c98fe8eb20b08802030f3cf3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:11:14 +0900 Subject: [PATCH 3/3] Feat: Box Python int/float/bool/str into Kotlin Any slots and unbox on read (#69) --- .../ffi/pythonx/PythonCallables.kt | 90 +++++++++++++ .../ffi/pythonx/PythonxAdapter.kt | 121 ++++++++++++++++-- .../ffi/upcall/UpcallTrampoline.kt | 37 ++++++ 3 files changed, 236 insertions(+), 12 deletions(-) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt index 4f51d4ff1..5304789c4 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt @@ -336,7 +336,79 @@ object PythonCallables { paramHasDefault = listOf(false), callable = { args -> findFunction(args[0] as String) }, ), + // Issue #69: a Python scalar written into a `kotlin.Any` slot, boxed as the Kotlin type + // `PythonxAdapter._box_scalar` chose. Each takes the primitive tag that already carries + // that value exactly, and answers with the handle of the box (see [boxScalar]). + boxEntry(BOX_INT, TypeTag.INT, "kotlin.Int") { (it as Long).toInt() }, + boxEntry(BOX_LONG, TypeTag.INT, "kotlin.Long") { it as Long }, + boxEntry(BOX_DOUBLE, TypeTag.FLOAT, "kotlin.Double") { it as Double }, + boxEntry(BOX_BOOLEAN, TypeTag.BOOLEAN, "kotlin.Boolean") { it as Boolean }, + boxEntry(BOX_STRING, TypeTag.STRING, "kotlin.String") { it as String }, + ExposedCallable( + name = UNBOX_SCALAR, + arity = 1, + // INT, not OBJECT: this needs the handle *number*, to release it, and an OBJECT + // slot would hand over only the object it resolves to. + paramTypes = listOf(TypeTag.INT), + returnType = TypeTag.OBJECT, + paramNames = listOf("handle"), + paramTypeNames = listOf("kotlin.Long"), + // No return type name, like the two entries above: the result is a Python scalar or + // `None`, never a handle, so no producer may try to own it. + paramHasDefault = listOf(false), + callable = { args -> unboxScalar(args[0] as Long) }, + ), ) + + private fun boxEntry(name: String, tag: TypeTag, typeName: String, box: (Any?) -> Any): ExposedCallable = + ExposedCallable( + name = name, + arity = 1, + paramTypes = listOf(tag), + returnType = TypeTag.INT, + paramNames = listOf("value"), + paramTypeNames = listOf(typeName), + paramHasDefault = listOf(false), + callable = { args -> boxScalar(box(args[0])) }, + ) + } + + /** + * Roots [value] -- a Kotlin box the binding layer asked for -- and returns its raw handle. + * + * ### Why a box crosses as a handle at all + * + * The value is on its way into an `OBJECT` slot, and the only thing an `OBJECT` slot reads a + * Python `int` as is a [HandleTable] handle (`UpcallTrampoline.toKotlinObject`). So the box is + * made here, rooted, and its handle is what Python then puts in the slot; the slot resolves it to + * this very object, and whatever Kotlin stores it in holds it from then on by an ordinary + * reference. + * + * ### Who gives the root back + * + * Python, as soon as the argument tuple the handle sits in is gone: `PythonxAdapter + * ._BoxedArgument` is an `int` whose `__del__` releases it, so the root lives exactly as long as + * the one call it was made for. Releasing here instead would hand the slot a stale handle. + */ + internal fun boxScalar(value: Any): Long = HandleTable.register(value).raw + + /** + * What a handle Python read out of a `kotlin.Any` slot means, if it is a boxed scalar. + * + * @return a [python.multiplatform.ffi.upcall.ScalarResult] -- which the trampoline marshals as + * the Python scalar, as a new reference -- **after releasing [raw]**, because the Python side + * keeps no proxy over a scalar and the handle has no other owner; or `null` (`None`) for any + * other object, whose handle is left rooted for the proxy Python then builds over it. A stale + * [raw] is `null` too: there is nothing to release, and the proxy built over it refuses on use. + */ + internal fun unboxScalar(raw: Long): Any? { + val held = HandleTable.resolveRaw(raw) ?: return null + when (held) { + is Boolean, is Int, is Long, is Short, is Byte, is Double, is Float, is String, is Char -> Unit + else -> return null + } + HandleTable.release(ObjectReference(raw)) + return python.multiplatform.ffi.upcall.ScalarResult(held) } /** The name the binding layer resolves. Shared so the Python source and the entry cannot drift. */ @@ -344,6 +416,24 @@ object PythonCallables { /** @see findFunction */ internal const val FIND_FUNCTION: String = "python.multiplatform.ffi.pythonx.PythonCallables.findFunction" + + /** @see boxScalar */ + internal const val BOX_INT: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxInt" + + /** @see boxScalar */ + internal const val BOX_LONG: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxLong" + + /** @see boxScalar */ + internal const val BOX_DOUBLE: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxDouble" + + /** @see boxScalar */ + internal const val BOX_BOOLEAN: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxBoolean" + + /** @see boxScalar */ + internal const val BOX_STRING: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxString" + + /** @see unboxScalar */ + internal const val UNBOX_SCALAR: String = "python.multiplatform.ffi.pythonx.PythonCallables.unboxScalar" } /** 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 b85782048..90d2690de 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 @@ -1339,12 +1339,43 @@ object PythonxAdapter { def _wrap(result, kotlin_type_name): - '''A `TypeTag.OBJECT` result: a handle integer, given an owner.''' - if kotlin_type_name is None or not isinstance(result, int): + '''A `TypeTag.OBJECT` result: a handle integer, given an owner. + + A `bool` is not a handle even though it is an `int`: the boundary never sends one for a + Kotlin object, and a `True` read as handle 1 would name some unrelated root. + + A value read as `kotlin.Any` may be a boxed scalar (issue #69); `_from_any` asks. + ''' + if kotlin_type_name is None or isinstance(result, bool) or not isinstance(result, int): return result + if kotlin_type_name == _KOTLIN_ANY: + return _from_any(result) return _proxy_type(kotlin_type_name)(result) + def _from_any(handle): + '''The handle of a value Kotlin holds as `kotlin.Any`: the Python scalar, or a proxy. + + ### Why this has to ask Kotlin + + A Kotlin `Int` in an `Any` crosses as a handle like every other Kotlin object, and an `int` + is all Python receives either way -- so nothing on this side can tell `MutableState`'s + 7 from the handle of a `Modifier`. Kotlin can: `PythonCallables.unboxScalar` looks the + handle up, and for a boxed `Int`/`Long`/`Short`/`Byte`/`Double`/`Float`/`Boolean`/ + `String`/`Char` it **releases** the root and answers the Python scalar (a new reference, + built by the trampoline). For anything else it answers `None` and the root stays, owned + by the proxy built here exactly as before. + + The cost is one more crossing per `kotlin.Any` read that is not a Python object. A Python + object Kotlin holds (`state.value = root`) comes back as itself, not as an `int`, and + never reaches this function. + ''' + scalar = _boundary()['invoke'](_resolve('${PythonCallables.UNBOX_SCALAR}'), (handle,)) + if scalar is None: + return _proxy_type(_KOTLIN_ANY)(handle) + return scalar + + # --------------------------------------------------------------------------- calling # What an unbounded Kotlin type parameter is read as (issue #38): `mutableStateOf`'s `value`, @@ -1355,6 +1386,73 @@ object PythonxAdapter { # Boolean cannot: `TypeTag.INT` narrows a `Long`, and the walker declines a nullable one. _NULLABLE_TAGS = ('STRING', 'BYTES', 'OBJECT') + # Issue #69: which Kotlin integer an `int` written into a `kotlin.Any` slot becomes. `Int` when + # it fits in 32 bits -- what `mutableStateOf(0)` is in Kotlin -- else `Long`; outside 64 bits + # there is no Kotlin integer to box it as, and the write is refused. + _INT_MIN = -(1 << 31) + _INT_MAX = (1 << 31) - 1 + _LONG_MIN = -(1 << 63) + _LONG_MAX = (1 << 63) - 1 + + # What `_coerce` puts in a scalar's slot during a *trial* bind, for `_CALLABLE_PENDING`'s + # reason: boxing crosses into Kotlin and roots the box, which a candidate that then loses + # has no business doing. The winner is re-bound strictly before the call. + _SCALAR_PENDING = object() + + + class _BoxedArgument(int): + '''The handle of a Kotlin box, on its way into one `kotlin.Any` slot of one call. + + An `int` because that is what the slot reads a Kotlin object as (`UpcallTrampoline + .toKotlinObject`); a subclass so that it can give the root back. It lives in the argument + tuple, so it dies -- and releases -- when the call that tuple was built for is over, by + which time Kotlin holds the box by an ordinary reference (the state's field, say) and + needs the root no more. Released earlier, the slot would resolve a stale handle. + ''' + + __slots__ = () + + def __del__(self): + try: + _boundary()['release'](int(self)) + except Exception: + # `__del__` cannot raise usefully, and at interpreter teardown the boundary may + # already be gone; the root then goes with `HandleTable` itself. + pass + + + def _box_scalar(value, strict): + '''A Python scalar for a `kotlin.Any` slot, as the handle of the Kotlin box it means (#69). + + `bool` before `int`, because a `bool` *is* an `int`. A subclass of a scalar type is boxed as + the base type's value (`int(value)`), never sent as itself: an `int` subclass reaching the + slot would be read as a handle. + ''' + if isinstance(value, bool): + entry = '${PythonCallables.BOX_BOOLEAN}' + carried = bool(value) + elif isinstance(value, int): + if _INT_MIN <= value <= _INT_MAX: + entry = '${PythonCallables.BOX_INT}' + elif _LONG_MIN <= value <= _LONG_MAX: + entry = '${PythonCallables.BOX_LONG}' + else: + return _refuse( + strict, + 'an int outside 64 bits cannot be stored in a Kotlin Any?: no Kotlin integer ' + 'holds ' + repr(value), + ) + carried = int(value) + elif isinstance(value, float): + entry = '${PythonCallables.BOX_DOUBLE}' + carried = float(value) + else: + entry = '${PythonCallables.BOX_STRING}' + carried = str(value) + if not strict: + return _SCALAR_PENDING + return _BoxedArgument(_boundary()['invoke'](_resolve(entry), (carried,))) + def _coerce(value, tag, type_name, decl, slot, strict): '''One argument on its way in. Returns the marshalled value, or raises when `strict`.''' @@ -1367,19 +1465,18 @@ object PythonxAdapter { 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. + # `Any?` holds whatever it is given. A proxy is a Kotlin object and goes as its handle. + # A Python scalar is boxed as the Kotlin type it means (issue #69, `_box_scalar`): + # `mutableStateOf(0)` holds a Kotlin `Int`, which Compose and every Kotlin reader + # expects. Sent as itself it could not cross at all -- the boundary reads every int in + # an OBJECT slot as a 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`. 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', - ) + if isinstance(value, (int, float, str)): + return _box_scalar(value, strict) return value if isinstance(value, _ValueProxy): if type_name is not None and value.kotlin_type_name != type_name: diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt index a786b3bbe..797190724 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt @@ -310,6 +310,7 @@ object UpcallTrampoline { } private fun fromKotlinObject(value: Any): Long { + if (value is ScalarResult) return fromKotlinScalar(value.value) if (value is PyObject) { // The wrapper keeps the reference it holds, so Python needs one of its own. Py_IncRef(value.pointer) @@ -320,6 +321,24 @@ object UpcallTrampoline { return newReference(PyLong_FromLongLong(HandleTable.register(value).raw), "object handle") } + /** + * A boxed Kotlin scalar as the Python scalar it means (issue #69), reached only through a + * [ScalarResult] -- see there for why a bare boxed value must not take this path. + * + * Every constructor here returns a **new reference**, which is what this function owes Python; + * nothing is wrapped in a Kotlin `PyObject`, so there is no second reference left for a cleaner. + */ + private fun fromKotlinScalar(value: Any): Long = when (value) { + is Boolean -> newReference(PyBool_FromLong(if (value) 1 else 0), "bool") + is Int, is Long, is Short, is Byte -> newReference(PyLong_FromLongLong((value as Number).toLong()), "int") + is Double, is Float -> newReference(PyFloat_FromDouble((value as Number).toDouble()), "float") + is String -> newReference(PyUnicode_FromString(value), "str") + is Char -> newReference(PyUnicode_FromString(value.toString()), "str") + else -> throw IllegalArgumentException( + "a ${value::class.simpleName} is not a Kotlin scalar Python has a value type for", + ) + } + /** The mirror of [toByteArray], and the same caveat: a tuple of ints, one object per byte. */ private fun toPythonBytes(bytes: ByteArray): NativePointer? { val tuple = PyTuple_New(bytes.size.toLong()) ?: return null @@ -412,3 +431,21 @@ object UpcallTrampoline { */ internal fun runtimeErrorClass(): PyObject = PyObject(runtimeErrorType, borrowed = true) } + +/** + * A `TypeTag.OBJECT` result that is to reach Python as the **scalar** [value] holds -- `int`, + * `float`, `bool` or `str` -- rather than as a [HandleTable] handle (issue #69). + * + * ### Why a marker, and not a rule on the boxed value itself + * + * An `OBJECT` result crosses as an integer handle, and Python has no way to tell a handle from an + * `int`. If [UpcallTrampoline] turned every boxed `Int` it was handed into a Python `int`, a caller + * expecting a handle -- every KSP entry, every walked function returning a type parameter -- could + * no longer tell a Kotlin `Int` from the Kotlin object whose handle happens to be that number. So + * the scalar form is **asked for**, by the one caller that knows it wants it: the binding layer, + * reading a `kotlin.Any` value, calls `PythonCallables.unboxScalar`, which answers with this. No + * other path produces one, so nothing that crosses today changes shape. + * + * @property value a `Boolean`, `Int`, `Long`, `Short`, `Byte`, `Double`, `Float`, `String` or `Char`. + */ +internal class ScalarResult(val value: Any)