Skip to content

Commit c378e1e

Browse files
committed
Feat: remember_saveable with the notebook's getValue()/setValue(), over the host's rememberSaveableWrapper
INTENT 5.9. remember_saveable(initial) calls python.multiplatform.compose.rememberSaveableWrapper(initial) (python-multiplatform #174, 8c56f19b), which owns the type rule (Int, Long, Double, Boolean, String; anything else refused). It returns SaveableState, a thin wrapper over the Kotlin MutableState with getValue(), setValue(v) and .value, every read going through MutableState.value. Outside a composition the binder raises RuntimeError; an unbound wrapper raises RuntimeError naming #174 and the artifactIncludePackages entry. The app attaches the state itself (App.messages = messages = remember_saveable("")), so notebook cells 9 to 13 work as written. The stub generator now carries a package-defined class with its public methods and properties, so the stub types SaveableState; regenerated, only the runtime stub changes. Tests written first and observed red (AttributeError/ImportError: no remember_saveable; the stub and typing checks): test_remember_saveable.py (9), one stub test, two typing tests. Counts, main checkout: 149/113 -> 152/122 without a checkout, 262 -> 274 with one; worktree baselines 151/123 and 273/1. SPEC S5.6 (partial), guide, status page (its stale baselines too), README en/ko and PROJECT follow. Closes #102
1 parent f76bc7e commit c378e1e

13 files changed

Lines changed: 390 additions & 42 deletions

File tree

‎PROJECT.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`
2828
| 상태 | 항목 |
2929
|---|---|
3030
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1 에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사), `Alignment`/`Arrangement` 상수(평면 `Alignment.End` 와 묶음 `Alignment.Horizontal.End` 모두, #9), proxy 메서드의 snake_case 이름과 키워드 인자(`m.padding(padding_values=...)`; python-multiplatform #131 이 바인더 쪽에서 제공하므로 #131 이상 필요; Kotlin 이름도 실행 시 그대로 통하고 스텁은 snake_case 만), 함수이기도 한 Kotlin object 의 호출(`Color(0xFFFFFFFF)` 과 `Color.Red` 둘 다; #88, 설치한 wheel 에서도 테스트됨. Int/Long 오버로드 선택은 python-multiplatform #146) |
31-
| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 python-multiplatform 노트북 E2E #26 에서 통과; 숫자·문자열 상태는 python-multiplatform #69 부터 왕복됨), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 노트북 E2E 에만, 설치한 wheel 에서 Kotlin 과 픽셀 일치), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨, 스텁에서는 `DefaultIcons.Add` 가 `ImageVector`; `DefaultIcons.Add` 로 쓴다; 노트북의 `DefaultIcons.Add()` 가 아님), `TextField(state=...)`·`TextFieldState`(#10; `pythonx.compose.foundation.text.input`, 가짜 호스트로 테스트됨, 노트북의 `text_state=`/`padding=` 는 `state=`/`modifier=Modifier.padding(8)`; IME 조합 증거는 python-multiplatform 노트북 E2E #26 에서 통과) |
32-
| 계획 | `remember_saveable`·색 스킴·코루틴 스코프 |
31+
| 부분 | 노트북 표기 그대로의 `remember_saveable`(`getValue()`/`setValue()`, `.value` 별칭; #102, 가짜 호스트로 테스트됨, 저장·복원 증거는 python-multiplatform #174), 선언형 앱 루트 `@app`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 python-multiplatform 노트북 E2E #26 에서 통과; 숫자·문자열 상태는 python-multiplatform #69 부터 왕복됨), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 노트북 E2E 에만, 설치한 wheel 에서 Kotlin 과 픽셀 일치), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨, 스텁에서는 `DefaultIcons.Add` 가 `ImageVector`; `DefaultIcons.Add` 로 쓴다; 노트북의 `DefaultIcons.Add()` 가 아님), `TextField(state=...)`·`TextFieldState`(#10; `pythonx.compose.foundation.text.input`, 가짜 호스트로 테스트됨, 노트북의 `text_state=`/`padding=` 는 `state=`/`modifier=Modifier.padding(8)`; IME 조합 증거는 python-multiplatform 노트북 E2E #26 에서 통과) |
32+
| 계획 | 색 스킴, 코루틴 스코프(pythonx-concurrent 이후) |
3333

3434
### 테스트 기준선
3535

3636
```
3737
uv run --with pytest --with mypy pytest tests -q
38-
PythonMultiplatform 체크아웃 없음 148 passed, 114 skipped
39-
python-multiplatform develop 31c092f0+ 261 passed, 1 skipped
38+
PythonMultiplatform 체크아웃 없음 151 passed, 123 skipped
39+
python-multiplatform develop 31c092f0+ 273 passed, 1 skipped
4040
describe_member 없는 체크아웃 같은 수, 그중 7 개 건너뜀 (python-multiplatform #54 이전)
4141
describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전)
4242
그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (#131 이전, snake_case 이름 없음)

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ standalone desktop toolkit.
174174
| `TextField(state=...)` with `TextFieldState` (`pythonx.compose.foundation.text.input`) | 🟡 partial: tested against a fake host shaped after python-multiplatform #73; the input-method (IME) composing proof is python-multiplatform E2E #26 (#10) |
175175
| `DefaultIcons` (`Icons.Default`), written `DefaultIcons.Add` | 🟡 partial: tested against a fake host shaped after python-multiplatform #37/#38; `Icon(DefaultIcons.Add, …)` is drawn in python-multiplatform's render test |
176176
| Colour schemes | ⏳ planned |
177-
| `remember_saveable`, coroutine scopes | ⏳ planned |
177+
| `remember_saveable` with the notebook's `getValue()` / `setValue()` | 🟡 partial: tested against a fake host; save and restore is python-multiplatform's test (#174) |
178+
| Coroutine scopes (`DefaultCoroutineScope`, `MainCoroutineScope`) | ⏳ planned, after pythonx-concurrent |
178179

179180
The full list is on the guide's [Status page](https://thisisthepy.github.io/pythonx-compose/status.html).
180181

‎docs/SPEC.md‎

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ Run from a worktree with `uv run --with pytest --with mypy pytest tests -q -rs`
2121

2222
| Environment | Result |
2323
|---|---|
24-
| No `PythonMultiplatform` checkout found | **148 passed, 114 skipped**, 164 subtests passed |
25-
| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **261 passed, 1 skipped**, 195 subtests passed |
24+
| No `PythonMultiplatform` checkout found | **151 passed, 123 skipped**, 186 subtests passed |
25+
| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **273 passed, 1 skipped**, 232 subtests passed |
2626
| an older checkout, before python-multiplatform #131 (no binder-side snake_case names) | the same, with the snake_case method tests skipped |
2727
| an older one, without `describe(module, name)` (python-multiplatform #36) | with 13 more skipped again |
2828
| an older one still, without `add_member_resolver` | with 5 more skipped again |
2929

3030
Measured in a worktree, which has `.tmp/kotlin-stubs.zip` but no `UI.ipynb`. Without a checkout, the
31-
113 tests that install the binder's layers through `tests/adapter.py` skip (one of them against the
31+
122 tests that install the binder's layers through `tests/adapter.py` skip (one of them against the
3232
installed wheel, #88), plus the notebook test.
3333
Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1)
3434
skip. Against a binder before python-multiplatform #131, the tests that call a snake_case method or
@@ -406,47 +406,57 @@ reads the committed text. Not exercised there: the AWT layer that decodes an `In
406406
which needs a window. Not modelled here: a function-typed slot (`label`, `on_value_change`), which needs
407407
the binder's `NewFunction` rows.
408408

409-
### S5.6 `remember_saveable`, and the notebook's state spelling: `planned`
409+
### S5.6 `remember_saveable`, and the notebook's state spelling: `partial` (the Python half is tested here against the fake host; that Compose saves and restores the state is python-multiplatform's test)
410410

411411
INTENT §5.9 (2026-10-04). The 2024 demo the notebook ran against (PyREPL `abc6952`) wrote
412412
`cls.messages = messages = remember_saveable("")` in its root, and `UI.ipynb` cells 9 to 13 read
413413
`main.App.messages`, `main.App.messages.getValue()` and `main.App.messages.setValue(...)`.
414414

415415
- `remember_saveable(initial)`, from `pythonx.compose.runtime`, calls the host's `@Composable`
416-
`rememberSaveableWrapper(init, type)` (python-multiplatform's `python-multiplatform-compose`,
417-
package `python.multiplatform.compose`, exposed through the binder; python-multiplatform #174;
418-
this repository's #102). It follows pycomposeui's `rememberSaveableWrapper`: `type` is
419-
chosen in Python from the initial value, and Kotlin wraps the matching state in
420-
`rememberSaveable { ... }`:
416+
`python.multiplatform.compose.rememberSaveableWrapper(initial)` (python-multiplatform #174,
417+
`8c56f19b`; `python-multiplatform-compose`, `RememberSaveable.kt`). The value is passed as it
418+
is. The type rule is Kotlin's alone, after pycomposeui's wrapper of that name: the boxed value's
419+
Kotlin type picks the state wrapped in `rememberSaveable { ... }`.
421420

422-
| Python value | `type` | Kotlin state |
421+
| Python value | Kotlin type | Kotlin state |
423422
|---|---|---|
424-
| `bool` | `"bool"` | `mutableStateOf(Boolean)` |
425-
| `int` within 32 bits | `"int"` | `mutableIntStateOf` |
426-
| `int` beyond 32 bits, within 64 | `"long"` | `mutableLongStateOf` |
427-
| `float` | `"float"` | `mutableDoubleStateOf` |
428-
| `str` | `"str"` | `mutableStateOf(String)` |
429-
430-
`bool` is tested before `int`, because a Python `bool` is an `int`. (pycomposeui sent `"bool"`
431-
from Python but matched `"boolean"` in Kotlin, so its booleans fell through to the generic
432-
branch.) Any other value is a `TypeError` here until a saver for it is specified, and an `int`
433-
beyond 64 bits is a `TypeError` too.
434-
- It returns a thin wrapper over the Kotlin `MutableState`, with `getValue()`, `setValue(value)` and
435-
`.value` (read and write). Every read goes through `MutableState.value`, so Compose records the
436-
snapshot read and recomposes on a write; the wrapper holds nothing of its own.
437-
- It works only inside a composition, as in Kotlin: called outside one, it raises `RuntimeError`.
423+
| `int` within 32 bits | `Int` | `mutableIntStateOf` |
424+
| larger `int`, within 64 bits | `Long` | `mutableLongStateOf` |
425+
| `float` | `Double` | `mutableDoubleStateOf` |
426+
| `bool` | `Boolean` | `mutableStateOf` |
427+
| `str` | `String` | `mutableStateOf` |
428+
429+
Any other value is refused by Kotlin with an error naming its type (`list`). An `int` beyond 64
430+
bits is refused before that, as a `TypeError`, by the binder's scalar boxing (python-multiplatform
431+
#69).
432+
- It returns a `SaveableState`: a thin wrapper over the Kotlin `MutableState`, with `getValue()`,
433+
`setValue(value)` and `.value` (read and write). Every read goes through `MutableState.value`, so
434+
Compose records the snapshot read and recomposes on a write; the wrapper holds nothing else. The
435+
stub types it (`remember_saveable(initial: bool | int | float | str) -> SaveableState`), and the
436+
stub generator carries a class the package defines over with its public methods and properties.
437+
- It works only inside a composition, as in Kotlin: called outside one, the binder raises
438+
`RuntimeError`. When the wrapper is not bound, `remember_saveable` raises `RuntimeError` naming
439+
#174 and the host's `artifactIncludePackages` entry, `python.multiplatform.compose.RememberSaveableKt`.
438440
- How `main.App.messages` exists: the app attaches it, as the 2024 demo did. Inside
439441
`def App():` it writes `App.messages = messages = remember_saveable("")`. `@app` returns the
440442
function unchanged, so `main.App` is that function and `main.App.messages` is the state from its
441443
latest composition. There is no framework lookup by variable name. Before the first composition,
442444
`main.App.messages` does not exist yet.
443-
- Surviving rotation and process restart is Compose's `SaveableStateRegistry`. The host places
444-
`PythonAppView` under it, and the save-and-restore proof is python-multiplatform's (the same
445-
issue).
446-
447-
Tests here (planned): the type rule, the wrapper's three spellings over one state, the error outside
448-
a composition, and an app that attaches `App.messages` as above. They run against fake host rows
449-
shaped after the agreed `rememberSaveableWrapper`.
445+
- Surviving rotation and process restart is Compose's `SaveableStateRegistry`, proved in
446+
python-multiplatform's `RememberSaveableRenderTest` (saved, scene closed, restored into a new
447+
scene).
448+
449+
Tests: `tests/test_remember_saveable.py` (9 tests, against fake host rows shaped after
450+
`8c56f19b`):
451+
- `TheValueSent`: the value reaches Kotlin unchanged; an `int` beyond 64 bits is refused.
452+
- `TheWrapper`: the three spellings over one state; every read goes to the Kotlin state.
453+
- `OutsideTheComposition`.
454+
- `TheNotebookSpelling`: cells 9 to 13 as written, against an `@app` root that attaches
455+
`App.messages`.
456+
457+
Stubs: `tests/test_stubs.py::TheConversion::test_a_class_the_package_defines_keeps_its_public_members`.
458+
Typing: `tests/test_typing.py::test_remember_saveable_types_the_notebook_spelling` and
459+
`test_what_remember_saveable_returns_has_no_other_spelling`.
450460

451461
### S5.7 `DefaultCoroutineScope`, `MainCoroutineScope`: `planned` (after pythonx-concurrent)
452462

@@ -589,7 +599,7 @@ the binder lists no member that would win over these groups.
589599

590600
| Name | Notebook use | Note |
591601
|---|---|---|
592-
| `remember_saveable` | imported from `pythonx.compose.runtime`; `main.App.messages` read/written with `getValue()` / `setValue()` (cells 9 to 13) | INTENT §5.9: supported as written, `.value` as the alias; S5.6, `planned` |
602+
| `remember_saveable` | imported from `pythonx.compose.runtime`; `main.App.messages` read/written with `getValue()` / `setValue()` (cells 9 to 13) | INTENT §5.9: supported as written, `.value` as the alias; S5.6, `partial` (#102; save and restore proved in python-multiplatform #174) |
593603
| `DefaultCoroutineScope`, `MainCoroutineScope` | imported from `pythonx.compose.runtime` | INTENT §5.10: built on pythonx-concurrent; S5.7, `planned` |
594604
| `DefaultIcons` | `DefaultIcons.Add()` | INTENT §5.7, `implemented` as `DefaultIcons.Add`. The notebook's `Add()` is the spelling of its time; the current spelling is `Add`, a property read without parentheses, as for every constant (decided with the ecosystem lead; S5.3, `tests/test_chain.py::DefaultIconsAlias`, fake host) |
595605
| `modifier` | lower-case instance from `pythonx.compose.ui` | INTENT §5.4: not provided; written `Modifier` |

‎docs/guide/guide-composables.html‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,33 @@ <h2><span data-lang="en">Spellings that changed</span><span data-lang="ko">바
108108
</tbody></table></div>
109109
</section>
110110

111+
<section>
112+
<h2><span data-lang="en">State that survives, as the notebook writes it</span><span data-lang="ko">살아남는 상태, 노트북 표기 그대로</span></h2>
113+
<pre><code class="language-python"># main.py
114+
from pythonx.compose.runtime import Composable, app, remember_saveable
115+
from pythonx.compose.material3 import Text
116+
117+
@app
118+
@Composable
119+
def App():
120+
App.messages = messages = remember_saveable(&quot;Hello&quot;)
121+
Text(messages.getValue())
122+
123+
# a notebook cell
124+
import main
125+
backup = main.App.messages.getValue()
126+
main.App.messages.setValue(&quot;Changed from the notebook&quot;)
127+
main.App.messages.setValue(backup)</code></pre>
128+
<p data-lang="en"><code>remember_saveable(initial)</code> is Compose's <code>rememberSaveable</code>: the value survives recomposition, rotation and process restart. It takes an <code>int</code>, <code>float</code>, <code>bool</code> or <code>str</code>, and works only inside a composition. What it returns reads and writes with <code>getValue()</code> / <code>setValue(...)</code>, as the notebook writes, and with <code>.value</code>. The app attaches the state to its root itself (<code>App.messages = ...</code>), which is how <code>main.App.messages</code> exists after the first composition. A host app binds it by listing <code>python.multiplatform.compose.RememberSaveableKt</code> in <code>artifactIncludePackages</code>.</p><p data-lang="ko"><code>remember_saveable(initial)</code> 은 Compose 의 <code>rememberSaveable</code> 입니다. 값은 재구성, 화면 회전, 프로세스 재시작 뒤에도 남습니다. <code>int</code>, <code>float</code>, <code>bool</code>, <code>str</code> 을 받으며 컴포지션 안에서만 동작합니다. 돌려받은 값은 노트북처럼 <code>getValue()</code> / <code>setValue(...)</code> 로, 또 <code>.value</code> 로 읽고 씁니다. 앱이 상태를 루트에 직접 붙이므로(<code>App.messages = ...</code>) 첫 컴포지션 뒤에 <code>main.App.messages</code> 가 생깁니다. 호스트 앱은 <code>artifactIncludePackages</code> 에 <code>python.multiplatform.compose.RememberSaveableKt</code> 를 넣어 바인딩합니다.</p>
129+
<p data-lang="en"><strong>Status: partial.</strong> The Python half is tested against a fake host; saving and restoring through Compose's <code>SaveableStateRegistry</code> is python-multiplatform's test (#174).</p><p data-lang="ko"><strong>상태: 부분.</strong> 파이썬 쪽은 가짜 호스트로 테스트되었고, Compose 의 <code>SaveableStateRegistry</code> 를 통한 저장과 복원은 python-multiplatform 의 테스트(#174)입니다.</p>
130+
</section>
131+
111132
<section>
112133
<h2><span data-lang="en">What does not work yet</span><span data-lang="ko">아직 안 되는 것</span></h2>
113134
<ul class="clean">
114135
<li><span data-lang="en">The colour-scheme factories cannot be called yet: they take 36 colour parameters. <code>DefaultIcons</code> is <code>Icons.Default</code>, an icon is a value (an <code>ImageVector</code> to a type checker) read without parentheses: <code>Icon(DefaultIcons.Add, content_description=None)</code>, not the notebook's <code>DefaultIcons.Add()</code>. It is tested here against a fake host only; the real draw is in python-multiplatform's render test.</span><span data-lang="ko">색 스킴 팩토리는 색 매개변수를 36 개 받아 아직 호출할 수 없습니다. <code>DefaultIcons</code> 는 <code>Icons.Default</code> 이고, 아이콘은 괄호 없이 읽는 값(타입 검사기에는 <code>ImageVector</code>)입니다. 노트북의 <code>DefaultIcons.Add()</code> 가 아니라 <code>Icon(DefaultIcons.Add, content_description=None)</code>. 여기서는 가짜 호스트로만 테스트되며, 실제 그리기는 python-multiplatform 의 렌더 테스트에 있습니다.</span></li>
115136
<li><span data-lang="en"><code>TextField(state=…)</code> is wired and tested against a fake host only; that the input method's composing sequence survives, with no Python callback per keystroke, is proved in python-multiplatform's end-to-end test (#26).</span><span data-lang="ko"><code>TextField(state=…)</code> 는 가짜 호스트로만 테스트되었습니다. 입력기의 조합 입력이 키 입력마다 파이썬 콜백 없이 유지되는지는 python-multiplatform 의 종단 간 테스트(#26)에서 증명합니다.</span></li>
116-
<li><span data-lang="en"><code>remember_saveable</code> and the coroutine scopes the notebook imports are planned.</span><span data-lang="ko">노트북이 import 하는 <code>remember_saveable</code>, 코루틴 스코프는 계획 단계입니다.</span></li>
137+
<li><span data-lang="en">The coroutine scopes the notebook imports, <code>DefaultCoroutineScope</code> and <code>MainCoroutineScope</code>, come after pythonx-concurrent.</span><span data-lang="ko">노트북이 import 하는 코루틴 스코프 <code>DefaultCoroutineScope</code>, <code>MainCoroutineScope</code> 는 pythonx-concurrent 이후에 나옵니다.</span></li>
117138
</ul>
118139
<div class="pager"><a href="concepts.html">← <span data-lang="en">Concepts</span><span data-lang="ko">개념</span></a><a href="guide-modifiers.html"><span data-lang="en">Modifiers</span><span data-lang="ko">모디파이어</span> →</a></div>
119140
</section>

0 commit comments

Comments
 (0)