From 645f622331e80c5af90dac2de2ce865a0b52c95f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:37:46 +0900 Subject: [PATCH] Test: Round-trip numbers through state() now that the binder boxes them python-multiplatform #69 (PR #81, develop 26485a02) boxes a Python scalar bound for a kotlin.Any slot by calling a PythonCallables box entry, and unboxes a handle read back out of one through unboxScalar. So state(0) and counter.value += 1 work; an int beyond 64 bits is refused with the reason. The test that pinned the old refusal is flipped, as its comment promised. The fake host gains the six entries in PythonCallables.kt's shape (boxInt, boxLong, boxDouble, boxBoolean, boxString: one primitive slot, the box's handle back as INT; unboxScalar: the handle in, the scalar or None out, releasing it), and a scalar held in a kotlin.Any now crosses back as a handle, as a Kotlin box would. Against a binder older than #69 the two scalar tests skip. runtime/__init__.py is unchanged: the boxing is the binder's. test changed: the refusal test becomes test_scalars_round_trip_through_a_kotlin_any (bool, int, float, str, and value += 1); test added: test_an_int_beyond_64_bits_is_refused_with_the_reason. Red against 26485a02 before the fake host change: 2 failed (no exposed declaration ...boxInt / ...boxString), then the old refusal test failed. documentation corrected: SPEC S5.4 and section 8, PROJECT.md, guide, README and README_ko; baselines. Suite with python-multiplatform 26485a02 (main checkout, notebook and zip): 223 passed; without a checkout, 125 passed, 98 skipped. check_guide.py: 0. Refs #51 Refs #11 --- PROJECT.md | 6 ++--- README.md | 2 +- docs/SPEC.md | 14 ++++++------ docs/guide/guide-composables.html | 2 +- docs/locale/README_ko.md | 2 +- tests/fake_host.py | 38 +++++++++++++++++++++++++++++-- tests/test_app_root.py | 28 ++++++++++++++++++----- 7 files changed, 71 insertions(+), 21 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index c44c2f3..2597693 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 메서드의 snake_case 키워드 인자(`m.padding(padding_values=...)`, python-multiplatform `describe_member` 필요; Kotlin 이름도 실행 시 그대로 통하고 스텁은 snake_case 만) | -| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 E2E 모듈 #19, 바인더가 `Any?` 칸의 int 를 거부하므로 `state(1)` 은 아직 TypeError, python-multiplatform #69 대기), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨; `DefaultIcons.Add` 로 쓴다 — 노트북의 `DefaultIcons.Add()` 가 아님) | +| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 E2E 모듈 #19; 숫자·문자열 상태는 python-multiplatform #69 부터 왕복됨), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨; `DefaultIcons.Add` 로 쓴다 — 노트북의 `DefaultIcons.Add()` 가 아님) | | 계획 | `remember_saveable`·색 스킴·코루틴 스코프 | ### 테스트 기준선 ``` python3 -m pytest tests -q - PythonMultiplatform 체크아웃 없음 124 passed, 98 skipped - python-multiplatform develop 31c092f0+ 221 passed, 1 skipped + PythonMultiplatform 체크아웃 없음 124 passed, 99 skipped + python-multiplatform develop 31c092f0+ 222 passed, 1 skipped describe_member 없는 체크아웃 같은 수, 그중 7 개 건너뜀 (python-multiplatform #54 이전) describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전) 그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (member resolver 없음) diff --git a/README.md b/README.md index d499e8d..edef549 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,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` (#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 — 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) | +| 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); numbers and strings round-trip through `state` | | `TextField` input-method (IME) handling | ⏳ planned (#10) | | `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 | | Colour schemes | ⏳ planned | diff --git a/docs/SPEC.md b/docs/SPEC.md index 432b606..60bee89 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -21,8 +21,8 @@ Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CP | Environment | Result | |---|---| -| No `PythonMultiplatform` checkout found | **124 passed, 98 skipped**, 157 subtests passed | -| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **221 passed, 1 skipped**, 184 subtests passed | +| No `PythonMultiplatform` checkout found | **124 passed, 99 skipped**, 157 subtests passed | +| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **222 passed, 1 skipped**, 184 subtests passed | | an older checkout, without `describe_member` (python-multiplatform #54) | the same, with 7 more skipped | | an older one, without `describe(module, name)` (python-multiplatform #36) | with 13 more skipped again | | an older one still, without `add_member_resolver` | with 5 more skipped again | @@ -261,10 +261,10 @@ re-export rule, and the module's own names win. 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` +the returned `MutableState`'s `.value` reads and writes. Since python-multiplatform #69 (PR #81) the +binder boxes a Python scalar for an `Any` slot (bool, int within 64 bits, float, str) and unboxes it +on read, so `state(0)` and `counter.value += 1` work; an int beyond 64 bits is refused with the +reason. `pythonx` does none of this itself. 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 @@ -431,7 +431,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`, #11; an `int` initial value is refused by the binder until python-multiplatform #69); 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; scalars round-trip since 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, `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) | | `modifier` | lower-case instance from `pythonx.compose.ui` | INTENT §5.4: not provided; written `Modifier` | diff --git a/docs/guide/guide-composables.html b/docs/guide/guide-composables.html index aafed8d..70adede 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 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 를 냅니다.

+

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. Numbers and strings round-trip: state(0) and count.value += 1 work (python-multiplatform #69).

상태: 부분. Compose 의 mutableStateOf 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 증거는 아직 E2E 모듈입니다. 숫자와 문자열은 왕복됩니다: state(0) 과 count.value += 1 이 동작합니다(python-multiplatform #69).

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index 4470274..8707409 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -155,7 +155,7 @@ pip 패키지 **`pythonx-compose`**(파이썬 3.11 이상)로 배포되며, impo | Material 3 위젯 (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 부분 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) | | 실제 Compose 1.11.1 에서 생성한 타입 스텁(`.pyi`), `py.typed` | ✅ 구현, mypy 로 검사; 아직 많은 타입이 `Any` 입니다(#12) | | 배포 (`pythonx-compose`) | 🟡 부분 — wheel 에 매니페스트, 재노출 규칙, 스텁, `py.typed` 포함 | -| 갱신 호출 없는 선언형 앱 루트(`@app`)와 파이썬다운 상태(`state`) | 🟡 부분 — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19), 바인더가 `Any?` 칸의 int 를 거부해 `state(1)` 은 거부됨 | +| 갱신 호출 없는 선언형 앱 루트(`@app`)와 파이썬다운 상태(`state`) | 🟡 부분 — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19), 숫자와 문자열은 `state` 로 왕복됨 | | `TextField` 입력기(IME) 처리 | ⏳ 계획(#10) | | `DefaultIcons`(`Icons.Default`), `DefaultIcons.Add` 로 씀 | 🟡 부분 — python-multiplatform #37/#38 모양의 가짜 호스트로 테스트됨; `Icon(DefaultIcons.Add, …)` 의 실제 그리기는 python-multiplatform 의 렌더 테스트에 있음 | | 색 스킴 | ⏳ 계획 | diff --git a/tests/fake_host.py b/tests/fake_host.py index ca8a880..068f0a9 100644 --- a/tests/fake_host.py +++ b/tests/fake_host.py @@ -160,8 +160,15 @@ def invoke(self, handle, args): if property_row: unpacked.insert(0, self._object(args[0])) result = body(unpacked) - if row[7] == "OBJECT" and row[8] != KOTLIN_ANY: - return self._handle(result) + if row[7] == "OBJECT": + if row[8] is None: + return result # `unboxScalar`: a Python scalar or None, never a handle + if row[8] != KOTLIN_ANY: + return self._handle(result) + if isinstance(result, (bool, int, float, str)): + # A scalar held in a `kotlin.Any` is a Kotlin box, so it crosses as a handle and the + # binding layer unboxes it through `unboxScalar` (python-multiplatform #69). + return self._handle(result) return result def _unpack(self, value, tag, type_name): @@ -253,6 +260,7 @@ def body(receiver, args): return body def _build(self): + self._scalar_boxes() self._add( EMPTY_MODIFIER, 0, (), (), (), "OBJECT", MODIFIER, False, None, (), lambda args: StubModifier(), @@ -387,6 +395,32 @@ def _read_icon(self, icon): self.calls.append(f"Icons.Filled.{icon}") return StubConstant(icon) + def _scalar_boxes(self): + """`PythonCallables`' box entries, the shape python-multiplatform 26485a02 (#69) registers. + + `PythonxAdapter._box_scalar` turns a Python scalar bound for a `kotlin.Any` slot into the + handle of a Kotlin box by calling one of these; `unboxScalar` turns a handle read back out of + such a slot into the scalar again, releasing it, or answers None for any other object. + Names and fields are those of `PythonCallables.kt` (`boxEntry`, `UNBOX_SCALAR`). + """ + prefix = "python.multiplatform.ffi.pythonx.PythonCallables." + for leaf, tag, type_name in ( + ("boxInt", "INT", "kotlin.Int"), ("boxLong", "INT", "kotlin.Long"), + ("boxDouble", "FLOAT", "kotlin.Double"), ("boxBoolean", "BOOLEAN", "kotlin.Boolean"), + ("boxString", "STRING", "kotlin.String"), + ): + self._add(prefix + leaf, 1, ("value",), (tag,), (type_name,), "INT", None, False, None, + (False,), lambda args: self._handle(args[0])) + self._add(prefix + "unboxScalar", 1, ("handle",), ("INT",), ("kotlin.Long",), "OBJECT", None, + False, None, (False,), self._unbox) + + def _unbox(self, args): + held = self._handles.get(args[0]) + if not isinstance(held, (bool, int, float, str)): + return None + self.release(args[0]) + return held + 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 0034f3f..2830cc0 100644 --- a/tests/test_app_root.py +++ b/tests/test_app_root.py @@ -258,13 +258,29 @@ def test_state_round_trips_through_the_binder(self): 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. + def test_scalars_round_trip_through_a_kotlin_any(self): + """python-multiplatform #69 boxes a scalar for an `Any` slot and unboxes it on read. + + This used to pin the refusal (`state(1)` was a TypeError); #69 is what reversed it. + """ + if not hasattr(self.binding, "_box_scalar"): + self.skipTest("needs python-multiplatform #69 (scalar boxing for Any slots)") + counter = self.runtime.state(0) + self.assertEqual(0, counter.value) + counter.value = counter.value + 1 + self.assertEqual(1, counter.value) + for value in (True, 2**40, 1.5, "text"): + with self.subTest(value=value): + holder = self.runtime.state(value) + self.assertEqual(value, holder.value) + self.assertIs(type(value), type(holder.value)) + + def test_an_int_beyond_64_bits_is_refused_with_the_reason(self): + if not hasattr(self.binding, "_box_scalar"): + self.skipTest("needs python-multiplatform #69 (scalar boxing for Any slots)") with self.assertRaises(TypeError) as raised: - self.runtime.state(1) - self.assertIn("int", str(raised.exception)) + self.runtime.state(2**64) + self.assertIn("64 bits", str(raised.exception)) if __name__ == "__main__":