diff --git a/PROJECT.md b/PROJECT.md index 424419d..befc6a5 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -35,8 +35,8 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **` ``` uv run --with pytest --with mypy pytest tests -q - PythonMultiplatform 체크아웃 없음 148 passed, 113 skipped - python-multiplatform develop 31c092f0+ 260 passed, 1 skipped + PythonMultiplatform 체크아웃 없음 148 passed, 114 skipped + python-multiplatform develop 31c092f0+ 261 passed, 1 skipped describe_member 없는 체크아웃 같은 수, 그중 7 개 건너뜀 (python-multiplatform #54 이전) describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전) 그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (#131 이전, snake_case 이름 없음) diff --git a/README.md b/README.md index 52d19f0..9b90adb 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,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); numbers and strings round-trip through `state` | +| Declared app root (`@app`), no update call; state through Kotlin's `mutable_state_of` | 🟡 partial: binder path tested against a fake host shaped after python-multiplatform #38; against real Compose, python-multiplatform's notebook E2E passes (#11, #19); numbers and strings round-trip | | `TextField(state=...)` with `TextFieldState` (`pythonx.compose.foundation.text.input`) | 🟡 partial: tested against a fake host shaped after python-multiplatform #73; the input-method (IME) composing proof is python-multiplatform E2E #26 (#10) | | `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 a4589a7..44a0bdb 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -21,14 +21,14 @@ Run from a worktree with `uv run --with pytest --with mypy pytest tests -q -rs` | Environment | Result | |---|---| -| No `PythonMultiplatform` checkout found | **148 passed, 113 skipped**, 167 subtests passed | -| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **260 passed, 1 skipped**, 198 subtests passed | +| No `PythonMultiplatform` checkout found | **148 passed, 114 skipped**, 164 subtests passed | +| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **261 passed, 1 skipped**, 195 subtests passed | | an older checkout, before python-multiplatform #131 (no binder-side snake_case names) | the same, with the snake_case method tests 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 | Measured in a worktree, which has `.tmp/kotlin-stubs.zip` but no `UI.ipynb`. Without a checkout, the -112 tests that install the binder's layers through `tests/adapter.py` skip (one of them against the +113 tests that install the binder's layers through `tests/adapter.py` skip (one of them against the installed wheel, #88), plus the notebook test. Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1) skip. Against a binder before python-multiplatform #131, the tests that call a snake_case method or @@ -297,11 +297,14 @@ python-multiplatform #26 diagnosis). `pythonx.compose.runtime` provides: 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)` is **removed** (INTENT §5.11, issue #101; `planned` until that lands): Kotlin's - `mutable_state_of` does the same job. A screen keeps state with `remember_saveable` inside `App` - (S5.6) or with `mutable_state_of`. `_new_state` stays as the internal function behind `app_root`. - -Until #101 lands, both states come from one internal function, `_new_state`. When the binder cannot supply +- `state(initial)` is **removed** (INTENT §5.11, issue #101): Kotlin's `mutable_state_of` does the + same job. A screen keeps state with `remember_saveable` inside `App` (S5.6) or with + `mutable_state_of`. `_new_state` stays as the internal function behind `app_root`. Tests: + `tests/test_app_root.py::TheLogic::test_state_is_not_a_name_of_the_module`, + `::TheBinderPath::test_state_is_not_served_even_with_a_binder`, and the stub check in + `tests/test_stubs.py`. + +`app_root` comes 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. @@ -310,7 +313,7 @@ Evidence: python-multiplatform #38 (`31c092f0`) binds generic functions and prop 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. 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 +on read, so `mutable_state_of(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` diff --git a/docs/guide/guide-composables.html b/docs/guide/guide-composables.html index a9346ea..4dc7c25 100644 --- a/docs/guide/guide-composables.html +++ b/docs/guide/guide-composables.html @@ -51,17 +51,17 @@

Write a screen화면 하나

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

-
from pythonx.compose.runtime import app, state
+
from pythonx.compose.runtime import app, mutable_state_of
 from pythonx.compose.material3 import Text, Button
 
-count = state(0)
+count = mutable_state_of(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 PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root").

@app 은 호스트가 그리는 화면을 선언하고 함수를 그대로 돌려줍니다. 노트북 셀 등에서 다시 선언하면 화면이 바뀌며, 갱신 호출은 없습니다. state(initial) 는 Compose 상태이며 .value 로 읽고 씁니다. 호스트는 PythonAppView(module = "pythonx.compose.runtime", attribute = "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. 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).

+

@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. mutable_state_of(initial) is Compose's own mutableStateOf, read and written through .value; there is no second name for it. The host is configured once with PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root").

@app 은 호스트가 그리는 화면을 선언하고 함수를 그대로 돌려줍니다. 노트북 셀 등에서 다시 선언하면 화면이 바뀌며, 갱신 호출은 없습니다. mutable_state_of(initial) 는 Compose 의 mutableStateOf 그대로이며 .value 로 읽고 씁니다. 다른 이름은 두지 않습니다. 호스트는 PythonAppView(module = "pythonx.compose.runtime", attribute = "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; against real Compose, python-multiplatform's notebook E2E passes. Numbers and strings round-trip: mutable_state_of(0) and count.value += 1 work (python-multiplatform #69).

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

@@ -112,7 +112,6 @@

Spellings that changed바

What does not work yet아직 안 되는 것

  • The colour-scheme factories cannot be called yet: they take 36 colour parameters. DefaultIcons is Icons.Default, an icon is a value (an ImageVector to a type checker) read without parentheses: Icon(DefaultIcons.Add, content_description=None), not the notebook's DefaultIcons.Add(). It is tested here against a fake host only; the real draw is in python-multiplatform's render test.색 스킴 팩토리는 색 매개변수를 36 개 받아 아직 호출할 수 없습니다. DefaultIcons 는 Icons.Default 이고, 아이콘은 괄호 없이 읽는 값(타입 검사기에는 ImageVector)입니다. 노트북의 DefaultIcons.Add() 가 아니라 Icon(DefaultIcons.Add, content_description=None). 여기서는 가짜 호스트로만 테스트되며, 실제 그리기는 python-multiplatform 의 렌더 테스트에 있습니다.
  • -
  • A declared app root and Pythonic state (redefining a screen replaces it, with no update call) are planned (issue #11).선언형 앱 루트와 파이썬다운 상태(화면을 다시 정의하면 교체되고, 갱신 호출은 없음)는 계획 단계입니다(이슈 #11).
  • TextField(state=…) is wired and tested against a fake host only; that the input method's composing sequence survives, with no Python callback per keystroke, is proved in python-multiplatform's end-to-end test (#26).TextField(state=…) 는 가짜 호스트로만 테스트되었습니다. 입력기의 조합 입력이 키 입력마다 파이썬 콜백 없이 유지되는지는 python-multiplatform 의 종단 간 테스트(#26)에서 증명합니다.
  • remember_saveable and the coroutine scopes the notebook imports are planned.노트북이 import 하는 remember_saveable, 코루틴 스코프는 계획 단계입니다.
diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index e9f324c..baf025a 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -167,7 +167,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), 숫자와 문자열은 `state` 로 왕복됨 | +| 갱신 호출 없는 선언형 앱 루트(`@app`), 상태는 Kotlin 의 `mutable_state_of` | 🟡 부분: 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 에서는 python-multiplatform 노트북 E2E 가 통과(#11, #19), 숫자와 문자열은 왕복됨 | | `TextField(state=...)` 와 `TextFieldState` (`pythonx.compose.foundation.text.input`) | 🟡 부분: python-multiplatform #73 모양의 가짜 호스트로 테스트됨; 입력기(IME) 조합 증거는 python-multiplatform E2E #26 (#10) | | `DefaultIcons`(`Icons.Default`), `DefaultIcons.Add` 로 씀 | 🟡 부분: python-multiplatform #37/#38 모양의 가짜 호스트로 테스트됨; `Icon(DefaultIcons.Add, …)` 의 실제 그리기는 python-multiplatform 의 렌더 테스트에 있음 | | 색 스킴 | ⏳ 계획 | diff --git a/docs/style/writing.md b/docs/style/writing.md index 5dc39f9..3a719e8 100644 --- a/docs/style/writing.md +++ b/docs/style/writing.md @@ -32,7 +32,7 @@ visible string of the guide in `data-lang="en"` and `data-lang="ko"`). use the plain declarative, 한다체: "한다", "두었다". - **Terms.** Code identifiers, Kotlin names, commands and file names stay in their original spelling inside code formatting, in both languages. A particle follows code with a space: "`UI.ipynb` 를", - "`state()` 로". Common technical words are written as Korean loanwords when that is how Korean + "`mutable_state_of()` 로". Common technical words are written as Korean loanwords when that is how Korean developers say them ("바인더", "스텁", "컴포저블", "모듈"); otherwise the English term stays ("snake_case", "wheel", "proxy"). - **Status words** pair one to one: implemented / 구현, partial / 부분, planned / 계획. diff --git a/pythonx/compose/runtime/__init__.py b/pythonx/compose/runtime/__init__.py index 429bae3..3b7393c 100644 --- a/pythonx/compose/runtime/__init__.py +++ b/pythonx/compose/runtime/__init__.py @@ -35,21 +35,22 @@ slot detection; `@Composable` changes nothing about that and exists only so the notebook's own spelling keeps working. -## The app root and `state` +## The app root -`@app` declares the screen the host draws; `state(initial)` makes a value Compose observes. The host -is configured once with +`@app` declares the screen the host draws. The host is configured once with `PythonAppView(module = "pythonx.compose.runtime", attribute = "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. +`app_root` comes from `_new_state`, the internal function that asks the binder for +`androidx.compose.runtime.mutableStateOf`; it is created on first read, because the binder may be +installed after `pythonx` is imported. A screen's own state is not a name of this package: it is +Kotlin's `mutable_state_of`, re-exported by the rule, or `remember_saveable` (`docs/INTENT.md` +sections 5.9 and 5.11; the former `state()` is removed, issue #101). 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 @@ -99,11 +100,6 @@ def _new_state(initial): 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. diff --git a/pythonx/compose/runtime/__init__.pyi b/pythonx/compose/runtime/__init__.pyi index 45b26b5..bde6df7 100644 --- a/pythonx/compose/runtime/__init__.pyi +++ b/pythonx/compose/runtime/__init__.pyi @@ -173,8 +173,5 @@ 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 index 70b4278..7335b9d 100644 --- a/tests/test_app_root.py +++ b/tests/test_app_root.py @@ -1,4 +1,4 @@ -"""`@app`, `state` and `app_root` in `pythonx.compose.runtime` (issue #11, docs/INTENT.md section 5.1). +"""`@app` and `app_root` in `pythonx.compose.runtime` (issue #11, docs/INTENT.md section 5.1). The host draws with `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`: it reads the module @@ -12,6 +12,9 @@ `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`. + +`state()` is gone (INTENT section 5.11, issue #101): Kotlin's `mutable_state_of` does the same job, so +a screen's own state is `mutable_state_of` or `remember_saveable`, and `_new_state` stays internal. """ from __future__ import annotations @@ -113,14 +116,14 @@ def Screen(): # noqa: F811 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_state_is_not_a_name_of_the_module(self): + # INTENT 5.11: no alias and no shim; `mutable_state_of` is Kotlin's own name for it. + self.assertNotIn("state", vars(self.runtime)) + self.assertNotIn("state", dir(self.runtime)) - def test_each_state_call_makes_its_own_state(self): - self.assertIsNot(self.runtime.state(0), self.runtime.state(0)) + def test_the_internal_factory_is_not_exported(self): + public = {n for n in dir(self.runtime) if not n.startswith("_")} + self.assertNotIn("new_state", public) def test_no_update_or_refresh_name_is_public(self): public = [n for n in dir(self.runtime) if not n.startswith("_")] @@ -131,7 +134,7 @@ def test_no_update_or_refresh_name_is_public(self): self.assertNotIn(banned, vars(self.runtime)) def test_dir_lists_the_new_names(self): - for name in ("app", "state", "app_root", "Composable"): + for name in ("app", "app_root", "Composable"): self.assertIn(name, dir(self.runtime)) def test_unknown_names_still_go_to_the_reexport_path(self): @@ -180,10 +183,8 @@ def test_no_binder_at_all_raises_pointing_at_38(self): 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): + def test_app_root_uses_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") @@ -253,8 +254,15 @@ def Screen(): # noqa: F811 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): - label = self.runtime.state("a") + def test_state_is_not_served_even_with_a_binder(self): + with self.assertRaises(AttributeError): + self.runtime.state # noqa: B018 + with self.assertRaises(ImportError): + from pythonx.compose.runtime import state # noqa: F401 + + def test_mutable_state_of_round_trips_through_the_binder(self): + # The Kotlin name a screen uses for its own state now that `state()` is gone (INTENT 5.11). + label = self.runtime.mutable_state_of("a") label.value = "b" self.assertEqual("b", label.value) self.assertEqual(["a", "b"], self.host_states()[0].writes) @@ -266,13 +274,13 @@ def test_scalars_round_trip_through_a_kotlin_any(self): """ if not hasattr(self.binding, "_box_scalar"): self.skipTest("needs python-multiplatform #69 (scalar boxing for Any slots)") - counter = self.runtime.state(0) + counter = self.runtime.mutable_state_of(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) + holder = self.runtime.mutable_state_of(value) self.assertEqual(value, holder.value) self.assertIs(type(value), type(holder.value)) @@ -280,7 +288,7 @@ 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(2**64) + self.runtime.mutable_state_of(2**64) self.assertIn("64 bits", str(raised.exception)) diff --git a/tests/test_stubs.py b/tests/test_stubs.py index 3f28c57..4c61b92 100644 --- a/tests/test_stubs.py +++ b/tests/test_stubs.py @@ -147,7 +147,7 @@ def test_a_public_annotated_name_the_package_declares_is_carried_over(self): 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) + self.assertNotIn("def state(", runtime, "state() is removed (INTENT 5.11)") def test_every_stub_is_valid_python(self): for path, text in self.stubs.items():