Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 없음)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 7 additions & 7 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/guide-composables.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h2><span data-lang="en">Declare the app and keep state</span><span data-lang="k
Button(on_click=lambda: setattr(count, &quot;value&quot;, count.value + 1),
content=lambda: Text(f&quot;Clicked {count.value}&quot;))</code></pre>
<p data-lang="en"><code>@app</code> 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. <code>state(initial)</code> is a Compose state, read and written through <code>.value</code>. The host is configured once with <code>PythonContent(&quot;pythonx.compose.runtime&quot;, &quot;app_root&quot;)</code>.</p><p data-lang="ko"><code>@app</code> 은 호스트가 그리는 화면을 선언하고 함수를 그대로 돌려줍니다. 노트북 셀 등에서 다시 선언하면 화면이 바뀌며, 갱신 호출은 없습니다. <code>state(initial)</code> 는 Compose 상태이며 <code>.value</code> 로 읽고 씁니다. 호스트는 <code>PythonContent(&quot;pythonx.compose.runtime&quot;, &quot;app_root&quot;)</code> 로 한 번만 설정합니다.</p>
<p data-lang="en"><strong>Status: partial.</strong> The binder path, which creates the state through Compose's <code>mutableStateOf</code>, is tested against a fake host shaped after python-multiplatform #38; proof against real Compose is still the E2E module. The binder refuses an <code>int</code> in that slot, so <code>state(1)</code> raises a <code>TypeError</code> today.</p><p data-lang="ko"><strong>상태: 부분.</strong> Compose 의 <code>mutableStateOf</code> 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 증거는 아직 E2E 모듈입니다. 바인더가 그 칸의 <code>int</code> 를 거부하므로 <code>state(1)</code> 은 현재 <code>TypeError</code> 를 냅니다.</p>
<p data-lang="en"><strong>Status: partial.</strong> The binder path, which creates the state through Compose's <code>mutableStateOf</code>, 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: <code>state(0)</code> and <code>count.value += 1</code> work (python-multiplatform #69).</p><p data-lang="ko"><strong>상태: 부분.</strong> Compose 의 <code>mutableStateOf</code> 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 증거는 아직 E2E 모듈입니다. 숫자와 문자열은 왕복됩니다: <code>state(0)</code> 과 <code>count.value += 1</code> 이 동작합니다(python-multiplatform #69).</p>
</section>

<section>
Expand Down
2 changes: 1 addition & 1 deletion docs/locale/README_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 의 렌더 테스트에 있음 |
| 색 스킴 | ⏳ 계획 |
Expand Down
38 changes: 36 additions & 2 deletions tests/fake_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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")
28 changes: 22 additions & 6 deletions tests/test_app_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
Loading