Skip to content

Commit eb45de8

Browse files
authored
Merge pull request #48 from thisisthepy/work/compose-host
Feat: A Compose host entry point that draws a Python-declared root
2 parents 86012ca + b2f5e20 commit eb45de8

13 files changed

Lines changed: 626 additions & 96 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
211211
`ksp-fixtures/compose/src/desktopTest/` (`ComposableRenderTest.kt`, `M3ProofRenderTest.kt`,
212212
`CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`;
213213
`planned` on Android, iOS and wasm.
214+
- **B-8** A host draws a Python-declared application root with
215+
`python.multiplatform.compose.PythonContent(root: PyObject)` or `PythonContent(module, attribute)`,
216+
from the `python-multiplatform-compose` module (`python-multiplatform` itself does not depend on
217+
Compose). The root is a Python callable, or a Compose `State` that Python holds whose value is that
218+
callable. The state is read inside the composition, so a Python write into it replaces the root on
219+
the next frame with no host call; Python callables a root passed into composables are released when
220+
that root is replaced or the composition is disposed. The entry point names no library.
221+
`Status: implemented` on desktop — `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module
222+
compiles for Android, nothing runs there yet.
214223
- **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names
215224
(keyword parameters by Kotlin name, `= ...` for a Kotlin default, receiver positional-only). Types are
216225
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.
258267
- **N-3** Binding Kotlin/Native klib declarations at run time (B-2). `Status: planned`.
259268
- **N-4** Linux and Windows desktop runs in CI (P-2). `Status: planned`.
260269
- **N-5** Native-image upcall verification as an automated test (U-6). `Status: planned`.
270+
- **N-6** Compose state created and written from Python through the binder:
271+
`androidx.compose.runtime.mutableStateOf(x)` callable, and `.value` of the returned `MutableState`
272+
readable and writable on its proxy. Neither is bound today: the walker emits only `FUNCTION` and
273+
`STATIC_GETTER` entries (no instance members), and declines every declaration whose signature
274+
mentions a type parameter. B-8's test writes its root state through a fixture-local KSP-bound
275+
setter instead. `Status: planned`.
261276

262277
---
263278

‎ksp-fixtures/compose/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ kotlin {
4545
}
4646
val desktopMain by getting {
4747
dependencies {
48+
implementation(projects.pythonMultiplatformCompose)
4849
implementation(compose.runtime)
4950
implementation(compose.foundation)
5051
implementation(compose.material3)

‎ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package fixture.compose
22

3-
import androidx.compose.runtime.currentComposer
43
import androidx.compose.runtime.remember
54
import androidx.compose.ui.Modifier
65
import androidx.compose.ui.composed
6+
import python.multiplatform.compose.withPythonComposer
77
import python.multiplatform.ffi.PyObject
88
import python.multiplatform.ffi.Python3
99
import python.multiplatform.ffi.types.basic.PyInt
@@ -53,8 +53,9 @@ import python.native.ffi.PyLong_AsLongLong
5353
*
5454
* ### The composer, and the receiver, cross the same way [PythonComposition]'s does
5555
*
56-
* [currentComposer] is rooted and pushed onto `pythonx`'s composer stack (`pythonx.push_composer`) for
57-
* the duration of the call, so [factory] may itself call a bound composable (the same contract
56+
* The composer is rooted and pushed onto the binding layer's composer stack for the duration of the
57+
* call by `python-multiplatform-compose`'s `withPythonComposer` -- the same helper [PythonComposition]
58+
* uses -- so [factory] may itself call a bound composable (the same contract
5859
* [PythonComposition] documents). `this` -- the `Modifier` chain [factory] receives as its receiver,
5960
* matching `@Composable Modifier.() -> Modifier` -- is rooted too and passed as a bare handle
6061
* (`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
7677
slot.invocations++
7778
ComposedInvocationSlot.lastCreated = slot
7879

79-
val composer = currentComposer
80-
val composerRef = HandleTable.register(composer)
81-
val receiverRef = HandleTable.register(this)
82-
try {
83-
Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.push_composer(${composerRef.raw})")
80+
val receiver = this
81+
// The receiver is rooted *inside* the composer call: Compose forbids a try/finally around a
82+
// composable invocation, so the release cannot wrap `withPythonComposer` from outside.
83+
withPythonComposer {
84+
val receiverRef = HandleTable.register(receiver)
8485
try {
8586
val receiverHandle = PyInt.from(receiverRef.raw)
8687
try {
@@ -101,19 +102,16 @@ fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.c
101102
} else {
102103
Python3.withPython { PyLong_AsLongLong(result.pointer) }
103104
}
104-
(HandleTable.resolveRaw(raw) as? Modifier) ?: this
105+
(HandleTable.resolveRaw(raw) as? Modifier) ?: receiver
105106
} finally {
106107
result.close()
107108
}
108109
} finally {
109110
receiverHandle.close()
110111
}
111112
} finally {
112-
Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.pop_composer()")
113+
HandleTable.release(receiverRef)
113114
}
114-
} finally {
115-
HandleTable.release(receiverRef)
116-
HandleTable.release(composerRef)
117115
}
118116
}
119117

‎ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt‎

Lines changed: 36 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
package fixture.compose
22

33
import androidx.compose.runtime.Composable
4-
import androidx.compose.runtime.RememberObserver
5-
import androidx.compose.runtime.currentComposer
64
import androidx.compose.runtime.remember
75
import androidx.compose.ui.Modifier
6+
import python.multiplatform.compose.PythonCallableArena
7+
import python.multiplatform.compose.withPythonComposer
88
import python.multiplatform.ffi.Python3
9-
import python.multiplatform.ffi.pythonx.PythonCallableScope
109
import python.multiplatform.ffi.pythonx.PythonCallables
11-
import python.multiplatform.reflection.HandleTable
1210

1311
/**
1412
* **The one hand-written `@Composable` in the whole design**, and the only thing Python cannot do
1513
* for itself.
1614
*
15+
* ### What moved out of here
16+
*
17+
* The composer plumbing and the callable holder are no longer this fixture's: they are
18+
* `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host
19+
* entry point `PythonContent` is built on too. What stays is executing a *source string* per pass,
20+
* which is a test harness rather than an entry point -- a host draws a Python-declared root with
21+
* `PythonContent` (`PythonContentRenderTest`).
22+
*
1723
* ### Why exactly one, and not one per component
1824
*
1925
* The 2024 `pythonx-compose` wrote a Kotlin wrapper per widget -- 37 files, 28 of them empty --
@@ -51,98 +57,63 @@ fun PythonComposition(source: String) {
5157
// `remember`, and this is the only reason the entry point needs one. A Python `content=lambda:`
5258
// has to outlive the call that passed it -- Compose stores it in the slot table -- so something
5359
// must hold a Python reference for it, and the only thing whose lifetime *is* the composition's
54-
// is a remembered value. See [PythonCallableArena].
55-
val arena = remember { PythonCallableArena() }
56-
val composer = currentComposer
57-
val reference = HandleTable.register(composer)
58-
try {
60+
// is a remembered value. See `python.multiplatform.compose.PythonCallableArena`.
61+
val arena = remember { CountingCallableArena() }
62+
withPythonComposer {
5963
PythonCallables.withScope(arena.scope) {
60-
Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.push_composer(${reference.raw})")
61-
try {
62-
Python3.exec(source)
63-
} finally {
64-
Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.pop_composer()")
65-
}
64+
Python3.exec(source)
6665
}
67-
} finally {
68-
HandleTable.release(reference)
6966
}
7067
}
7168

7269
/**
73-
* Who holds a Python callable, and the one hook that says when to let go.
74-
*
75-
* `docs/archive/pythonx-adapter-design.md` §6 item 1: *"When Compose drops the slot, does anything tell the
76-
* Kotlin holder? `RememberObserver.onForgotten` is the only hook that reports it… Item 1 is the one
77-
* that fails silently and should be tested first."* This is that hook, wired to the one thing that
78-
* can act on it.
79-
*
80-
* ### Why the composition and not something shorter or longer
70+
* The library's [PythonCallableArena] (`python-multiplatform-compose`), counted.
8171
*
82-
* | candidate holder | what it gets wrong |
83-
* |---|---|
84-
* | the composition **pass** | ends when `PythonComposition` returns, and Compose calls a stored `content` on every later recomposition |
85-
* | the `PyObject`'s own cleaner | fires whenever the collector reaches the Kotlin wrapper, which is not a time and is not every platform |
86-
* | `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 |
87-
* | **this** | `onRemembered` … `onForgotten` is exactly the interval in which Compose may call the content |
72+
* The holder itself -- why the composition, why `onForgotten`, why `onAbandoned` too -- is
73+
* documented where it now lives. What stays here is test-visible counters, because "the arena never
74+
* held anything" and "the arena held it and gave it back" produce the same reference count and must
75+
* not produce the same verdict.
8876
*
89-
* ### Both ends, because a leak test alone would pass a double release
90-
*
91-
* [PythonCallableScope.close] reports how many callables it actually released and answers `0` on
92-
* every later call, and [released] accumulates that -- so a test can assert that the reference count
93-
* came back *and* that nothing released it twice. `agent-rules` §14 is why those are two assertions:
94-
* a reference dropped twice does not fail where it happens, it corrupts a free list and surfaces
95-
* somewhere unrelated.
96-
*
97-
* `onAbandoned` closes the scope too. It is the case where the composition that created this was
98-
* discarded before it was ever applied, so nothing will ever call `onForgotten`; the two are mutually
99-
* exclusive by Compose's contract, and [close] being idempotent means it does not matter here if
100-
* that contract is ever weakened.
77+
* [released] accumulates what `PythonCallableScope.close` *reported* rather than counting calls to
78+
* it, which is what makes a double release visible: a second close answers `0`, so a total higher
79+
* than the number of callables that crossed can only come from releasing something twice.
10180
*/
102-
class PythonCallableArena : RememberObserver {
103-
104-
val scope: PythonCallableScope = PythonCallables.newScope()
81+
class CountingCallableArena : PythonCallableArena() {
10582

10683
override fun onRemembered() {
84+
super.onRemembered()
10785
created++
10886
latest = this
10987
}
11088

11189
override fun onForgotten() {
11290
forgotten++
113-
released += scope.close()
91+
val before = super.released
92+
super.onForgotten()
93+
Companion.released += super.released - before
11494
}
11595

11696
override fun onAbandoned() {
11797
abandoned++
118-
released += scope.close()
98+
val before = super.released
99+
super.onAbandoned()
100+
Companion.released += super.released - before
119101
}
120102

121-
/**
122-
* Test-visible counters, because "the arena never held anything" and "the arena held it and gave
123-
* it back" produce the same reference count and must not produce the same verdict.
124-
*
125-
* [released] accumulates what [PythonCallableScope.close] *reported* rather than counting calls
126-
* to it, which is what makes a double release visible: a second close answers `0`, so a total
127-
* higher than the number of callables that crossed can only come from releasing something twice.
128-
*/
129103
companion object {
130104
var created: Int = 0
131105
var forgotten: Int = 0
132106
var abandoned: Int = 0
133107
var released: Int = 0
134108

135109
/**
136-
* The most recently remembered arena, so a test can read [PythonCallableScope.liveCount]
137-
* **while the composition is still alive**.
138-
*
139-
* The counters above can only be read after disposal, and "how much did this composition
140-
* accumulate across its recompositions" is a question about the interval before it --
141-
* `RecompositionAccumulationTest` is the whole reason this exists. Set in [onRemembered]
142-
* rather than in the constructor because an arena that was built and then abandoned by a
143-
* discarded composition never becomes the one a render is measuring.
110+
* The most recently remembered arena, so a test can read `PythonCallableScope.liveCount`
111+
* **while the composition is still alive** -- `RecompositionAccumulationTest` is the whole
112+
* reason this exists. Set in [onRemembered] rather than in the constructor because an arena
113+
* that was built and then abandoned by a discarded composition never becomes the one a render
114+
* is measuring.
144115
*/
145-
var latest: PythonCallableArena? = null
116+
var latest: CountingCallableArena? = null
146117

147118
fun resetCounters() {
148119
created = 0
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package fixture.compose
2+
3+
import androidx.compose.runtime.MutableState
4+
import androidx.compose.runtime.mutableStateOf
5+
import python.multiplatform.ffi.PyObject
6+
7+
/**
8+
* The Python side of `PythonContentRenderTest`: a Compose [MutableState] that Python creates, holds
9+
* and writes, standing in for what a Pythonic package (pythonx-compose's `@app`) does with its own.
10+
*
11+
* Bound by KSP as plain top-level functions (`fixture.compose.rootState`, `fixture.compose.writeRoot`)
12+
* because the artefact walker binds static functions only, and `MutableState.value`'s setter is an
13+
* instance method. What the entry point under test reads is the [MutableState] itself; how a package
14+
* writes into it is that package's business, so this is the smallest writer there is.
15+
*
16+
* `root` is declared [PyObject] -- not `Any` -- so a Python function crosses as itself rather than
17+
* being unwrapped as a Kotlin handle (`PythonProxySource.argValues`'s one exception).
18+
*/
19+
fun rootState(): MutableState<PyObject?> = mutableStateOf(null)
20+
21+
/** `state.value = root`, called from Python. An ordinary snapshot write; nothing else. */
22+
fun writeRoot(state: MutableState<PyObject?>, root: PyObject) {
23+
state.value = root
24+
}

‎ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ class ComposableRenderTest {
370370
""".trimIndent(),
371371
)
372372

373-
PythonCallableArena.resetCounters()
373+
CountingCallableArena.resetCounters()
374374
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
375375
PythonComposition(
376376
"""
@@ -393,15 +393,15 @@ class ComposableRenderTest {
393393

394394
println(
395395
"callable lifetime: refcount base=$base held=$held disposed=$after; " +
396-
"arenas created=${PythonCallableArena.created} forgotten=${PythonCallableArena.forgotten} " +
397-
"released=${PythonCallableArena.released}",
396+
"arenas created=${CountingCallableArena.created} forgotten=${CountingCallableArena.forgotten} " +
397+
"released=${CountingCallableArena.released}",
398398
)
399-
assertEquals(1, PythonCallableArena.created, "the arena was never remembered")
399+
assertEquals(1, CountingCallableArena.created, "the arena was never remembered")
400400
assertTrue(held > base, "nothing held a reference for the composition: $base -> $held")
401-
assertEquals(1, PythonCallableArena.forgotten, "onForgotten never fired, so nothing could release")
401+
assertEquals(1, CountingCallableArena.forgotten, "onForgotten never fired, so nothing could release")
402402
// Not "at least one": `close` reports `0` for every call after the first, so a total above
403403
// the number of callables that crossed is the double-release this cannot be allowed to pass.
404-
assertEquals(1, PythonCallableArena.released, "callables released, expected exactly the one that crossed")
404+
assertEquals(1, CountingCallableArena.released, "callables released, expected exactly the one that crossed")
405405
assertEquals(base, after, "the composition did not give the Python reference back")
406406
}
407407

@@ -445,7 +445,7 @@ class ComposableRenderTest {
445445
""".trimIndent(),
446446
)
447447

448-
PythonCallableArena.resetCounters()
448+
CountingCallableArena.resetCounters()
449449
val body = mutableStateOf(columnCalling("_first"))
450450
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
451451
PythonComposition(body.value)
@@ -458,8 +458,8 @@ class ComposableRenderTest {
458458
Snapshot.sendApplyNotifications()
459459
secondInk = inkOfImage(scene.render())
460460
Python3.exec("_held_first = sys.getrefcount(_first)")
461-
assertEquals(1, PythonCallableArena.created, "the arena was rebuilt instead of remembered")
462-
assertEquals(0, PythonCallableArena.released, "something released a callable before disposal")
461+
assertEquals(1, CountingCallableArena.created, "the arena was rebuilt instead of remembered")
462+
assertEquals(0, CountingCallableArena.released, "something released a callable before disposal")
463463
} finally {
464464
scene.close()
465465
}
@@ -468,14 +468,14 @@ class ComposableRenderTest {
468468
println(
469469
"recomposition: ink $firstInk -> $secondInk; _first refcount " +
470470
"${pyInt("_base_first")} held=${pyInt("_held_first")} disposed=${pyInt("_after_first")}; " +
471-
"released=${PythonCallableArena.released}",
471+
"released=${CountingCallableArena.released}",
472472
)
473473
assertTrue(secondInk > firstInk, "the second pass drew the first pass's content: $firstInk vs $secondInk")
474474
assertTrue(
475475
pyInt("_held_first") > pyInt("_base_first"),
476476
"the first pass's callable was not still held during the second",
477477
)
478-
assertEquals(2, PythonCallableArena.released, "expected both passes' callables to be released once each")
478+
assertEquals(2, CountingCallableArena.released, "expected both passes' callables to be released once each")
479479
assertEquals(pyInt("_base_first"), pyInt("_after_first"), "the first pass's callable never came back")
480480
assertEquals(pyInt("_base_second"), pyInt("_after_second"), "the second pass's callable never came back")
481481
}

0 commit comments

Comments
 (0)