From d1c21a5fd777559f16db059a86bf3f42879eb9f4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:58:39 +0900 Subject: [PATCH 1/2] Test: Exercise app_root and state() through the binder's real state path python-multiplatform #38 (PR #65, develop 31c092f0) binds mutableStateOf and MutableState.value's getter and setter. The fake host now carries those three rows in the shape that version's KotlinSurface.kt and PythonxAdapter.kt describe (a generic FUNCTION whose value slot is kotlin.Any, a GETTER and a SETTER whose leaf ends in '=', both on the MutableState receiver), backed by a stub state that logs its writes. TheBinderPath now runs: app_root is the binder's MutableState proxy, @app writes its value, redeclaring writes to the same state, and a string round-trips through .value. It also found that state(1) is refused: the binding layer will not put a Python int in a kotlin.Any slot, because it would cross as an object handle. A counter like state(0) does not work today. That is the binder's coercion, not this package's; it is pinned by a test and recorded in SPEC S5.4, and reported upstream. runtime/__init__.py is unchanged -- the real proxy and a callable in the Any slot worked as written. The rows are read from the binder's source, not from a walked jar, so they are only as faithful as that reading; fake_host.py says so. The real-Compose proof is still the E2E module (python-multiplatform #26, issue #19). Drafted by a subagent in a worktree; reviewed here. test added: TheBinderPath grows from 2 to 5 (the proxy, the same-state redeclaration, a string round trip, the int refusal); it skips on a binder older than #38. Red: 1 failed (the int TypeError) with the old test. Making the SETTER a no-op fails 4; renaming the mutableStateOf row skips 5. documentation corrected: SPEC 0/S5.4/8, PROJECT.md, guide, README and README_ko (the int refusal; a stale Korean baseline in status.html). Suite: with python-multiplatform 31c092f0, 177 passed, 4 skipped -> 182 passed, 2 skipped; without a checkout, 105 passed, 76 skipped -> 105 passed, 79 skipped. check_guide.py: 0. Refs #11 --- PROJECT.md | 6 +-- README.md | 2 +- docs/SPEC.md | 29 ++++++++----- docs/guide/getting-started.html | 4 +- docs/guide/guide-composables.html | 2 +- docs/guide/status.html | 4 +- docs/locale/README_ko.md | 2 +- tests/fake_host.py | 68 +++++++++++++++++++++++++++++-- tests/test_app_root.py | 54 +++++++++++++++++++----- 9 files changed, 137 insertions(+), 34 deletions(-) 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..094b747 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,17 @@ 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. 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 +366,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, #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` | 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 @@
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 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.
@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 를 냅니다.
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 를 읽는 테스트입니다.