diff --git a/PROJECT.md b/PROJECT.md index c690923..e2de32b 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) | -| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 python-multiplatform #38 대기), 배포 설정, proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨) | +| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 E2E 모듈 #19, 바인더가 `Any?` 칸의 int 를 거부하므로 `state(1)` 은 아직 TypeError), 배포 설정, proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨) | | 계획 | `remember_saveable`·`DefaultIcons`·코루틴 스코프 | ### 테스트 기준선 ``` python3 -m pytest tests -q - PythonMultiplatform 체크아웃 없음 106 passed, 75 skipped - python-multiplatform develop d6d39787+ 178 passed, 3 skipped + PythonMultiplatform 체크아웃 없음 106 passed, 78 skipped + python-multiplatform develop 31c092f0+ 183 passed, 1 skipped describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전) 그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (member resolver 없음) ``` diff --git a/README.md b/README.md index 8396d7a..f32a7a4 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 (`@app`) and Pythonic state (`state`), no update call | 🟡 partial — logic tested; the binder path waits for python-multiplatform #38 (#11) | +| Declared app root (`@app`) and Pythonic state (`state`), no update call | 🟡 partial — binder path tested against a fake host shaped after python-multiplatform #38; real Compose is the E2E module (#11, #19); `state(1)` is refused by the binder (int in an `Any?` slot) | | `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 5497b44..1163f4c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -21,16 +21,16 @@ Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CP | Environment | Result | |---|---| -| 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 | +| No `PythonMultiplatform` checkout found | **106 passed, 78 skipped**, 122 subtests passed | +| python-multiplatform `develop` at `31c092f0` or later (has property rows, #38, besides `add_member_resolver` and `describe(module, name)`) | **183 passed, 1 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. +Without a checkout, the 75 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 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 +binder returned skip as well, because that needs its member resolver (python-multiplatform #17). The skip against a current checkout is `UI.ipynb`'s (§2). A binder older than #38 skips the 5 +`TheBinderPath` tests (S5.4). `tests/test_typing.py` skips where mypy is not installed. A skip is not a pass. Of the 106 that pass without a checkout, 38 check the type stubs and the wheel (S1.2); most of the @@ -194,7 +194,7 @@ 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) +### S5.4 The app root, `app` and `state` — `partial` (the binder path is tested against a fake host shaped after python-multiplatform #38; real Compose is the E2E module, issue #19) The host draws with `PythonContent("pythonx.compose.runtime", "app_root")` (python-multiplatform #18, issue #11). `pythonx.compose.runtime` provides: @@ -213,10 +213,18 @@ Both states come from one internal function, `_new_state`. When the binder canno 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`). +Evidence: python-multiplatform #38 (`31c092f0`) binds generic functions and property getters and +setters, and an `Any?` slot carries a Python object as itself, so `mutableStateOf(x)` is callable and +the returned `MutableState`'s `.value` reads and writes. One consequence: the binder refuses an +`int` in an `Any?` slot (it would cross as an object handle), so `state(1)` raises `TypeError` today +and `pythonx` does not box it. python-multiplatform #69 makes the binder box scalars in an `Any` +slot (bool, int, float, str) and unbox them on read; when it lands this refusal and its test flip. Tests: `tests/test_app_root.py::TheLogic` (11) and `::TheStateFactory` +(4) run against a test-only fake state patched in for `_new_state`; `::TheBinderPath` (5) uses the +real `_new_state` through the binder's Python layer and `tests/fake_host.py`, whose `mutableStateOf` +and `MutableState.value` rows are shaped after #38 (`KotlinSurface.kt`, `PythonxAdapter.kt`), not +walked from a jar. It skips against a binder older than #38. That Compose observes a write and +recomposes is not shown here; it is the E2E module (python-multiplatform #26, issue #19). The stub carries `app`, +`state` and `app_root` (`tests/test_stubs.py`). ### S5.2 Material 3 composables reach Python without per-widget wrappers — `partial` @@ -359,7 +367,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: `state(initial)` read and written through `.value` (S5.4, `partial`, #38); keeping a value across recreation is not provided | +| `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`, #11; an `int` initial value is refused by the binder until python-multiplatform #69); 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` | diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index 3f2e512..985c5e5 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 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 체크아웃이 있거나 다음이 필요합니다:

+

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

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

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 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.

+

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

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

diff --git a/docs/guide/guide-composables.html b/docs/guide/guide-composables.html index a8b4fe2..7d7278c 100644 --- a/docs/guide/guide-composables.html +++ b/docs/guide/guide-composables.html @@ -61,7 +61,7 @@

Declare the app and keep state@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 가 발생합니다.

+

Status: partial. The binder path, which creates the state through Compose's mutableStateOf, is tested against a fake host shaped after python-multiplatform #38; proof against real Compose is still the E2E module. The binder refuses an int in that slot, so state(1) raises a TypeError today.

상태: 부분. Compose 의 mutableStateOf 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 증거는 아직 E2E 모듈입니다. 바인더가 그 칸의 int 를 거부하므로 state(1) 은 현재 TypeError 를 냅니다.

diff --git a/docs/guide/status.html b/docs/guide/status.html index 0ea683f..44cf4c0 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 포함
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, 코루틴 스코프
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 — binder path tested against a fake host shaped after python-multiplatform #38; real Compose is the E2E module (#11, #19)갱신 호출 없는 선언형 앱 루트(@app)와 파이썬다운 상태(state) — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19)
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: 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 이 필요합니다.

+

Test baseline: 106 passed, 78 skipped without a python-multiplatform checkout; 183 passed, 1 skipped with a current one. The one skip with a current checkout reads the maintainer's UI.ipynb.

테스트 기준선: python-multiplatform 체크아웃이 없으면 106 passed, 78 skipped, 최신 체크아웃이면 183 passed, 1 skipped. 최신 체크아웃에서 건너뛰는 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트입니다.

FAQ

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index d173e80..c4ee2f9 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` 포함 | -| 갱신 호출 없는 선언형 앱 루트(`@app`)와 파이썬다운 상태(`state`) | 🟡 부분 — 로직은 테스트됨, 바인더 경로는 python-multiplatform #38 대기(#11) | +| 갱신 호출 없는 선언형 앱 루트(`@app`)와 파이썬다운 상태(`state`) | 🟡 부분 — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19), 바인더가 `Any?` 칸의 int 를 거부해 `state(1)` 은 거부됨 | | `TextField` 입력기(IME) 처리 | ⏳ 계획(#10) | | `Icon`, `DefaultIcons`, 색 스킴 | ⏳ 계획(python-multiplatform #37) | | `remember_saveable`, 코루틴 스코프 | ⏳ 계획 | diff --git a/tests/fake_host.py b/tests/fake_host.py index 3a0c374..8773530 100644 --- a/tests/fake_host.py +++ b/tests/fake_host.py @@ -11,6 +11,19 @@ `PythonxAdapterTest.shapeMatchesTheWalkedEntries`, so these rows are the shape the artefact walker actually produces out of `foundation-layout-desktop-1.6.11.jar` -- not a convenient simplification. +**The state rows are shaped after python-multiplatform `31c092f0` (#38), not transcribed from a +walked jar.** `KotlinSurface.kt` (the describe docs) and `PythonxAdapter.kt` (`_register_table`, +`_read_property`, `_write_property`, `_coerce`) define them, and the fields modelled are: +`androidx.compose.runtime.mutableStateOf` -- `kind` FUNCTION, one slot `value` tagged OBJECT +declared `kotlin.Any` (an unbounded type parameter, the spelling `_KOTLIN_ANY` reads), returning an +OBJECT of `androidx.compose.runtime.MutableState`; `androidx.compose.runtime.MutableState.value` -- +`kind` GETTER, arity 0, `receiver_type_name` `MutableState` (the receiver is *not* a slot, but the +adapter sends its handle in `args[0]`), returning OBJECT `kotlin.Any`; and +`androidx.compose.runtime.MutableState.value=` -- `kind` SETTER, whose leaf ends in `=`, one slot +`value` OBJECT `kotlin.Any`, returning `kotlin.Unit`. A `kotlin.Any` slot carries a Python object as +itself and a Kotlin object as its handle, as `_coerce` does; the stub keeps the Python object. +No walked row for these exists in this repository, so the shape is only as faithful as that reading. + What this does **not** prove: that the Kotlin half marshals correctly, that Compose's own `padding` runs, or that a handle is released on the Kotlin side. Those are `WalkedArtifactComposeModifierTest` and `PythonxAdapterTest`, and they live in the other repository @@ -35,6 +48,10 @@ ALIGNMENT_HORIZONTAL = ALIGNMENT + ".Horizontal" ALIGNMENT_VERTICAL = ALIGNMENT + ".Vertical" +KOTLIN_ANY = "kotlin.Any" +RUNTIME = "androidx.compose.runtime" +MUTABLE_STATE = RUNTIME + ".MutableState" + EMPTY_MODIFIER = "androidx.compose.ui.emptyModifier" """The one entry the real walker does **not** produce; see `ComposeShapedFragment.EMPTY_MODIFIER`. @@ -78,6 +95,20 @@ def __init__(self, label): self.label = label +class StubState: + """A `MutableState` for this fixture: one value, and a log of what was written to it.""" + + __slots__ = ("value", "writes") + + def __init__(self, value): + self.value = value + self.writes = [value] + + def write(self, value): + self.value = value + self.writes.append(value) + + def _dp(value): # `ComposeShapedFragment.dp` is `(value as Double).toString()`, so 16 renders as "16.0". return str(float(value)) @@ -107,15 +138,30 @@ def invoke(self, handle, args): name = self._order[handle] row, body = self._entries[name] tags = row[5] + property_row = row[2] in ("GETTER", "SETTER") + # A property's receiver is not one of its slots (`CallableKind.GETTER`): its handle is + # `args[0]` and the declared slots follow it. + slots = args[1:] if property_row else args unpacked = [ - self._object(value) if tags[index] == "OBJECT" else value - for index, value in enumerate(args) + self._unpack(value, tags[index], row[6][index]) for index, value in enumerate(slots) ] + if property_row: + unpacked.insert(0, self._object(args[0])) result = body(unpacked) - if row[7] == "OBJECT": + if row[7] == "OBJECT" and row[8] != KOTLIN_ANY: return self._handle(result) return result + def _unpack(self, value, tag, type_name): + if tag != "OBJECT": + return value + if type_name == KOTLIN_ANY and not self._is_handle(value): + return value # a Python object crossing an `Any?` slot as itself + return self._object(value) + + def _is_handle(self, value): + return isinstance(value, int) and not isinstance(value, bool) and value in self._handles + def release(self, handle): self.released.append(handle) self._handles.pop(handle, None) @@ -294,6 +340,22 @@ def _build(self): lambda args: StubConstant(f"spacedBy({_dp(args[0])})"), ) + self._add( + # A generic function: `T` is unbounded, so its slot is declared `kotlin.Any`. + f"{RUNTIME}.mutableStateOf", 1, ("value",), ("OBJECT",), (KOTLIN_ANY,), + "OBJECT", MUTABLE_STATE, False, None, (False,), + lambda args: StubState(args[0]), + ) + self._add( + f"{MUTABLE_STATE}.value", 0, (), (), (), "OBJECT", KOTLIN_ANY, False, MUTABLE_STATE, (), + lambda args: args[0].value, kind="GETTER", + ) + self._add( + f"{MUTABLE_STATE}.value=", 1, ("value",), ("OBJECT",), (KOTLIN_ANY,), + "UNIT", "kotlin.Unit", False, MUTABLE_STATE, (False,), + lambda args: args[0].write(args[1]), kind="SETTER", + ) + def _constant(self, name, declared): self._add(name, 0, (), (), (), "OBJECT", declared, False, None, (), lambda args, label=name.rpartition(".")[2]: StubConstant(label), kind="STATIC_GETTER") diff --git a/tests/test_app_root.py b/tests/test_app_root.py index 83bd6f5..0034f3f 100644 --- a/tests/test_app_root.py +++ b/tests/test_app_root.py @@ -8,8 +8,9 @@ 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`. +`TheBinderPath` runs the real `_new_state` through the binder's Python layer and `fake_host.py`, +whose `mutableStateOf` and `MutableState.value` rows are shaped after python-multiplatform +`31c092f0` (#38). It skips only while the installed table binds no `mutableStateOf`. """ from __future__ import annotations @@ -188,12 +189,12 @@ def test_state_and_app_root_use_the_factory_not_a_python_stand_in(self): class TheBinderPath(unittest.TestCase): - """The real `_new_state`, unpatched, through the binder. + """The real `_new_state`, unpatched, through the binder's Python layer and the fake host. - 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). + The host's `mutableStateOf` / `MutableState.value` rows are shaped after python-multiplatform + `31c092f0` (#38), so this proves the Python half: the proxy, the `value` property, the `Any?` + slot. It skips while the installed table binds no `androidx.compose.runtime.mutableStateOf`. + That Compose itself observes the write is the E2E module (python-multiplatform #26, issue #19). """ def setUp(self): @@ -201,6 +202,8 @@ def setUp(self): self.binding = adapter_loader.install() except adapter_loader.AdapterUnavailable as unavailable: self.skipTest(str(unavailable)) + if not hasattr(self.binding, "_PROPERTIES"): + self.skipTest(NEEDS_38 + ": this binder serves no property rows (`MutableState.value`)") self.host = fake_host.FakeHost() self.host.bind() self.host.register(self.binding) @@ -226,10 +229,42 @@ def Screen(): # noqa: F811 self.assertIs(Screen, self.runtime.app_root.value) + def test_app_root_is_a_binder_state_proxy(self): + root = self.runtime.app_root + self.assertEqual("androidx.compose.runtime.MutableState", type(root)._kotlin_type_name) + self.assertIsNone(root.value) + self.assertIs(root, self.runtime.app_root) + + def test_redeclaring_replaces_the_value_in_the_same_host_state(self): + root = self.runtime.app_root + + @self.runtime.app + def Screen(): + return "one" + + @self.runtime.app + def Screen(): # noqa: F811 + return "two" + + self.assertEqual("two", root.value()) + self.assertEqual(3, len(self.host_states()[0].writes)) + + def host_states(self): + return [o for o in self.host._handles.values() if isinstance(o, fake_host.StubState)] + def test_state_round_trips_through_the_binder(self): - counter = self.runtime.state(1) - counter.value = 2 - self.assertEqual(2, counter.value) + label = self.runtime.state("a") + label.value = "b" + self.assertEqual("b", label.value) + self.assertEqual(["a", "b"], self.host_states()[0].writes) + + def test_an_int_state_is_refused_by_the_binder_not_papered_over(self): + # `PythonxAdapter._coerce`: an int in an `Any?` slot would cross as an object handle, so the + # binder refuses it. `state(1)` is therefore an error today; `pythonx` does not box it. + # python-multiplatform #69 boxes scalars in `Any` slots: when it lands, this becomes a round trip. + with self.assertRaises(TypeError) as raised: + self.runtime.state(1) + self.assertIn("int", str(raised.exception)) if __name__ == "__main__":