Skip to content

Commit 8c56f19

Browse files
authored
Merge pull request #176 from thisisthepy/feat/remember-saveable
Feat: rememberSaveableWrapper gives Python Compose's rememberSaveable
2 parents 54a3a54 + 0cfa054 commit 8c56f19

5 files changed

Lines changed: 252 additions & 0 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
400400
that root is replaced or the composition is disposed. The entry point names no library.
401401
`Status: implemented` on desktop, `ksp-fixtures/compose/.../PythonAppViewRenderTest.kt`; the module
402402
compiles for Android, nothing runs there yet.
403+
- **B-9** A Python root keeps state across the recreation of its composition with
404+
`python.multiplatform.compose.rememberSaveableWrapper(initial)` (the pycomposeui function of that name),
405+
callable from Python inside a composition. A Python `int` (32 bit) becomes `rememberSaveable { mutableIntStateOf }`,
406+
a larger one `mutableLongStateOf`, a `float` `mutableDoubleStateOf`, a `bool` or `str` `mutableStateOf`; the
407+
returned `MutableState` is read and written through `.value`. A value written from Python survives
408+
`SaveableStateRegistry.performSave()` and a new composition over a registry built from the saved map; without
409+
a registry the new composition starts at `initial`. Any other value (a Python `list`) raises an error that
410+
names its type. `Status: implemented` on desktop (#174),
411+
`ksp-fixtures/compose/.../RememberSaveableRenderTest.kt`.
403412
- **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names
404413
plus their U-12 Pythonic aliases (`fill_max_width = fillMaxWidth` beside each module `def`, a second
405414
`ClassVar` or `@property` on a receiver's class, a second member of a callable module), parameters

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ pythonBindings {
111111
// `TextFieldStateRenderTest` is what each one is for.
112112
"androidx.compose.foundation.text.input.TextFieldState",
113113
"androidx.compose.foundation.text.input.TextFieldStateKt",
114+
// 8. `rememberSaveableWrapper` (issue #174): the one function of `python-multiplatform-compose`
115+
// Python calls inside a composition. Its file facade, not the package, so that the host
116+
// composables (`PythonAppView` and friends) stay out of the table.
117+
"python.multiplatform.compose.RememberSaveableKt",
114118
),
115119
)
116120
// Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package fixture.compose
2+
3+
import androidx.compose.runtime.CompositionLocalProvider
4+
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
5+
import androidx.compose.runtime.saveable.SaveableStateRegistry
6+
import androidx.compose.runtime.snapshots.Snapshot
7+
import androidx.compose.ui.ImageComposeScene
8+
import androidx.compose.ui.unit.Density
9+
import python.multiplatform.compose.PythonWidget
10+
import python.multiplatform.ffi.Python3
11+
import python.multiplatform.ffi.pythonx.PythonxAdapter
12+
import python.multiplatform.ffi.upcall.UpcallBootstrap
13+
import python.multiplatform.generated.artifacts.ArtifactTable
14+
import python.multiplatform.reflection.UpcallTable
15+
import kotlin.test.AfterTest
16+
import kotlin.test.BeforeTest
17+
import kotlin.test.Test
18+
import kotlin.test.assertEquals
19+
import kotlin.test.assertTrue
20+
21+
/**
22+
* **`rememberSaveableWrapper` from Python** (issue #174): the original pycomposeui's
23+
* `rememberSaveableWrapper(init, type)`, as `python.multiplatform.compose.rememberSaveableWrapper(initial)`,
24+
* reached through the walk (`RememberSaveableKt`) and called inside a Python root.
25+
*
26+
* The root asks for one state per supported type (Int, Long, Double, Boolean, String), draws them, and
27+
* keeps them in `_rs_states` so the test can write `.value` from Python. The scene is hosted inside
28+
* `CompositionLocalProvider(LocalSaveableStateRegistry provides registry)`; the test writes, saves the
29+
* registry, closes the scene, builds a new registry from the saved map and a new scene over the same
30+
* root, and reads the values the new states hold.
31+
*
32+
* ### Red before the implementation
33+
*
34+
* `from python.multiplatform.compose import rememberSaveableWrapper` is an `ImportError` raised by the
35+
* Python root when the first frame composes, so each test fails there and not at an assertion on a
36+
* value, which is what tells that red from a regression.
37+
*/
38+
class RememberSaveableRenderTest {
39+
40+
@BeforeTest
41+
fun installProducers() {
42+
Python3.initialize(silent = true)
43+
UpcallTable.clear()
44+
UpcallTable.install(ArtifactTable.fragments)
45+
check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
46+
PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
47+
Python3.exec(ROOTS)
48+
}
49+
50+
@AfterTest
51+
fun cleanup() {
52+
Python3.exec(
53+
"""
54+
for _name in [n for n in globals() if n.startswith('_rs_')]:
55+
del globals()[_name]
56+
""".trimIndent(),
57+
)
58+
UpcallTable.clear()
59+
}
60+
61+
/** Each supported type: initial value, written value, saved, restored into a new scene. */
62+
@Test
63+
fun everySupportedTypeSurvivesASaveAndARestoreThroughTheRegistry() {
64+
val saved = firstScene(canBeSaved = true)
65+
println("rememberSaveableWrapper: saved ${saved.size} entries")
66+
assertTrue(saved.isNotEmpty(), "nothing was saved, so the states are not registered with the registry")
67+
68+
runScene(SaveableStateRegistry(restoredValues = saved, canBeSaved = { true }))
69+
assertEquals(WRITTEN, snapshotOfStates(), "the restored states do not hold the values saved")
70+
assertEquals(
71+
listOf("int", "int", "float", "bool", "str"),
72+
pyStr("[type(_rs_states[k].value).__name__ for k in 'ilfbs']").let { parseList(it) },
73+
)
74+
}
75+
76+
/**
77+
* The check that can fail: the same flow hosted without a registry (so nothing can be saved) shows
78+
* the initial values again.
79+
*/
80+
@Test
81+
fun withoutASaveableRegistryTheRecreatedSceneShowsTheInitialValues() {
82+
val saved = firstScene(canBeSaved = null)
83+
assertTrue(saved.isEmpty(), "a scene without a registry still saved $saved")
84+
85+
runScene(null)
86+
assertEquals(INITIAL, snapshotOfStates(), "values came back although nothing was saved")
87+
}
88+
89+
/** A value of any other type is an error that names the type. */
90+
@Test
91+
fun anUnsupportedTypeRaisesAnErrorNamingTheType() {
92+
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
93+
CompositionLocalProvider(LocalSaveableStateRegistry provides SaveableStateRegistry(null) { true }) {
94+
PythonWidget("_rs_root_bad", moduleName = "__main__")
95+
}
96+
}
97+
try {
98+
scene.render()
99+
} finally {
100+
scene.close()
101+
}
102+
val message = pyStr("_rs_error[0]")
103+
println("rememberSaveableWrapper unsupported: $message")
104+
assertTrue("list" in message, "the error does not name the Python list type: $message")
105+
}
106+
107+
private fun firstScene(canBeSaved: Boolean?): Map<String, List<Any?>> {
108+
val registry = canBeSaved?.let { SaveableStateRegistry(restoredValues = null, canBeSaved = { _ -> it }) }
109+
val scene = host(registry)
110+
try {
111+
scene.render()
112+
assertEquals(INITIAL, snapshotOfStates(), "the states did not start at the values Python passed")
113+
Python3.exec("_rs_states['i'].value = 8; _rs_states['l'].value = 6000000000; _rs_states['f'].value = 2.5; _rs_states['b'].value = False; _rs_states['s'].value = 'xyz'")
114+
Snapshot.sendApplyNotifications()
115+
scene.render()
116+
assertEquals(WRITTEN, snapshotOfStates(), "the write from Python was not read back")
117+
return registry?.performSave() ?: emptyMap()
118+
} finally {
119+
scene.close()
120+
}
121+
}
122+
123+
private fun runScene(registry: SaveableStateRegistry?) {
124+
Python3.exec("_rs_states.clear()")
125+
val scene = host(registry)
126+
try {
127+
scene.render()
128+
} finally {
129+
scene.close()
130+
}
131+
}
132+
133+
private fun host(registry: SaveableStateRegistry?) =
134+
ImageComposeScene(width = 300, height = 200, density = Density(1f)) {
135+
if (registry == null) {
136+
PythonWidget("_rs_root", moduleName = "__main__")
137+
} else {
138+
CompositionLocalProvider(LocalSaveableStateRegistry provides registry) {
139+
PythonWidget("_rs_root", moduleName = "__main__")
140+
}
141+
}
142+
}
143+
144+
private fun snapshotOfStates(): String = pyStr("[_rs_states[k].value for k in 'ilfbs']")
145+
146+
private fun parseList(text: String): List<String> =
147+
text.removePrefix("[").removeSuffix("]").split(", ").map { it.trim('\'') }
148+
149+
private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals ->
150+
Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString()
151+
}
152+
153+
private companion object {
154+
/** `Py_eval_input`. */
155+
const val PY_EVAL_INPUT = 258
156+
const val INITIAL = "[7, 5000000000, 1.5, True, 'abc']"
157+
const val WRITTEN = "[8, 6000000000, 2.5, False, 'xyz']"
158+
159+
val ROOTS = """
160+
_rs_states = {}
161+
_rs_error = []
162+
163+
def _rs_root():
164+
from python.multiplatform.compose import rememberSaveableWrapper
165+
from androidx.compose.foundation.layout import Column
166+
from androidx.compose.material3 import Text
167+
_rs_states['i'] = rememberSaveableWrapper(7)
168+
_rs_states['l'] = rememberSaveableWrapper(5000000000)
169+
_rs_states['f'] = rememberSaveableWrapper(1.5)
170+
_rs_states['b'] = rememberSaveableWrapper(True)
171+
_rs_states['s'] = rememberSaveableWrapper('abc')
172+
def _rs_body():
173+
for _k in 'ilfbs':
174+
Text(str(_rs_states[_k].value))
175+
Column(content=_rs_body)
176+
177+
def _rs_root_bad():
178+
from python.multiplatform.compose import rememberSaveableWrapper
179+
try:
180+
rememberSaveableWrapper([1, 2])
181+
except BaseException as _e:
182+
_rs_error.append(type(_e).__name__ + ': ' + str(_e))
183+
""".trimIndent()
184+
}
185+
}

‎python-multiplatform-compose/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ kotlin {
3636
api(projects.pythonMultiplatform)
3737
api(compose.runtime)
3838
api(compose.ui)
39+
api(compose.runtimeSaveable)
3940
}
4041
}
4142
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package python.multiplatform.compose
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.MutableState
5+
import androidx.compose.runtime.mutableDoubleStateOf
6+
import androidx.compose.runtime.mutableIntStateOf
7+
import androidx.compose.runtime.mutableLongStateOf
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.saveable.rememberSaveable
10+
import python.multiplatform.ffi.PyObject
11+
12+
/**
13+
* `rememberSaveable { mutableStateOf(initial) }` for a value Python holds, so that a Python screen's
14+
* state survives what `rememberSaveable` survives (recreating an activity, process death with a
15+
* saved instance state, a `SaveableStateRegistry` saved and restored).
16+
*
17+
* Follows `rememberSaveableWrapper(init, type)` of the original pycomposeui
18+
* (`Runtime.android.kt`), which dispatched on a type name Python passed along. Here the value alone
19+
* is passed: a Python scalar written into a `kotlin.Any?` slot arrives boxed, so its runtime type is
20+
* the type name.
21+
*
22+
* | Python value | Kotlin type | State created |
23+
* |---|---|---|
24+
* | `int` that fits 32 bits | `Int` | `mutableIntStateOf` |
25+
* | larger `int` | `Long` | `mutableLongStateOf` |
26+
* | `float` | `Double` | `mutableDoubleStateOf` |
27+
* | `bool` | `Boolean` | `mutableStateOf` |
28+
* | `str` | `String` | `mutableStateOf` |
29+
*
30+
* Any other value throws [IllegalArgumentException] naming its type (`list` for a Python list): like the original, only these
31+
* are supported, because only these are saveable without a custom `Saver`.
32+
*
33+
* The returned state is Compose's own: Python reads and writes its `value`, and a write is saved by
34+
* the next `SaveableStateRegistry.performSave`. Call it inside a composition only.
35+
*/
36+
@Composable
37+
fun rememberSaveableWrapper(initial: Any?): MutableState<*> = when (initial) {
38+
is Int -> rememberSaveable { mutableIntStateOf(initial) }
39+
is Long -> rememberSaveable { mutableLongStateOf(initial) }
40+
is Double -> rememberSaveable { mutableDoubleStateOf(initial) }
41+
is Boolean -> rememberSaveable { mutableStateOf(initial) }
42+
is String -> rememberSaveable { mutableStateOf(initial) }
43+
else -> throw IllegalArgumentException(
44+
"rememberSaveableWrapper supports Int, Long, Double, Boolean and String, not " + typeNameOf(initial),
45+
)
46+
}
47+
48+
/** The type of [value] as Python spells it for a Python object (`list`), as Kotlin does otherwise. */
49+
private fun typeNameOf(value: Any?): String = when (value) {
50+
null -> "null"
51+
is PyObject -> value.Type.getAttr("__name__").toString()
52+
else -> value::class.qualifiedName ?: value::class.simpleName ?: "unknown"
53+
}

0 commit comments

Comments
 (0)