From 9e61d11d58d6b1d1f70c636075492ddb02c4f73f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:43:38 +0900 Subject: [PATCH] Feat: A Compose host entry point that draws a Python-declared root python-multiplatform-compose (new module; the library itself stays free of Compose) adds PythonContent(root) / PythonContent(module, attribute): it reads a Python-held State inside the composition and composes its value, a Python callable, so a Python write that replaces the root recomposes on the next frame with no host call. Each root has its own callable arena, released when the root is replaced or the composition disposed. withPythonComposer and PythonCallableArena are promoted from the compose fixtures, which now use them. No library name is known to the binder. --- docs/SPEC.md | 15 ++ ksp-fixtures/compose/build.gradle.kts | 1 + .../kotlin/fixture/compose/PythonComposed.kt | 24 +- .../fixture/compose/PythonComposition.kt | 101 +++----- .../kotlin/fixture/compose/PythonRootState.kt | 24 ++ .../fixture/compose/ComposableRenderTest.kt | 22 +- .../compose/PythonContentRenderTest.kt | 226 ++++++++++++++++++ .../compose/RecompositionAccumulationTest.kt | 14 +- python-multiplatform-compose/build.gradle.kts | 52 ++++ .../compose/PythonCallableArena.kt | 51 ++++ .../multiplatform/compose/PythonComposer.kt | 40 ++++ .../multiplatform/compose/PythonContent.kt | 149 ++++++++++++ settings.gradle.kts | 3 + 13 files changed, 626 insertions(+), 96 deletions(-) create mode 100644 ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonRootState.kt create mode 100644 ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt create mode 100644 python-multiplatform-compose/build.gradle.kts create mode 100644 python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt create mode 100644 python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt create mode 100644 python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 539a84a03..3695620c0 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -204,6 +204,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). It emits nothing under `pythonx` and renames nothing; a Pythonic stub product belongs to pythonx-compose. @@ -239,6 +248,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 6e52aeba9..1c27a5161 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 c836b6ae6..27a771749 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 94f3023f0..6fd3a66a7 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/design/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 298768ecb..008f7f274 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 ac63a8885..55bc47664 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