diff --git a/PROJECT.md b/PROJECT.md index 0bc271a..c690923 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -28,15 +28,15 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **` | 상태 | 항목 | |---|---| | 구현 | 매핑 매니페스트 `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 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨) | -| 계획 | 선언형 앱 루트(#11), `remember_saveable`·`DefaultIcons`·코루틴 스코프 | +| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 python-multiplatform #38 대기), 배포 설정, proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨) | +| 계획 | `remember_saveable`·`DefaultIcons`·코루틴 스코프 | ### 테스트 기준선 ``` python3 -m pytest tests -q - PythonMultiplatform 체크아웃 없음 91 passed, 73 skipped - python-multiplatform develop d6d39787+ 163 passed, 1 skipped + PythonMultiplatform 체크아웃 없음 106 passed, 75 skipped + python-multiplatform develop d6d39787+ 178 passed, 3 skipped describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전) 그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (member resolver 없음) ``` diff --git a/README.md b/README.md index eca187c..8396d7a 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ standalone desktop toolkit. | Material 3 widgets (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 partial — re-exported by rule; render proofs per widget pending (#9) | | Type stubs (`.pyi`) from real Compose 1.11.1, `py.typed` | ✅ implemented and checked with mypy; many types are still `Any`, and method keywords are Kotlin's (#12) | | Distribution (`pythonx-compose`) | 🟡 partial — the wheel carries the manifest, the re-export rule, the stubs and `py.typed` | -| Declared app root and Pythonic state, no update call | ⏳ planned (#11) | +| Declared app root (`@app`) and Pythonic state (`state`), no update call | 🟡 partial — logic tested; the binder path waits for python-multiplatform #38 (#11) | | `TextField` input-method (IME) handling | ⏳ planned (#10) | | `Icon`, `DefaultIcons`, colour schemes | ⏳ planned (python-multiplatform #37) | | `remember_saveable`, coroutine scopes | ⏳ planned | diff --git a/docs/SPEC.md b/docs/SPEC.md index 2f8796c..5497b44 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -14,26 +14,26 @@ Each item carries a status: A behaviour change starts here, then becomes a failing test, then code (`AGENTS.md` §5). -## 0. Test baseline (2026-10-03, after issue #9's grouped constants) +## 0. Test baseline (2026-10-03, after issue #11's app root) Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CPython 3.13), where `UI.ipynb` is absent and its one test skips: | Environment | Result | |---|---| -| No `PythonMultiplatform` checkout found | **91 passed, 73 skipped**, 122 subtests passed | -| python-multiplatform `develop` at `d6d39787` or later (has `add_member_resolver` and `describe(module, name)`) | **163 passed, 1 skipped**, 149 subtests passed | +| No `PythonMultiplatform` checkout found | **106 passed, 75 skipped**, 122 subtests passed | +| python-multiplatform `develop` at `d6d39787` or later (has `add_member_resolver` and `describe(module, name)`) | **178 passed, 3 skipped**, 149 subtests passed | | an older checkout, without `describe(module, name)` (python-multiplatform #36) | the same, with 13 more skipped | | an older one still, without `add_member_resolver` | with 5 more skipped again | Without a checkout, the 72 tests that install the binder's layers through `tests/adapter.py` skip. Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1) skip. Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the -binder returned skip as well, because that needs its member resolver (python-multiplatform #17). The 1 skip in both -rows is `UI.ipynb`'s (§2). `tests/test_typing.py` skips where mypy is not installed. A skip is not a +binder returned skip as well, because that needs its member resolver (python-multiplatform #17). The 3 skips against a current checkout are `UI.ipynb`'s (§2) and the 2 that need +python-multiplatform #38 (S5.4). `tests/test_typing.py` skips where mypy is not installed. A skip is not a pass. -Of the 91 that pass without a checkout, 37 check the type stubs and the wheel (S1.2); most of the +Of the 106 that pass without a checkout, 38 check the type stubs and the wheel (S1.2); most of the rest assert **absence** (a retired token, a deleted file, a docstring that exists). Those are listed in §9 and are not counted as features. @@ -194,6 +194,30 @@ job. Tests: `tests/test_runtime_module.py::TheRuntimeSeam` (4 tests) and `::TheChaquopyMechanismIsGone` (3 tests), importing `pythonx.compose.runtime` the ordinary way. +### S5.4 The app root, `app` and `state` — `partial` (logic tested; the binder path waits for python-multiplatform #38) + +The host draws with `PythonContent("pythonx.compose.runtime", "app_root")` (python-multiplatform #18, +issue #11). `pythonx.compose.runtime` provides: + +- `app_root` — a Compose `MutableState` whose `.value` is a zero-argument callable, or `None` (the + host draws nothing). It is created on first read, by the binder's + `androidx.compose.runtime.mutableStateOf(None)`, because the binder may be installed after `pythonx` + is imported; afterwards it is an ordinary module attribute, the same object every time. +- `@app` — `app(fn)` sets `app_root.value = fn` and returns `fn` unchanged. Declaring the root again + replaces the value, so the screen follows. There is no update or refresh function (INTENT §5.1). +- `state(initial)` — `mutableStateOf(initial)`, read and written through `.value`; no `getValue` or + `setValue`. + +Both states come from one internal function, `_new_state`. When the binder cannot supply +`mutableStateOf` it raises `RuntimeError` naming python-multiplatform #38. No pure-Python state is a +fallback: it would not make Compose recompose. Names other than `app_root` still resolve through the +re-export rule, and the module's own names win. + +Tests: `tests/test_app_root.py::TheLogic` (11) and `::TheStateFactory` (4) run against a test-only +fake state patched in for `_new_state`; `::TheBinderPath` (2) uses the real one and skips, "needs +python-multiplatform #38", until the binder binds `mutableStateOf`. The stub carries `app`, `state` +and `app_root` (`tests/test_stubs.py`). + ### S5.2 Material 3 composables reach Python without per-widget wrappers — `partial` `Text`, `Button`, `Card`, `ListItem`, `Badge`, `BadgedBox`, `MaterialTheme`, `IconButton` and its @@ -335,7 +359,7 @@ the binder lists no member that would win over these groups. | Name | Notebook use | Note | |---|---|---| -| `remember_saveable` | imported from `pythonx.compose.runtime`; state read/written with `getValue()` / `setValue()` | INTENT §5.1: Pythonic attribute access instead of the accessors | +| `remember_saveable` | imported from `pythonx.compose.runtime`; state read/written with `getValue()` / `setValue()` | INTENT §5.1: `state(initial)` read and written through `.value` (S5.4, `partial`, #38); keeping a value across recreation is not provided | | `DefaultCoroutineScope`, `MainCoroutineScope` | imported from `pythonx.compose.runtime` | INTENT §4.1, open | | `DefaultIcons` | `DefaultIcons.Add()` | INTENT §5.7: `Icons.Default` by alias, once python-multiplatform #37 binds `material-icons-core` | | `modifier` | lower-case instance from `pythonx.compose.ui` | INTENT §5.4: not provided; written `Modifier` | @@ -343,7 +367,7 @@ the binder lists no member that would win over these groups. | `Spacer(start=..., top=...)` | spacing parameters | INTENT §5.6: not supported; written `Spacer(modifier=Modifier.padding(...))` | | `Column`, `Row`, `Spacer` | imported from `pythonx.compose.material3` | INTENT §5.2: served from both, by the manifest's `[aliases]` (`tests/test_chain.py::ManifestAliases`); render proof pending (#9) | | `Card`, `Button`, `Text`, `TextField` | as above | S5.2 | -| `main.App`, `App.update(...)` | live screen replacement from a cell | INTENT §5.1: a declared root that a redefinition replaces, no update function (issue #11) | +| `main.App`, `App.update(...)` | live screen replacement from a cell | INTENT §5.1: `@app` declares the root, a redeclaration replaces it, no update function (S5.4, `partial`, issue #11) | ## 9. Repository hygiene (not behaviour) diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index a67dffe..3f2e512 100644 --- a/docs/guide/getting-started.html +++ b/docs/guide/getting-started.html @@ -43,9 +43,9 @@

1. Get the source1. 소스

2. Run the tests2. 테스트 실행

python3 -m pytest tests -q
-

Expect 91 passed, 73 skipped. One skipped test reads the maintainer's UI.ipynb, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:

91 passed, 73 skipped 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 UI.ipynb 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:

+

Expect 106 passed, 75 skipped. One skipped test reads the maintainer's UI.ipynb, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:

106 passed, 75 skipped 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 UI.ipynb 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:

PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q
-

With a current one (ba4c6f49 or later) 143 pass and only the UI.ipynb test skips; an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.

최신 체크아웃(ba4c6f49 이후)이면 143 개가 통과하고 UI.ipynb 테스트만 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.

+

With a current one (ba4c6f49 or later) 178 pass and 3 skip (the UI.ipynb test and the 2 that need python-multiplatform #38); an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.

최신 체크아웃(ba4c6f49 이후)이면 178 개가 통과하고 3 개(UI.ipynb 테스트와 python-multiplatform #38 이 필요한 2 개)를 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.

diff --git a/docs/guide/guide-composables.html b/docs/guide/guide-composables.html index d93d2e1..a8b4fe2 100644 --- a/docs/guide/guide-composables.html +++ b/docs/guide/guide-composables.html @@ -49,6 +49,21 @@

Write a screen화면 하나 from pythonx.compose.layout import Column # Kotlin's home; same object

+
+

Declare the app and keep state앱 선언과 상태

+
from pythonx.compose.runtime import app, state
+from pythonx.compose.material3 import Text, Button
+
+count = state(0)
+
+@app
+def Main():
+    Button(on_click=lambda: setattr(count, "value", count.value + 1),
+           content=lambda: Text(f"Clicked {count.value}"))
+

@app declares the screen the host draws and returns the function unchanged. Declaring it again, for example in a notebook cell, replaces the screen; there is no update call. state(initial) is a Compose state, read and written through .value. The host is configured once with PythonContent("pythonx.compose.runtime", "app_root").

@app 은 호스트가 그리는 화면을 선언하고 함수를 그대로 돌려줍니다. 노트북 셀 등에서 다시 선언하면 화면이 바뀌며, 갱신 호출은 없습니다. state(initial) 는 Compose 상태이며 .value 로 읽고 씁니다. 호스트는 PythonContent("pythonx.compose.runtime", "app_root") 로 한 번만 설정합니다.

+

Status: partial. The logic is tested; the binder path, which creates the state through Compose's mutableStateOf, waits for python-multiplatform #38. Until then these calls raise a RuntimeError that says so.

상태: 부분. 로직은 테스트되었습니다. Compose 의 mutableStateOf 로 상태를 만드는 바인더 경로는 python-multiplatform #38 을 기다립니다. 그때까지는 이를 알리는 RuntimeError 가 발생합니다.

+
+

From the notebook's spelling to the real parameters노트북 표기에서 실제 매개변수로

The notebook's signatures are examples. The surface follows Compose:

노트북의 시그니처는 예시입니다. 실제 표면은 Compose 를 따릅니다:

diff --git a/docs/guide/status.html b/docs/guide/status.html index a695aa0..0ea683f 100644 --- a/docs/guide/status.html +++ b/docs/guide/status.html @@ -34,9 +34,9 @@

Implemented means code in this repository and a passing test that exercises it. Tests that only assert an absence are not counted.

구현은 이 저장소의 코드와 그것을 실행하는 통과 테스트가 함께 있다는 뜻입니다. 부재만 확인하는 테스트는 세지 않습니다.

- +
State상태Item항목
implemented구현Mapping manifest pythonx/compose/pythonx-map.toml — inside the package, ships in the wheel매핑 매니페스트 pythonx/compose/pythonx-map.toml — 패키지 안에 있으며 wheel 에 포함
implemented구현@Composable identity decorator@Composable 항등 데코레이터
implemented구현pythonx as a real on-disk package re-exporting androidx.compose.* by one rule — names, keywords, signatures, Dp allowlistandroidx.compose.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonx — 이름, 키워드, 시그니처, Dp 허용 목록
implemented구현Modifier chains with snake_case methods, overload dispatch, Dp as a numbersnake_case 메서드를 쓰는 Modifier 체인, 오버로드 디스패치, 숫자 Dp
implemented구현Column, Row, Spacer importable from material3 as well as layout (manifest [aliases])layout 뿐 아니라 material3 에서도 import 되는 Column, Row, Spacer (매니페스트 [aliases])
implemented구현Type stubs (.pyi) generated from real Compose 1.11.1 by the runtime's naming rule, with py.typed, checked with mypy (#12); many types are still Any and method keywords are Kotlin's런타임과 같은 이름 규칙으로 실제 Compose 1.11.1 에서 생성한 타입 스텁(.pyi)과 py.typed, mypy 로 검사(#12); 아직 많은 타입이 Any 이고 메서드 키워드는 Kotlin 이름
partial부분Snake-case names, keywords and signatures — done for modules and method names; method keywords are still Kotlin'ssnake_case 이름·키워드·시그니처 — 모듈과 메서드 이름 완료, 메서드 키워드는 아직 Kotlin 이름
partial부분The empty Modifier — Modifier.padding(16) from the class needs an app-supplied factory against real Compose빈 Modifier — 클래스에서 시작하는 Modifier.padding(16) 은 실제 Compose 에서 앱이 제공하는 팩토리가 필요
implemented구현Alignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), and the grouped Alignment.Horizontal.End beside Alignment.EndAlignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), 그리고 Alignment.End 와 나란히 묶음 표기 Alignment.Horizontal.End
partial부분Material 3 widgets — re-exported by rule; render proofs per widget pending (#9)Material 3 위젯 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9)
partial부분Distribution as pythonx-compose — the wheel carries the manifest, the re-export rule, the type stubs and py.typedpythonx-compose 배포 — wheel 에 매니페스트, 재노출 규칙, 타입 스텁, py.typed 포함
planned계획A declared app root and Pythonic state, with no update call (#11)갱신 호출 없는 선언형 앱 루트와 파이썬다운 상태(#11)
planned계획TextField input-method (IME) handling (#10)TextField 입력기(IME) 처리(#10)
planned계획Icon, DefaultIcons (python-multiplatform #37), colour schemesIcon, DefaultIcons (python-multiplatform #37), 색 스킴
planned계획remember_saveable, coroutine scopesremember_saveable, 코루틴 스코프
implemented구현Mapping manifest pythonx/compose/pythonx-map.toml — inside the package, ships in the wheel매핑 매니페스트 pythonx/compose/pythonx-map.toml — 패키지 안에 있으며 wheel 에 포함
implemented구현@Composable identity decorator@Composable 항등 데코레이터
implemented구현pythonx as a real on-disk package re-exporting androidx.compose.* by one rule — names, keywords, signatures, Dp allowlistandroidx.compose.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonx — 이름, 키워드, 시그니처, Dp 허용 목록
implemented구현Modifier chains with snake_case methods, overload dispatch, Dp as a numbersnake_case 메서드를 쓰는 Modifier 체인, 오버로드 디스패치, 숫자 Dp
implemented구현Column, Row, Spacer importable from material3 as well as layout (manifest [aliases])layout 뿐 아니라 material3 에서도 import 되는 Column, Row, Spacer (매니페스트 [aliases])
implemented구현Type stubs (.pyi) generated from real Compose 1.11.1 by the runtime's naming rule, with py.typed, checked with mypy (#12); many types are still Any and method keywords are Kotlin's런타임과 같은 이름 규칙으로 실제 Compose 1.11.1 에서 생성한 타입 스텁(.pyi)과 py.typed, mypy 로 검사(#12); 아직 많은 타입이 Any 이고 메서드 키워드는 Kotlin 이름
partial부분Snake-case names, keywords and signatures — done for modules and method names; method keywords are still Kotlin'ssnake_case 이름·키워드·시그니처 — 모듈과 메서드 이름 완료, 메서드 키워드는 아직 Kotlin 이름
partial부분The empty Modifier — Modifier.padding(16) from the class needs an app-supplied factory against real Compose빈 Modifier — 클래스에서 시작하는 Modifier.padding(16) 은 실제 Compose 에서 앱이 제공하는 팩토리가 필요
implemented구현Alignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), and the grouped Alignment.Horizontal.End beside Alignment.EndAlignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), 그리고 Alignment.End 와 나란히 묶음 표기 Alignment.Horizontal.End
partial부분Material 3 widgets — re-exported by rule; render proofs per widget pending (#9)Material 3 위젯 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9)
partial부분Distribution as pythonx-compose — the wheel carries the manifest, the re-export rule, the type stubs and py.typedpythonx-compose 배포 — wheel 에 매니페스트, 재노출 규칙, 타입 스텁, py.typed 포함
partial부분A declared app root (@app) and Pythonic state (state), with no update call — logic tested, the binder path waits for python-multiplatform #38 (#11)갱신 호출 없는 선언형 앱 루트(@app)와 파이썬다운 상태(state) — 로직은 테스트됨, 바인더 경로는 python-multiplatform #38 대기(#11)
planned계획TextField input-method (IME) handling (#10)TextField 입력기(IME) 처리(#10)
planned계획Icon, DefaultIcons (python-multiplatform #37), colour schemesIcon, DefaultIcons (python-multiplatform #37), 색 스킴
planned계획remember_saveable, coroutine scopesremember_saveable, 코루틴 스코프
-

Test baseline: 91 passed, 73 skipped without a python-multiplatform checkout; 163 passed, 1 skipped with a current one. The one skip in both is the test that reads the maintainer's UI.ipynb.

테스트 기준선: python-multiplatform 체크아웃이 없으면 91 passed, 73 skipped, 최신 체크아웃이면 163 passed, 1 skipped. 두 경우 모두의 건너뜀 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트입니다.

+

Test baseline: 106 passed, 75 skipped without a python-multiplatform checkout; 178 passed, 3 skipped with a current one. Of the skips with a current checkout, one reads the maintainer's UI.ipynb and two need python-multiplatform #38.

테스트 기준선: python-multiplatform 체크아웃이 없으면 91 passed, 73 skipped, 최신 체크아웃이면 163 passed, 1 skipped. 최신 체크아웃에서 건너뛰는 3 개 중 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트이고 2 개는 python-multiplatform #38 이 필요합니다.

FAQ

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index 76fdc7a..d173e80 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -154,7 +154,7 @@ pip 패키지 **`pythonx-compose`**(파이썬 3.11 이상)로 배포되며, impo | Material 3 위젯 (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 부분 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) | | 실제 Compose 1.11.1 에서 생성한 타입 스텁(`.pyi`), `py.typed` | ✅ 구현, mypy 로 검사; 아직 많은 타입이 `Any` 이고 메서드 키워드는 Kotlin 이름(#12) | | 배포 (`pythonx-compose`) | 🟡 부분 — wheel 에 매니페스트, 재노출 규칙, 스텁, `py.typed` 포함 | -| 갱신 호출 없는 선언형 앱 루트와 파이썬다운 상태 | ⏳ 계획(#11) | +| 갱신 호출 없는 선언형 앱 루트(`@app`)와 파이썬다운 상태(`state`) | 🟡 부분 — 로직은 테스트됨, 바인더 경로는 python-multiplatform #38 대기(#11) | | `TextField` 입력기(IME) 처리 | ⏳ 계획(#10) | | `Icon`, `DefaultIcons`, 색 스킴 | ⏳ 계획(python-multiplatform #37) | | `remember_saveable`, 코루틴 스코프 | ⏳ 계획 | diff --git a/pythonx/compose/runtime/__init__.py b/pythonx/compose/runtime/__init__.py index f936566..3bc9441 100644 --- a/pythonx/compose/runtime/__init__.py +++ b/pythonx/compose/runtime/__init__.py @@ -35,6 +35,21 @@ slot detection; `@Composable` changes nothing about that and exists only so the notebook's own spelling keeps working. +## The app root and `state` + +`@app` declares the screen the host draws; `state(initial)` makes a value Compose observes. The host +is configured once with `PythonContent("pythonx.compose.runtime", "app_root")`: it reads the module +attribute `app_root`, a Compose `MutableState` whose `.value` is a zero-argument callable or `None` +(nothing is drawn). `app(fn)` writes `app_root.value = fn` and returns `fn` unchanged, so declaring +the root again -- in a notebook cell, say -- replaces the screen. There is deliberately no update or +refresh function (`docs/INTENT.md` section 5.1). + +Both states come from `_new_state`, the one place that asks the binder for +`androidx.compose.runtime.mutableStateOf`. `app_root` is created on first read, because the binder may +be installed after `pythonx` is imported. A Python object with a `.value` would not make Compose +recompose, so when the binder cannot supply `mutableStateOf` (python-multiplatform #38) `_new_state` +raises instead of falling back to one. + ## How it is reached By its own dotted name: `import pythonx.compose.runtime` loads this file. The binder used to put a @@ -46,6 +61,8 @@ from __future__ import annotations +import sys + def Composable(target): """Identity. `UI.ipynb` writes `@Composable def Screen(): ...`; nothing needs to happen to @@ -56,6 +73,56 @@ def Composable(target): """ return target + + +app_root: State +"""The state the host reads (`PythonContent("pythonx.compose.runtime", "app_root")`); annotated +only, created on first read by `__getattr__` below.""" + + +def _new_state(initial): + """A Compose `MutableState` holding `initial`, from the binder's `mutableStateOf`. + + Never a Python stand-in: only a state Compose created is observed by composition. + """ + try: + import androidx.compose.runtime as kotlin_runtime + + make = kotlin_runtime.mutableStateOf + except (ImportError, AttributeError) as missing: + raise RuntimeError( + "androidx.compose.runtime.mutableStateOf is not available from the binder: " + "calling it from Python needs python-multiplatform #38" + ) from missing + return make(initial) + + +def state(initial): + """A Compose state holding `initial`; read and write it through `.value`.""" + return _new_state(initial) + + +def app(root): + """Declare `root`, a zero-argument function, as the screen the host draws; returns it unchanged. + + Declaring again replaces the screen; there is no update call. + """ + sys.modules[__name__].app_root.value = root + return root + + from pythonx.compose._reexport import reexport -__getattr__, __dir__ = reexport(__name__) +_reexported_getattr, _reexported_dir = reexport(__name__) + + +def __getattr__(name): + if name == "app_root": + created = _new_state(None) + globals()["app_root"] = created + return created + return _reexported_getattr(name) + + +def __dir__(): + return sorted(set(_reexported_dir()) | {"app_root"}) diff --git a/pythonx/compose/runtime/__init__.pyi b/pythonx/compose/runtime/__init__.pyi index 06d9004..aa87881 100644 --- a/pythonx/compose/runtime/__init__.pyi +++ b/pythonx/compose/runtime/__init__.pyi @@ -26,3 +26,11 @@ class State: def Composable(target): ... + +app_root: State + +def state(initial): + ... + +def app(root): + ... diff --git a/tests/test_app_root.py b/tests/test_app_root.py new file mode 100644 index 0000000..83bd6f5 --- /dev/null +++ b/tests/test_app_root.py @@ -0,0 +1,236 @@ +"""`@app`, `state` and `app_root` in `pythonx.compose.runtime` (issue #11, docs/INTENT.md section 5.1). + +The host draws with `PythonContent("pythonx.compose.runtime", "app_root")`: it reads the module +attribute `app_root`, a Compose State whose `.value` is a zero-argument callable or None. `@app` +writes that value, so redeclaring the root replaces the screen; there is no update function. + +The state itself comes from the binder's `androidx.compose.runtime.mutableStateOf`, through one +internal function, `_new_state`. Calling it from Python is python-multiplatform #38 and has not +landed, so the logic here runs against `FakeState`, patched in for `_new_state` and kept in this +file: a pure-Python state in shipped code would not make Compose recompose, so none ships. +`TheBinderPath` is the test to switch on when #38 lands: it skips until the binder binds +`mutableStateOf`. +""" + +from __future__ import annotations + +import importlib +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import adapter as adapter_loader # noqa: E402 +import fake_host # noqa: E402 + +NEEDS_38 = "needs python-multiplatform #38" + + +class FakeState: + """Test-only stand-in for a Compose `MutableState`: `.value`, and a log of writes.""" + + def __init__(self, initial): + self.writes = [initial] + self._value = initial + + @property + def value(self): + return self._value + + @value.setter + def value(self, new): + self._value = new + self.writes.append(new) + + +def fresh_runtime(): + """Import `pythonx.compose.runtime` afresh, so `app_root` has not been created yet.""" + for name in [n for n in sys.modules if n == "pythonx.compose.runtime"]: + del sys.modules[name] + return importlib.import_module("pythonx.compose.runtime") + + +class TheLogic(unittest.TestCase): + def setUp(self): + saved = dict(sys.modules) + self.addCleanup(lambda: (sys.modules.clear(), sys.modules.update(saved))) + self.runtime = fresh_runtime() + self.created = [] + + def factory(initial): + made = FakeState(initial) + self.created.append(made) + return made + + patcher = mock.patch.object(self.runtime, "_new_state", factory) + patcher.start() + self.addCleanup(patcher.stop) + + def test_app_returns_the_function_unchanged(self): + def Screen(): + """A screen.""" + + self.assertIs(Screen, self.runtime.app(Screen)) + self.assertEqual("Screen", Screen.__name__) + + def test_app_root_is_created_lazily_and_once(self): + self.assertEqual([], self.created) + first = self.runtime.app_root + self.assertEqual(1, len(self.created)) + self.assertIs(first, self.runtime.app_root) + self.assertEqual(1, len(self.created)) + self.assertIn("app_root", vars(self.runtime), "after the first read it is a plain attribute") + + def test_before_any_declaration_the_root_is_none(self): + self.assertIsNone(self.runtime.app_root.value) + + def test_app_sets_the_root_value(self): + @self.runtime.app + def Screen(): + return "one" + + self.assertIs(Screen, self.runtime.app_root.value) + + def test_redeclaring_replaces_the_value_in_the_same_state(self): + root = self.runtime.app_root + + @self.runtime.app + def Screen(): + return "one" + + @self.runtime.app + def Screen(): # noqa: F811 + return "two" + + self.assertIs(root, self.runtime.app_root) + self.assertEqual("two", root.value()) + self.assertEqual(1, len(self.created)) + self.assertEqual(3, len(root.writes)) + + def test_state_round_trips_through_value(self): + counter = self.runtime.state(0) + self.assertEqual(0, counter.value) + counter.value = 5 + self.assertEqual(5, counter.value) + + def test_each_state_call_makes_its_own_state(self): + self.assertIsNot(self.runtime.state(0), self.runtime.state(0)) + + def test_no_update_or_refresh_name_is_public(self): + public = [n for n in dir(self.runtime) if not n.startswith("_")] + for name in public: + for banned in ("update", "refresh", "invalidate", "rerender"): + self.assertNotIn(banned, name.lower()) + for banned in ("update", "refresh", "_update", "_refresh"): + self.assertNotIn(banned, vars(self.runtime)) + + def test_dir_lists_the_new_names(self): + for name in ("app", "state", "app_root", "Composable"): + self.assertIn(name, dir(self.runtime)) + + def test_unknown_names_still_go_to_the_reexport_path(self): + # AttributeError with a binder; RuntimeError ("not installed") without one -- both come from + # the reexport path, which is what answered. + with self.assertRaises((AttributeError, RuntimeError)): + self.runtime.no_such_name_anywhere # noqa: B018 + + +class TheStateFactory(unittest.TestCase): + """`_new_state` asks the binder for `mutableStateOf` and never invents a state of its own.""" + + def setUp(self): + saved = dict(sys.modules) + self.addCleanup(lambda: (sys.modules.clear(), sys.modules.update(saved))) + self.runtime = fresh_runtime() + + def _binder_with(self, **names): + module = types.ModuleType("androidx.compose.runtime") + for key, value in names.items(): + setattr(module, key, value) + parent = types.ModuleType("androidx.compose") + parent.runtime = module + top = types.ModuleType("androidx") + top.compose = parent + return mock.patch.dict( + sys.modules, + {"androidx": top, "androidx.compose": parent, "androidx.compose.runtime": module}, + ) + + def test_it_calls_the_binders_mutable_state_of(self): + seen = [] + with self._binder_with(mutableStateOf=lambda initial: seen.append(initial) or "STATE"): + self.assertEqual("STATE", self.runtime._new_state(7)) + self.assertEqual([7], seen) + + def test_a_binder_without_mutable_state_of_raises_pointing_at_38(self): + with self._binder_with(): + with self.assertRaises(RuntimeError) as raised: + self.runtime._new_state(0) + self.assertIn("#38", str(raised.exception)) + + def test_no_binder_at_all_raises_pointing_at_38(self): + with mock.patch.dict(sys.modules, {"androidx": None}): + with self.assertRaises(RuntimeError) as raised: + self.runtime._new_state(0) + self.assertIn("#38", str(raised.exception)) + + def test_state_and_app_root_use_the_factory_not_a_python_stand_in(self): + with self._binder_with(): + with self.assertRaises(RuntimeError): + self.runtime.state(0) + with self.assertRaises(RuntimeError): + self.runtime.app_root # noqa: B018 + self.assertNotIn("app_root", vars(self.runtime), "a failed creation must not be cached") + + +class TheBinderPath(unittest.TestCase): + """The real `_new_state`, unpatched, through the binder. + + It skips while the installed table binds no `androidx.compose.runtime.mutableStateOf`. That stays + true here after python-multiplatform #38 lands, because `fake_host.py` is this repository's own + fixture: switching this on means adding rows in the shape #38 defines. The proof against real + Compose is the E2E module (python-multiplatform #26, issue #19). + """ + + def setUp(self): + try: + self.binding = adapter_loader.install() + except adapter_loader.AdapterUnavailable as unavailable: + self.skipTest(str(unavailable)) + self.host = fake_host.FakeHost() + self.host.bind() + self.host.register(self.binding) + self.addCleanup(self.host.unbind) + self.addCleanup(adapter_loader.uninstall) + try: + kotlin_runtime = importlib.import_module("androidx.compose.runtime") + kotlin_runtime.mutableStateOf + except (ImportError, AttributeError): + self.skipTest(NEEDS_38) + self.runtime = fresh_runtime() + + def test_app_root_is_a_compose_state_the_declaration_writes(self): + @self.runtime.app + def Screen(): + return None + + self.assertIs(Screen, self.runtime.app_root.value) + + @self.runtime.app + def Screen(): # noqa: F811 + return None + + self.assertIs(Screen, self.runtime.app_root.value) + + def test_state_round_trips_through_the_binder(self): + counter = self.runtime.state(1) + counter.value = 2 + self.assertEqual(2, counter.value) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stubs.py b/tests/test_stubs.py index cdb7808..5946064 100644 --- a/tests/test_stubs.py +++ b/tests/test_stubs.py @@ -133,6 +133,14 @@ def test_names_the_package_defines_itself_are_carried_over(self): runtime = self.stubs[COMPOSE / "runtime" / "__init__.pyi"] self.assertIn("def Composable(target)", runtime) + def test_a_public_annotated_name_the_package_declares_is_carried_over(self): + # `runtime.app_root` is created on first read by a module `__getattr__`, so `__init__.py` + # only annotates it; the stub must still say it exists. + runtime = self.stubs[COMPOSE / "runtime" / "__init__.pyi"] + self.assertIn("app_root: State", runtime) + self.assertIn("def app(root)", runtime) + self.assertIn("def state(initial)", runtime) + def test_every_stub_is_valid_python(self): for path, text in self.stubs.items(): with self.subTest(stub=str(path.relative_to(REPO))): diff --git a/tools/gen_stubs.py b/tools/gen_stubs.py index 3f43e41..d039e70 100644 --- a/tools/gen_stubs.py +++ b/tools/gen_stubs.py @@ -34,7 +34,8 @@ - a module that maps a Kotlin package an earlier module already maps re-exports that module, and the manifest's `[aliases]` are re-exported, because at run time those names are the same objects; - names the module's own `__init__.py` defines (`runtime.Composable`) are carried over, because a - `.pyi` beside a module replaces everything a type checker knows about it. + `.pyi` beside a module replaces everything a type checker knows about it; so is a bare + annotation (`app_root: State`), the way a module declares a name its `__getattr__` creates. Nothing here names a Kotlin declaration; it is the same rule `_reexport.py` applies at run time. The output is deterministic: the same input regenerates the same text. @@ -530,6 +531,14 @@ def own_definitions(init_py: Path) -> list[str]: node.body = [ast.Expr(ast.Constant(...))] node.decorator_list = [] stubs.append(ast.unparse(node) + "\n") + elif ( + isinstance(node, ast.AnnAssign) + and node.value is None + and isinstance(node.target, ast.Name) + and not node.target.id.startswith("_") + ): + # A name the module answers through its own `__getattr__` (`runtime.app_root`). + stubs.append(ast.unparse(node) + "\n") elif isinstance(node, ast.ClassDef) and not node.name.startswith("_"): stubs.append(f"class {node.name}: ...\n") return stubs