Skip to content

Commit 0aa945b

Browse files
authored
Merge pull request #81 from thisisthepy/work/any-slot-scalars
Feat: Box Python scalars into Kotlin Any slots and unbox on read
2 parents 368d40a + b7848d8 commit 0aa945b

7 files changed

Lines changed: 580 additions & 27 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,18 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
228228
extension property's getter reads the same way on its receiver (`Icons.Default.Add`), and both are
229229
found on the type and then on every type the table says it is a. A property is never a module
230230
attribute and makes no package. `None` written for a slot with no default is Kotlin's `null` for a
231-
reference type; a Python object written into a `kotlin.Any?` slot is held by Kotlin as itself, and an
232-
`int` is refused there (it would cross as a handle). `Status: implemented` —
231+
reference type; a Kotlin proxy written into a `kotlin.Any?` slot is the Kotlin object, and any other
232+
Python object is held by Kotlin as itself. A Python **scalar** written into a `kotlin.Any`/`kotlin.Any?`
233+
slot — a function argument or a property write — is boxed into the Kotlin type it means (#69): `bool`
234+
→ `kotlin.Boolean` (checked before `int`); `int` → `kotlin.Int` when it fits in 32 bits, else
235+
`kotlin.Long`, and outside 64 bits refused with a reason; `float` → `kotlin.Double`; `str` →
236+
`kotlin.String`. `None` is `null` for `Any?`; for a non-null `Any` it is Kotlin's own refusal (the
237+
table does not carry nullability). Reading a `kotlin.Any`/`kotlin.Any?` value back — a property, a
238+
function result, or a callback argument — gives the Python scalar for a Kotlin `Int`/`Long`/`Short`/`Byte`
239+
(`int`), `Double`/`Float` (`float`), `Boolean` (`bool`), `String`/`Char` (`str`), and a proxy for any
240+
other Kotlin object. `Status: implemented` —
233241
`PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`,
234-
`MaterialIconsRenderTest.kt`.
242+
`AnySlotScalarRenderTest.kt`, `MaterialIconsRenderTest.kt`.
235243

236244
## 6. Binding prebuilt libraries (Gradle plugin)
237245

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package fixture.compose
2+
3+
import androidx.compose.runtime.MutableState
4+
import androidx.compose.runtime.snapshots.Snapshot
5+
import androidx.compose.ui.ImageComposeScene
6+
import androidx.compose.ui.unit.Density
7+
import python.multiplatform.compose.PythonContent
8+
import python.multiplatform.ffi.Python3
9+
import python.multiplatform.ffi.pythonx.PythonxAdapter
10+
import python.multiplatform.ffi.upcall.PythonProxySource
11+
import python.multiplatform.ffi.upcall.UpcallBootstrap
12+
import python.multiplatform.generated.FunctionTable
13+
import python.multiplatform.generated.artifacts.ArtifactTable
14+
import python.multiplatform.reflection.HandleTable
15+
import python.multiplatform.reflection.UpcallTable
16+
import kotlin.test.AfterTest
17+
import kotlin.test.BeforeTest
18+
import kotlin.test.Test
19+
import kotlin.test.assertEquals
20+
import kotlin.test.assertTrue
21+
22+
/**
23+
* Issue #69 against the real Compose runtime: a counter declared from Python as
24+
* `mutableStateOf(0)` -- the walked generic function, its `T` read as `kotlin.Any?` -- holds a Kotlin
25+
* `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked
26+
* `MutableState.value` getter and setter) recomposes the root that read it.
27+
*
28+
* Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the
29+
* clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so
30+
* a frame can be attributed.
31+
*
32+
* ### Red before #69, and what that red says
33+
*
34+
* The first statement, `mutableStateOf(0)`, raised `TypeError: mutableStateOf(value: Any): an int
35+
* cannot be stored in a Kotlin Any?: it would cross as an object handle`. A *regression* reads
36+
* differently: a root that is not composed again after the write (the state was not a snapshot
37+
* state, or the read was not observed), or a Kotlin value that is a `Long` rather than an `Int`.
38+
*/
39+
class AnySlotScalarRenderTest {
40+
41+
@BeforeTest
42+
fun installBothProducers() {
43+
Python3.initialize(silent = true)
44+
UpcallTable.clear()
45+
UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
46+
check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
47+
PythonProxySource.install()
48+
PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
49+
}
50+
51+
@AfterTest
52+
fun cleanup() {
53+
Python3.exec(
54+
"""
55+
for _asr_name in ('_asr_counter', '_asr_root', '_asr_seen'):
56+
globals().pop(_asr_name, None)
57+
""".trimIndent(),
58+
)
59+
UpcallTable.clear()
60+
}
61+
62+
@Test
63+
fun aCounterDeclaredAsMutableStateOfZeroAndIncrementedFromPythonRecomposes() {
64+
Python3.exec(
65+
"""
66+
from androidx.compose.runtime import mutableStateOf
67+
from androidx.compose.material3 import Text
68+
69+
_asr_counter = mutableStateOf(0)
70+
_asr_seen = []
71+
72+
def _asr_root():
73+
_asr_n = _asr_counter.value
74+
_asr_seen.append(_asr_n)
75+
Text('count ' + str(_asr_n))
76+
""".trimIndent(),
77+
)
78+
assertEquals("0", pyStr("repr(_asr_counter.value)"))
79+
assertEquals("int", pyStr("type(_asr_counter.value).__name__"))
80+
assertEquals(0, kotlinValue(), "Kotlin does not hold the Int 0")
81+
82+
val root = Python3.import("__main__").getAttr("_asr_root")
83+
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
84+
PythonContent(root = root)
85+
}
86+
try {
87+
scene.render()
88+
assertEquals("[0]", pyStr("repr(_asr_seen)"), "the root was not composed once with the initial count")
89+
90+
// An idle frame is not a reason to compose again: nothing here polls.
91+
scene.render()
92+
assertEquals("[0]", pyStr("repr(_asr_seen)"), "an idle frame re-ran the root")
93+
94+
Python3.exec("_asr_counter.value = _asr_counter.value + 1")
95+
assertEquals(1, kotlinValue(), "the increment is not a Kotlin Int 1")
96+
nextFrame(scene)
97+
assertEquals("[0, 1]", pyStr("repr(_asr_seen)"), "the increment did not recompose the root")
98+
99+
Python3.exec("_asr_counter.value = _asr_counter.value + 1")
100+
nextFrame(scene)
101+
assertEquals("[0, 1, 2]", pyStr("repr(_asr_seen)"), "the second increment did not recompose the root")
102+
assertTrue(
103+
pyStr("all(type(_asr_x) is int for _asr_x in _asr_seen)") == "True",
104+
"a count read during composition was not a Python int: ${pyStr("repr([type(_asr_x) for _asr_x in _asr_seen])")}",
105+
)
106+
} finally {
107+
scene.close()
108+
root.close()
109+
}
110+
assertEquals(2, kotlinValue())
111+
}
112+
113+
/**
114+
* What the Kotlin `MutableState` behind the Python proxy holds, read on the Kotlin side: the
115+
* issue's rule is `kotlin.Int` for a value that fits in 32 bits, and an `Int` is not equal to a
116+
* `Long` of the same value, so this tells the two apart.
117+
*/
118+
private fun kotlinValue(): Any? {
119+
val raw = pyStr("_asr_counter._pm_handle").toLong()
120+
val state = HandleTable.resolveRaw(raw) as MutableState<*>
121+
return state.value
122+
}
123+
124+
private fun nextFrame(scene: ImageComposeScene) {
125+
Snapshot.sendApplyNotifications()
126+
scene.render()
127+
}
128+
129+
private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals ->
130+
Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString()
131+
}
132+
133+
private companion object {
134+
/** `Py_eval_input`. */
135+
const val PY_EVAL_INPUT = 258
136+
}
137+
}

‎python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,14 +336,104 @@ object PythonCallables {
336336
paramHasDefault = listOf(false),
337337
callable = { args -> findFunction(args[0] as String) },
338338
),
339+
// Issue #69: a Python scalar written into a `kotlin.Any` slot, boxed as the Kotlin type
340+
// `PythonxAdapter._box_scalar` chose. Each takes the primitive tag that already carries
341+
// that value exactly, and answers with the handle of the box (see [boxScalar]).
342+
boxEntry(BOX_INT, TypeTag.INT, "kotlin.Int") { (it as Long).toInt() },
343+
boxEntry(BOX_LONG, TypeTag.INT, "kotlin.Long") { it as Long },
344+
boxEntry(BOX_DOUBLE, TypeTag.FLOAT, "kotlin.Double") { it as Double },
345+
boxEntry(BOX_BOOLEAN, TypeTag.BOOLEAN, "kotlin.Boolean") { it as Boolean },
346+
boxEntry(BOX_STRING, TypeTag.STRING, "kotlin.String") { it as String },
347+
ExposedCallable(
348+
name = UNBOX_SCALAR,
349+
arity = 1,
350+
// INT, not OBJECT: this needs the handle *number*, to release it, and an OBJECT
351+
// slot would hand over only the object it resolves to.
352+
paramTypes = listOf(TypeTag.INT),
353+
returnType = TypeTag.OBJECT,
354+
paramNames = listOf("handle"),
355+
paramTypeNames = listOf("kotlin.Long"),
356+
// No return type name, like the two entries above: the result is a Python scalar or
357+
// `None`, never a handle, so no producer may try to own it.
358+
paramHasDefault = listOf(false),
359+
callable = { args -> unboxScalar(args[0] as Long) },
360+
),
339361
)
362+
363+
private fun boxEntry(name: String, tag: TypeTag, typeName: String, box: (Any?) -> Any): ExposedCallable =
364+
ExposedCallable(
365+
name = name,
366+
arity = 1,
367+
paramTypes = listOf(tag),
368+
returnType = TypeTag.INT,
369+
paramNames = listOf("value"),
370+
paramTypeNames = listOf(typeName),
371+
paramHasDefault = listOf(false),
372+
callable = { args -> boxScalar(box(args[0])) },
373+
)
374+
}
375+
376+
/**
377+
* Roots [value] -- a Kotlin box the binding layer asked for -- and returns its raw handle.
378+
*
379+
* ### Why a box crosses as a handle at all
380+
*
381+
* The value is on its way into an `OBJECT` slot, and the only thing an `OBJECT` slot reads a
382+
* Python `int` as is a [HandleTable] handle (`UpcallTrampoline.toKotlinObject`). So the box is
383+
* made here, rooted, and its handle is what Python then puts in the slot; the slot resolves it to
384+
* this very object, and whatever Kotlin stores it in holds it from then on by an ordinary
385+
* reference.
386+
*
387+
* ### Who gives the root back
388+
*
389+
* Python, as soon as the argument tuple the handle sits in is gone: `PythonxAdapter
390+
* ._BoxedArgument` is an `int` whose `__del__` releases it, so the root lives exactly as long as
391+
* the one call it was made for. Releasing here instead would hand the slot a stale handle.
392+
*/
393+
internal fun boxScalar(value: Any): Long = HandleTable.register(value).raw
394+
395+
/**
396+
* What a handle Python read out of a `kotlin.Any` slot means, if it is a boxed scalar.
397+
*
398+
* @return a [python.multiplatform.ffi.upcall.ScalarResult] -- which the trampoline marshals as
399+
* the Python scalar, as a new reference -- **after releasing [raw]**, because the Python side
400+
* keeps no proxy over a scalar and the handle has no other owner; or `null` (`None`) for any
401+
* other object, whose handle is left rooted for the proxy Python then builds over it. A stale
402+
* [raw] is `null` too: there is nothing to release, and the proxy built over it refuses on use.
403+
*/
404+
internal fun unboxScalar(raw: Long): Any? {
405+
val held = HandleTable.resolveRaw(raw) ?: return null
406+
when (held) {
407+
is Boolean, is Int, is Long, is Short, is Byte, is Double, is Float, is String, is Char -> Unit
408+
else -> return null
409+
}
410+
HandleTable.release(ObjectReference(raw))
411+
return python.multiplatform.ffi.upcall.ScalarResult(held)
340412
}
341413

342414
/** The name the binding layer resolves. Shared so the Python source and the entry cannot drift. */
343415
internal const val NEW_FUNCTION: String = "python.multiplatform.ffi.pythonx.PythonCallables.newFunction"
344416

345417
/** @see findFunction */
346418
internal const val FIND_FUNCTION: String = "python.multiplatform.ffi.pythonx.PythonCallables.findFunction"
419+
420+
/** @see boxScalar */
421+
internal const val BOX_INT: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxInt"
422+
423+
/** @see boxScalar */
424+
internal const val BOX_LONG: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxLong"
425+
426+
/** @see boxScalar */
427+
internal const val BOX_DOUBLE: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxDouble"
428+
429+
/** @see boxScalar */
430+
internal const val BOX_BOOLEAN: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxBoolean"
431+
432+
/** @see boxScalar */
433+
internal const val BOX_STRING: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxString"
434+
435+
/** @see unboxScalar */
436+
internal const val UNBOX_SCALAR: String = "python.multiplatform.ffi.pythonx.PythonCallables.unboxScalar"
347437
}
348438

349439
/**

0 commit comments

Comments
 (0)