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
14 changes: 11 additions & 3 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,104 @@ 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. */
internal const val NEW_FUNCTION: String = "python.multiplatform.ffi.pythonx.PythonCallables.newFunction"

/** @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"
}

/**
Expand Down
Loading
Loading