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
15 changes: 15 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`.

---

Expand Down
1 change: 1 addition & 0 deletions ksp-fixtures/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ kotlin {
}
val desktopMain by getting {
dependencies {
implementation(projects.pythonMultiplatformCompose)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -101,19 +102,16 @@ 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()
}
} finally {
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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 --
Expand Down Expand Up @@ -51,98 +57,63 @@ 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
var abandoned: Int = 0
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PyObject?> = mutableStateOf(null)

/** `state.value = root`, called from Python. An ordinary snapshot write; nothing else. */
fun writeRoot(state: MutableState<PyObject?>, root: PyObject) {
state.value = root
}
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ class ComposableRenderTest {
""".trimIndent(),
)

PythonCallableArena.resetCounters()
CountingCallableArena.resetCounters()
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
PythonComposition(
"""
Expand All @@ -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")
}

Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
Expand All @@ -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")
}
Expand Down
Loading
Loading