diff --git a/docs/SPEC.md b/docs/SPEC.md index 42096ef6c..1a33d5305 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -211,6 +211,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `ksp-fixtures/compose/src/desktopTest/` (`ComposableRenderTest.kt`, `M3ProofRenderTest.kt`, `CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`; `planned` on Android, iOS and wasm. +- **B-8** A host draws a Python-declared application root with + `python.multiplatform.compose.PythonContent(root: PyObject)` or `PythonContent(module, attribute)`, + from the `python-multiplatform-compose` module (`python-multiplatform` itself does not depend on + Compose). The root is a Python callable, or a Compose `State` that Python holds whose value is that + callable. The state is read inside the composition, so a Python write into it replaces the root on + the next frame with no host call; Python callables a root passed into composables are released when + that root is replaced or the composition is disposed. The entry point names no library. + `Status: implemented` on desktop — `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module + compiles for Android, nothing runs there yet. - **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names (keyword parameters by Kotlin name, `= ...` for a Kotlin default, receiver positional-only). Types are the declared Kotlin types: one stub class per bound type in the module of its own package, `Dp | float` @@ -258,6 +267,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **N-3** Binding Kotlin/Native klib declarations at run time (B-2). `Status: planned`. - **N-4** Linux and Windows desktop runs in CI (P-2). `Status: planned`. - **N-5** Native-image upcall verification as an automated test (U-6). `Status: planned`. +- **N-6** Compose state created and written from Python through the binder: + `androidx.compose.runtime.mutableStateOf(x)` callable, and `.value` of the returned `MutableState` + readable and writable on its proxy. Neither is bound today: the walker emits only `FUNCTION` and + `STATIC_GETTER` entries (no instance members), and declines every declaration whose signature + mentions a type parameter. B-8's test writes its root state through a fixture-local KSP-bound + setter instead. `Status: planned`. --- diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts index 5d89fd6a7..a226225ac 100644 --- a/ksp-fixtures/compose/build.gradle.kts +++ b/ksp-fixtures/compose/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { } val desktopMain by getting { dependencies { + implementation(projects.pythonMultiplatformCompose) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt index e01ca4587..0d3e0a51e 100644 --- a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt @@ -1,9 +1,9 @@ package fixture.compose -import androidx.compose.runtime.currentComposer import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import python.multiplatform.compose.withPythonComposer import python.multiplatform.ffi.PyObject import python.multiplatform.ffi.Python3 import python.multiplatform.ffi.types.basic.PyInt @@ -53,8 +53,9 @@ import python.native.ffi.PyLong_AsLongLong * * ### The composer, and the receiver, cross the same way [PythonComposition]'s does * - * [currentComposer] is rooted and pushed onto `pythonx`'s composer stack (`pythonx.push_composer`) for - * the duration of the call, so [factory] may itself call a bound composable (the same contract + * The composer is rooted and pushed onto the binding layer's composer stack for the duration of the + * call by `python-multiplatform-compose`'s `withPythonComposer` -- the same helper [PythonComposition] + * uses -- so [factory] may itself call a bound composable (the same contract * [PythonComposition] documents). `this` -- the `Modifier` chain [factory] receives as its receiver, * matching `@Composable Modifier.() -> Modifier` -- is rooted too and passed as a bare handle * (`int`), which `PythonxAdapter._coerce`'s OBJECT branch already accepts as a caller-selected @@ -76,11 +77,11 @@ fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.c slot.invocations++ ComposedInvocationSlot.lastCreated = slot - val composer = currentComposer - val composerRef = HandleTable.register(composer) - val receiverRef = HandleTable.register(this) - try { - Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.push_composer(${composerRef.raw})") + val receiver = this + // The receiver is rooted *inside* the composer call: Compose forbids a try/finally around a + // composable invocation, so the release cannot wrap `withPythonComposer` from outside. + withPythonComposer { + val receiverRef = HandleTable.register(receiver) try { val receiverHandle = PyInt.from(receiverRef.raw) try { @@ -101,7 +102,7 @@ fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.c } else { Python3.withPython { PyLong_AsLongLong(result.pointer) } } - (HandleTable.resolveRaw(raw) as? Modifier) ?: this + (HandleTable.resolveRaw(raw) as? Modifier) ?: receiver } finally { result.close() } @@ -109,11 +110,8 @@ fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.c receiverHandle.close() } } finally { - Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.pop_composer()") + HandleTable.release(receiverRef) } - } finally { - HandleTable.release(receiverRef) - HandleTable.release(composerRef) } } diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt index bb6ab978f..28ce7a08e 100644 --- a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt @@ -1,19 +1,25 @@ package fixture.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.currentComposer import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import python.multiplatform.compose.PythonCallableArena +import python.multiplatform.compose.withPythonComposer import python.multiplatform.ffi.Python3 -import python.multiplatform.ffi.pythonx.PythonCallableScope import python.multiplatform.ffi.pythonx.PythonCallables -import python.multiplatform.reflection.HandleTable /** * **The one hand-written `@Composable` in the whole design**, and the only thing Python cannot do * for itself. * + * ### What moved out of here + * + * The composer plumbing and the callable holder are no longer this fixture's: they are + * `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host + * entry point `PythonContent` is built on too. What stays is executing a *source string* per pass, + * which is a test harness rather than an entry point -- a host draws a Python-declared root with + * `PythonContent` (`PythonContentRenderTest`). + * * ### Why exactly one, and not one per component * * The 2024 `pythonx-compose` wrote a Kotlin wrapper per widget -- 37 files, 28 of them empty -- @@ -51,81 +57,49 @@ fun PythonComposition(source: String) { // `remember`, and this is the only reason the entry point needs one. A Python `content=lambda:` // has to outlive the call that passed it -- Compose stores it in the slot table -- so something // must hold a Python reference for it, and the only thing whose lifetime *is* the composition's - // is a remembered value. See [PythonCallableArena]. - val arena = remember { PythonCallableArena() } - val composer = currentComposer - val reference = HandleTable.register(composer) - try { + // is a remembered value. See `python.multiplatform.compose.PythonCallableArena`. + val arena = remember { CountingCallableArena() } + withPythonComposer { PythonCallables.withScope(arena.scope) { - Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.push_composer(${reference.raw})") - try { - Python3.exec(source) - } finally { - Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.pop_composer()") - } + Python3.exec(source) } - } finally { - HandleTable.release(reference) } } /** - * Who holds a Python callable, and the one hook that says when to let go. - * - * `docs/archive/pythonx-adapter-design.md` §6 item 1: *"When Compose drops the slot, does anything tell the - * Kotlin holder? `RememberObserver.onForgotten` is the only hook that reports it… Item 1 is the one - * that fails silently and should be tested first."* This is that hook, wired to the one thing that - * can act on it. - * - * ### Why the composition and not something shorter or longer + * The library's [PythonCallableArena] (`python-multiplatform-compose`), counted. * - * | candidate holder | what it gets wrong | - * |---|---| - * | the composition **pass** | ends when `PythonComposition` returns, and Compose calls a stored `content` on every later recomposition | - * | the `PyObject`'s own cleaner | fires whenever the collector reaches the Kotlin wrapper, which is not a time and is not every platform | - * | `HandleTable` alone | a strong root nothing gives back: the handle goes into a slot and is never handed to Python, so there is no `__del__` to release it | - * | **this** | `onRemembered` … `onForgotten` is exactly the interval in which Compose may call the content | + * The holder itself -- why the composition, why `onForgotten`, why `onAbandoned` too -- is + * documented where it now lives. What stays here is test-visible counters, because "the arena never + * held anything" and "the arena held it and gave it back" produce the same reference count and must + * not produce the same verdict. * - * ### Both ends, because a leak test alone would pass a double release - * - * [PythonCallableScope.close] reports how many callables it actually released and answers `0` on - * every later call, and [released] accumulates that -- so a test can assert that the reference count - * came back *and* that nothing released it twice. `agent-rules` §14 is why those are two assertions: - * a reference dropped twice does not fail where it happens, it corrupts a free list and surfaces - * somewhere unrelated. - * - * `onAbandoned` closes the scope too. It is the case where the composition that created this was - * discarded before it was ever applied, so nothing will ever call `onForgotten`; the two are mutually - * exclusive by Compose's contract, and [close] being idempotent means it does not matter here if - * that contract is ever weakened. + * [released] accumulates what `PythonCallableScope.close` *reported* rather than counting calls to + * it, which is what makes a double release visible: a second close answers `0`, so a total higher + * than the number of callables that crossed can only come from releasing something twice. */ -class PythonCallableArena : RememberObserver { - - val scope: PythonCallableScope = PythonCallables.newScope() +class CountingCallableArena : PythonCallableArena() { override fun onRemembered() { + super.onRemembered() created++ latest = this } override fun onForgotten() { forgotten++ - released += scope.close() + val before = super.released + super.onForgotten() + Companion.released += super.released - before } override fun onAbandoned() { abandoned++ - released += scope.close() + val before = super.released + super.onAbandoned() + Companion.released += super.released - before } - /** - * Test-visible counters, because "the arena never held anything" and "the arena held it and gave - * it back" produce the same reference count and must not produce the same verdict. - * - * [released] accumulates what [PythonCallableScope.close] *reported* rather than counting calls - * to it, which is what makes a double release visible: a second close answers `0`, so a total - * higher than the number of callables that crossed can only come from releasing something twice. - */ companion object { var created: Int = 0 var forgotten: Int = 0 @@ -133,16 +107,13 @@ class PythonCallableArena : RememberObserver { var released: Int = 0 /** - * The most recently remembered arena, so a test can read [PythonCallableScope.liveCount] - * **while the composition is still alive**. - * - * The counters above can only be read after disposal, and "how much did this composition - * accumulate across its recompositions" is a question about the interval before it -- - * `RecompositionAccumulationTest` is the whole reason this exists. Set in [onRemembered] - * rather than in the constructor because an arena that was built and then abandoned by a - * discarded composition never becomes the one a render is measuring. + * The most recently remembered arena, so a test can read `PythonCallableScope.liveCount` + * **while the composition is still alive** -- `RecompositionAccumulationTest` is the whole + * reason this exists. Set in [onRemembered] rather than in the constructor because an arena + * that was built and then abandoned by a discarded composition never becomes the one a render + * is measuring. */ - var latest: PythonCallableArena? = null + var latest: CountingCallableArena? = null fun resetCounters() { created = 0 diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt new file mode 100644 index 000000000..1f432727c --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt @@ -0,0 +1,24 @@ +package fixture.compose + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import python.multiplatform.ffi.PyObject + +/** + * The Python side of `PythonContentRenderTest`: a Compose [MutableState] that Python creates, holds + * and writes, standing in for what a Pythonic package (pythonx-compose's `@app`) does with its own. + * + * Bound by KSP as plain top-level functions (`fixture.compose.rootState`, `fixture.compose.writeRoot`) + * because the artefact walker binds static functions only, and `MutableState.value`'s setter is an + * instance method. What the entry point under test reads is the [MutableState] itself; how a package + * writes into it is that package's business, so this is the smallest writer there is. + * + * `root` is declared [PyObject] -- not `Any` -- so a Python function crosses as itself rather than + * being unwrapped as a Kotlin handle (`PythonProxySource.argValues`'s one exception). + */ +fun rootState(): MutableState = mutableStateOf(null) + +/** `state.value = root`, called from Python. An ordinary snapshot write; nothing else. */ +fun writeRoot(state: MutableState, root: PyObject) { + state.value = root +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt index 9266fb005..204189204 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt @@ -370,7 +370,7 @@ class ComposableRenderTest { """.trimIndent(), ) - PythonCallableArena.resetCounters() + CountingCallableArena.resetCounters() val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { PythonComposition( """ @@ -393,15 +393,15 @@ class ComposableRenderTest { println( "callable lifetime: refcount base=$base held=$held disposed=$after; " + - "arenas created=${PythonCallableArena.created} forgotten=${PythonCallableArena.forgotten} " + - "released=${PythonCallableArena.released}", + "arenas created=${CountingCallableArena.created} forgotten=${CountingCallableArena.forgotten} " + + "released=${CountingCallableArena.released}", ) - assertEquals(1, PythonCallableArena.created, "the arena was never remembered") + assertEquals(1, CountingCallableArena.created, "the arena was never remembered") assertTrue(held > base, "nothing held a reference for the composition: $base -> $held") - assertEquals(1, PythonCallableArena.forgotten, "onForgotten never fired, so nothing could release") + assertEquals(1, CountingCallableArena.forgotten, "onForgotten never fired, so nothing could release") // Not "at least one": `close` reports `0` for every call after the first, so a total above // the number of callables that crossed is the double-release this cannot be allowed to pass. - assertEquals(1, PythonCallableArena.released, "callables released, expected exactly the one that crossed") + assertEquals(1, CountingCallableArena.released, "callables released, expected exactly the one that crossed") assertEquals(base, after, "the composition did not give the Python reference back") } @@ -445,7 +445,7 @@ class ComposableRenderTest { """.trimIndent(), ) - PythonCallableArena.resetCounters() + CountingCallableArena.resetCounters() val body = mutableStateOf(columnCalling("_first")) val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { PythonComposition(body.value) @@ -458,8 +458,8 @@ class ComposableRenderTest { Snapshot.sendApplyNotifications() secondInk = inkOfImage(scene.render()) Python3.exec("_held_first = sys.getrefcount(_first)") - assertEquals(1, PythonCallableArena.created, "the arena was rebuilt instead of remembered") - assertEquals(0, PythonCallableArena.released, "something released a callable before disposal") + assertEquals(1, CountingCallableArena.created, "the arena was rebuilt instead of remembered") + assertEquals(0, CountingCallableArena.released, "something released a callable before disposal") } finally { scene.close() } @@ -468,14 +468,14 @@ class ComposableRenderTest { println( "recomposition: ink $firstInk -> $secondInk; _first refcount " + "${pyInt("_base_first")} held=${pyInt("_held_first")} disposed=${pyInt("_after_first")}; " + - "released=${PythonCallableArena.released}", + "released=${CountingCallableArena.released}", ) assertTrue(secondInk > firstInk, "the second pass drew the first pass's content: $firstInk vs $secondInk") assertTrue( pyInt("_held_first") > pyInt("_base_first"), "the first pass's callable was not still held during the second", ) - assertEquals(2, PythonCallableArena.released, "expected both passes' callables to be released once each") + assertEquals(2, CountingCallableArena.released, "expected both passes' callables to be released once each") assertEquals(pyInt("_base_first"), pyInt("_after_first"), "the first pass's callable never came back") assertEquals(pyInt("_base_second"), pyInt("_after_second"), "the second pass's callable never came back") } diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt new file mode 100644 index 000000000..567e6a208 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt @@ -0,0 +1,226 @@ +package fixture.compose + +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.compose.PythonContent +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallables +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.assertNotEquals +import kotlin.test.assertTrue + +/** + * **The host entry point** (python-multiplatform#18, for pythonx-compose#11's `@app`): the host says + * *where* the Python root state lives, and from then on Python alone decides what is on screen. + * + * The root lives in a Compose `MutableState` that Python created and holds ([rootState]); Python + * replaces the root by writing into it ([writeRoot]). The claim is that this write is *all* it takes: + * the next frame shows the new root, and nothing on the Kotlin side was called to make it so. The + * only thing the test does between the write and the frame is what a window's frame clock does on + * its own -- deliver the snapshot's apply notification -- and the per-root call counters show that the + * host's `PythonContent` was not re-entered by anything but that. + * + * The lifetime half follows `ComposableRenderTest.aDisposedCompositionGivesEveryPythonCallableBack`: + * a named Python function crosses as a `content=` slot, its `sys.getrefcount` rises while composed, + * and comes back to its baseline exactly -- above means a leak, below means a double release. + */ +class PythonContentRenderTest { + + @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) + Python3.exec(ROOTS) + } + + @AfterTest + fun cleanup() { + Python3.exec("del _root_state") + UpcallTable.clear() + } + + /** + * Declare, render, replace by a state write, render, replace back, render, dispose. + * + * Reached through `PythonContent(module, attribute)`, the spelling a host that knows only a module + * name uses. + */ + @Test + fun aRootWrittenFromPythonIsDrawnOnTheNextFrameWithoutAHostCall() { + Python3.exec("writeRoot(_root_state, _root_a)") + val baseRoots = HandleTable.liveCount + val baseInner = pyInt("sys.getrefcount(_inner)") + + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(module = "__main__", attribute = "_root_state") + } + val inkA: Int + val inkB: Int + val inkABack: Int + try { + inkA = inkOfImage(scene.render()) + assertEquals(1, pyInt("_calls['a']"), "the declared root was not composed exactly once") + assertEquals(0, pyInt("_calls['b']"), "a root that was never declared was composed") + assertTrue(inkA > 0, "the declared root drew nothing") + + // A frame with no write is not a reason to run the root again: nothing here polls. + scene.render() + assertEquals(1, pyInt("_calls['a']"), "an idle frame re-ran the root, so something polls") + + // Python replaces the root. Nothing on the Kotlin side is called. + Python3.exec("writeRoot(_root_state, _root_b)") + inkB = inkOfImage(nextFrame(scene)) + assertEquals(1, pyInt("_calls['b']"), "the root Python wrote was not composed on the next frame") + assertEquals(1, pyInt("_calls['a']"), "the replaced root was composed again") + assertNotEquals(inkA, inkB, "the frame after the write drew the old root's content") + val heldInner = pyInt("sys.getrefcount(_inner)") + assertTrue(heldInner > baseInner, "nothing held _inner while root B was composed: $baseInner -> $heldInner") + + // And back. + Python3.exec("writeRoot(_root_state, _root_a)") + inkABack = inkOfImage(nextFrame(scene)) + assertEquals(2, pyInt("_calls['a']"), "switching back did not compose root A again") + assertEquals(1, pyInt("_calls['b']"), "root B ran after it was replaced") + assertEquals(inkA, inkABack, "switching back did not draw root A's content") + // Root B's `content=` callable belongs to root B's composition, which just left. + assertEquals( + baseInner, pyInt("sys.getrefcount(_inner)"), + "root B's content callable was not given back when root B was replaced", + ) + } finally { + scene.close() + } + println("python content: ink A=$inkA B=$inkB A again=$inkABack; calls=${pyStr("_calls")}") + + assertEquals(baseInner, pyInt("sys.getrefcount(_inner)"), "a Python reference outlived the composition") + assertEquals(0, HandleTable.liveCount - baseRoots, "handles left rooted after the composition was disposed") + assertEquals(0, PythonCallables.openScopeCount, "a callable scope was left open") + } + + /** + * The other spelling: the host already holds the Python object the state lives in. Disposal is + * where root B's `content=` callable comes back here, since the root is never replaced. + */ + @Test + fun theHostCanPassThePythonStateObjectAndDisposalReleasesWhatTheRootPassed() { + Python3.exec("writeRoot(_root_state, _root_b)") + val baseRoots = HandleTable.liveCount + val baseInner = pyInt("sys.getrefcount(_inner)") + val state = Python3.import("__main__").getAttr("_root_state") + + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(root = state) + } + val ink: Int + val held: Int + try { + ink = inkOfImage(scene.render()) + held = pyInt("sys.getrefcount(_inner)") + } finally { + scene.close() + } + state.close() + val after = pyInt("sys.getrefcount(_inner)") + println("python content (state object): ink=$ink _inner refcount base=$baseInner held=$held disposed=$after") + + assertEquals(1, pyInt("_calls['b']"), "the root in the state was not composed") + assertTrue(ink > 0, "the root in the state drew nothing") + assertTrue(held > baseInner, "nothing held _inner for the composition: $baseInner -> $held") + assertEquals(baseInner, after, "the composition did not give _inner back") + assertEquals(0, HandleTable.liveCount - baseRoots, "handles left rooted after the composition was disposed") + assertEquals(0, PythonCallables.openScopeCount, "a callable scope was left open") + } + + /** A Python callable passed directly is a root that never changes -- no state involved. */ + @Test + fun aPlainPythonCallableIsAFixedRoot() { + val root = Python3.import("__main__").getAttr("_root_a") + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(root = root) + } + val ink: Int + try { + ink = inkOfImage(scene.render()) + } finally { + scene.close() + } + root.close() + assertEquals(1, pyInt("_calls['a']"), "the callable root was not composed exactly once") + assertTrue(ink > 0, "the callable root drew nothing") + } + + /** + * What a window's frame clock does by itself: deliver the global snapshot's pending writes to the + * recomposer, then draw. `ImageComposeScene` has no clock, so the test is the clock. + */ + private fun nextFrame(scene: ImageComposeScene): Image { + Snapshot.sendApplyNotifications() + return scene.render() + } + + private fun inkOfImage(image: Image): Int { + val bitmap = Bitmap.makeFromImage(image) + 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 + } + + private fun pyInt(expression: String): Int = pyStr(expression).toInt() + + 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 + + /** + * Two roots that draw visibly different amounts of ink, and count their own invocations so a + * frame can be attributed to the root that drew it. Root B passes a named function as a + * `content=` slot so its reference count can be followed across the composition's lifetime. + */ + val ROOTS = """ + import sys + from fixture.compose import rootState, writeRoot + from androidx.compose.foundation.layout import Column + from androidx.compose.material3 import Text + + _calls = {'a': 0, 'b': 0} + + def _root_a(): + _calls['a'] += 1 + Text('a') + + def _inner(): + Text('BBBBBBBBBBBBBBBB') + + def _root_b(): + _calls['b'] += 1 + Column(content=_inner) + + _root_state = rootState() + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt index ba8535e8a..5830a896b 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt @@ -322,7 +322,7 @@ class RecompositionAccumulationTest { """.trimIndent(), ) - PythonCallableArena.resetCounters() + CountingCallableArena.resetCounters() val body = mutableStateOf(nested(0)) val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { PythonComposition(body.value) @@ -332,7 +332,7 @@ class RecompositionAccumulationTest { try { var previouslyServed = 0 scene.render() - val arena = PythonCallableArena.latest ?: error("no arena was remembered") + val arena = CountingCallableArena.latest ?: error("no arena was remembered") servedPerPass += arena.scope.liveCount + arena.scope.reuseCount innerCallsPerPass += pyInt("_acc_inner_calls[0]") previouslyServed = servedPerPass[0] @@ -367,7 +367,7 @@ class RecompositionAccumulationTest { scene.close() } assertEquals( - 2, PythonCallableArena.released, + 2, CountingCallableArena.released, "both wrappers, released once each -- the inner one is still Compose's at the end", ) } @@ -411,7 +411,7 @@ class RecompositionAccumulationTest { * @param watched the name of a Python global whose refcount to follow, or `null`. */ private fun measure(watched: String?, source: (Int) -> String): Accumulation { - PythonCallableArena.resetCounters() + CountingCallableArena.resetCounters() val baseRoots = HandleTable.liveCount val baseRefs = if (watched == null) 0 else pyInt("sys.getrefcount($watched)") @@ -431,8 +431,8 @@ class RecompositionAccumulationTest { Snapshot.sendApplyNotifications() ink += inkOfImage(scene.render()) } - assertEquals(1, PythonCallableArena.created, "the arena was rebuilt instead of remembered") - val arena = PythonCallableArena.latest ?: error("no arena was remembered") + assertEquals(1, CountingCallableArena.created, "the arena was rebuilt instead of remembered") + val arena = CountingCallableArena.latest ?: error("no arena was remembered") wrappers = arena.scope.liveCount reuses = arena.scope.reuseCount rootDelta = HandleTable.liveCount - baseRoots @@ -446,7 +446,7 @@ class RecompositionAccumulationTest { rootDelta = rootDelta, heldRefs = heldRefs, inkPerPass = ink, - releasedAtDisposal = PythonCallableArena.released, + releasedAtDisposal = CountingCallableArena.released, rootsAfterDisposal = HandleTable.liveCount - baseRoots, refsAfterDisposal = if (watched == null) 0 else pyInt("sys.getrefcount($watched)") - baseRefs, ) diff --git a/python-multiplatform-compose/build.gradle.kts b/python-multiplatform-compose/build.gradle.kts new file mode 100644 index 000000000..bee7b9502 --- /dev/null +++ b/python-multiplatform-compose/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * The Compose host for python-multiplatform: the `@Composable` entry point that draws a Python-declared + * root (`PythonContent`) and the composer/callable plumbing it shares with every Python call into a + * composable. + * + * A separate module so that `:python-multiplatform` itself never depends on Compose (same naming as + * `-ksp` and `-gradle-plugin`). JVM targets first -- desktop and Android share the code, which is all + * in `commonMain`; iOS and wasm come later. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.jetpack.compose) + alias(libs.plugins.compose.compiler) +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + commonMain.dependencies { + api(projects.pythonMultiplatform) + api(compose.runtime) + } + } +} + +android { + namespace = "python.multiplatform.compose" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt new file mode 100644 index 000000000..63b545ba3 --- /dev/null +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt @@ -0,0 +1,51 @@ +package python.multiplatform.compose + +import androidx.compose.runtime.RememberObserver +import python.multiplatform.ffi.pythonx.PythonCallableScope +import python.multiplatform.ffi.pythonx.PythonCallables + +/** + * Who holds the Python callables a composition passes into Kotlin (`content=lambda: ...`), and the one + * hook that says when to let go of them. + * + * A Python callable given to a composable has to outlive the call that passed it -- Compose stores it + * in the slot table and calls it on later recompositions -- so something must hold a Python + * reference for it. The only thing whose lifetime *is* the composition's is a remembered value, and + * [RememberObserver.onForgotten] is the only hook that reports Compose dropping it. + * + * | candidate holder | what it gets wrong | + * |---|---| + * | the composition **pass** | ends when the pass returns, and Compose calls a stored `content` on every later recomposition | + * | the `PyObject`'s own cleaner | fires whenever the collector reaches the Kotlin wrapper, which is not a time and is not every platform | + * | `HandleTable` alone | a strong root nothing gives back | + * | **this** | `onRemembered` ... `onForgotten` is exactly the interval in which Compose may call the content | + * + * Use it as `remember { PythonCallableArena() }` and run the Python pass inside + * `PythonCallables.withScope(arena.scope) { ... }`. + * + * `onAbandoned` closes the scope too: it is the case where the composition that created this was + * discarded before it was applied, so `onForgotten` will never come. [PythonCallableScope.close] is + * idempotent and answers how many callables it actually released (`0` on every later call), and + * [released] accumulates that -- so a total higher than what crossed can only be a double release. + * + * Open so that a test harness can count the lifecycle; subclasses must call `super`. + */ +open class PythonCallableArena : RememberObserver { + + /** The scope Python callables crossing during this arena's passes are registered in. */ + val scope: PythonCallableScope = PythonCallables.newScope() + + /** What [PythonCallableScope.close] reported, summed over every close. */ + var released: Int = 0 + private set + + override fun onRemembered() {} + + override fun onForgotten() { + released += scope.close() + } + + override fun onAbandoned() { + released += scope.close() + } +} diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt new file mode 100644 index 000000000..b77243e49 --- /dev/null +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt @@ -0,0 +1,40 @@ +package python.multiplatform.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentComposer +import python.multiplatform.ffi.Python3 +import python.multiplatform.reflection.HandleTable + +/** + * Runs [block] -- Python code that may call bound `@Composable`s -- with this composition's composer + * made available to the binding layer. + * + * `$composer` is not a value but a *position*: `currentComposer` resolves to the composer parameter of + * the enclosing composable, so the only way to obtain one is to be inside a composition. Everything + * else about calling a composable from Python (which arguments were written, the `$default` mask, + * `$changed`) is arithmetic the binding layer does in Python (`python_multiplatform.binding`'s + * `_bind_composable`), which is why this is the one thing Kotlin has to supply. + * + * The composer crosses as an ordinary `TypeTag.OBJECT` handle, pushed onto the binding layer's + * composer stack for exactly the duration of [block] and released after it, so its lifetime is this + * call's: `push_composer` retains nothing. + * + * [block] is not `@Composable`: it composes through Python, not through Kotlin call sites. + */ +@Composable +fun withPythonComposer(block: () -> T): T { + val reference = HandleTable.register(currentComposer) + try { + Python3.exec("import $BINDING_MODULE as _pm_binding\n_pm_binding.push_composer(${reference.raw})") + try { + return block() + } finally { + Python3.exec("import $BINDING_MODULE as _pm_binding\n_pm_binding.pop_composer()") + } + } finally { + HandleTable.release(reference) + } +} + +/** The binder's own binding layer (`KotlinSurface`'s `BINDING_MODULE`). Not a library name. */ +private const val BINDING_MODULE = "python_multiplatform.binding" diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt new file mode 100644 index 000000000..8de24e3e6 --- /dev/null +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt @@ -0,0 +1,149 @@ +package python.multiplatform.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.State +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallables +import python.multiplatform.reflection.HandleTable +import python.native.ffi.PyLong_AsLongLong + +/** + * Composes the Python application root found at [root], and recomposes whenever Python replaces it. + * + * [root] is one of: + * + * - **a Compose [State] that Python holds** -- the Python proxy of a Kotlin `State` / `MutableState` + * (anything carrying the binder's `_pm_handle` for one). Its `value` is the root: a Python callable + * taking no arguments, or `None`/`null` for "nothing yet". The value is read *inside* the + * composition, so Compose subscribes to it, and Python replacing the root is an ordinary state write + * (`state.value = Root`): the next frame composes the new root and nothing on the Kotlin side is + * called to make that happen. + * - **a Python callable** -- a root that never changes. + * + * The root is called with this composition's composer available to the binding layer + * ([withPythonComposer]), so it may call any bound `@Composable`. Python callables it passes into + * composables (`content=lambda: ...`) are held for as long as *that root* is composed + * ([PythonCallableArena]): replacing the root, or disposing the composition, gives them back. + * Replacing the root also discards the old root's composition state (its `remember`ed values), as + * calling a different composable would -- a redeclared root is a new function. + * + * The binder names no library here: what the root state is called, and which package declares it, + * is the host's and that package's business. + * + * [root] is the caller's: it is neither consumed nor closed. + */ +@Composable +fun PythonContent(root: PyObject) { + val source = remember(root) { RootSource.of(root) } + val current = source.current() + if (current != null) { + key(current) { + PythonRoot(current) + } + } +} + +/** + * [PythonContent] for a root state that lives at `module.attribute` -- `PythonContent("main", "app")` + * for a Python `main.app`. + * + * The attribute is resolved **once**, when this enters the composition (and again only if [module] + * or [attribute] change). Rebinding the module attribute afterwards is not observed; replacing the + * root is a write into the state the attribute names, which is. + */ +@Composable +fun PythonContent(module: String, attribute: String) { + val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) } + PythonContent(resolved.value) +} + +/** + * One root, composed. Its own composable so that the root's composition -- and the arena holding the + * Python callables it passed -- is keyed by the root and leaves when the root is replaced. + * + * A Python read of a Compose state during the call is recorded against this function's recompose + * scope like any other read, so a root that reads `state.value` is called again when that changes. + */ +@Composable +private fun PythonRoot(root: PyObject) { + val arena = remember { PythonCallableArena() } + // The composer call outermost: Compose forbids a try/finally around a composable invocation, + // and `withScope`'s is one once inlined. + withPythonComposer { + PythonCallables.withScope(arena.scope) { + root().close() + } + } +} + +/** Where the current root comes from. */ +private sealed class RootSource { + + /** The root as of this read. Called during composition, which is what subscribes to a [State]. */ + abstract fun current(): PyObject? + + class Fixed(private val root: PyObject) : RootSource() { + override fun current(): PyObject = root + } + + class FromState(private val state: State<*>) : RootSource() { + override fun current(): PyObject? = when (val value = state.value) { + null -> null + is PyObject -> value + else -> throw IllegalStateException( + "the Python root state holds a ${value::class.simpleName}, not a Python callable", + ) + } + } + + companion object { + fun of(root: PyObject): RootSource { + val handle = root.getAttrOrNull(HANDLE_ATTRIBUTE) + if (handle != null) { + val raw = try { + Python3.withPython { PyLong_AsLongLong(handle.pointer) } + } finally { + handle.close() + } + return when (val kotlin = HandleTable.resolveRaw(raw)) { + is State<*> -> FromState(kotlin) + null -> throw IllegalArgumentException("the Python root refers to a released Kotlin object") + else -> throw IllegalArgumentException( + "the Python root is a ${kotlin::class.simpleName}; expected a Compose State or a Python callable", + ) + } + } + require(root.isCallable) { "the Python root is neither a Compose State nor a Python callable" } + return Fixed(root) + } + + /** The attribute the binder's proxy of a Kotlin object carries its handle under. */ + private const val HANDLE_ATTRIBUTE = "_pm_handle" + } +} + +/** `module.attribute`, held for as long as the composition remembers it. */ +private class ResolvedAttribute(module: String, attribute: String) : RememberObserver { + + val value: PyObject = Python3.import(module).let { imported -> + try { + imported.getAttr(attribute) + } finally { + imported.close() + } + } + + override fun onRemembered() {} + + override fun onForgotten() { + value.close() + } + + override fun onAbandoned() { + value.close() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2897be091..4e19f85ff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,6 +41,9 @@ rootProject.name = "PythonMultiplatformMobile" include(":sample") include(":python-multiplatform") include(":python-multiplatform-ksp") +// The Compose host: the `@Composable` that draws a Python-declared root. Its own module so that +// `:python-multiplatform` never depends on Compose. +include(":python-multiplatform-compose") include(":ksp-fixtures:library") include(":ksp-fixtures:app") // The fixture that carries an Android plugin. `:library` and `:app` apply none, which is why the