diff --git a/AGENTS.md b/AGENTS.md index 99af3df..cc11c49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,8 +188,9 @@ worktree so a test can see it. Read it in place. `.tmp/` (rule 2), not in the home directory. - Tests that exercise the binder's adaptation layer read it, read-only, from a `PythonMultiplatform` checkout: the sibling directory `../PythonMultiplatform`, or `PYTHONMULTIPLATFORM_HOME`. Without - one they **skip**; with a checkout older than python-multiplatform `ba4c6f49`, 5 of them skip for - want of its member resolver (see `docs/SPEC.md` §0). Report both numbers and say which environment you ran in. A skip is not + one they **skip**; against an older python-multiplatform some still skip, each saying which binder + feature it needs (the member resolver, `ba4c6f49`; `describe(module, name)`, #36; see + `docs/SPEC.md` §0). Report both numbers and say which environment you ran in. A skip is not a pass. - The suite runs on GitHub Actions (`.github/workflows/tests.yml`) in both environments, without and with a python-multiplatform checkout, for every pull request into `develop`. Local runs are for diff --git a/PROJECT.md b/PROJECT.md index 8dd2b1b..0bc271a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -27,22 +27,24 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **` | 상태 | 항목 | |---|---| -| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1 에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사) | -| 부분 | 배포 설정, proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨), `Alignment`/`Arrangement` 상수(`pythonx` 에서 읽힘; 묶음 표기는 python-multiplatform #36 대기) | +| 구현 | 매핑 매니페스트 `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 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨) | | 계획 | 선언형 앱 루트(#11), `remember_saveable`·`DefaultIcons`·코루틴 스코프 | ### 테스트 기준선 ``` python3 -m pytest tests -q - PythonMultiplatform 체크아웃 없음 84 passed, 60 skipped - python-multiplatform develop ba4c6f49+ 143 passed, 1 skipped - 그보다 오래된 체크아웃 같은 수, 그중 5 개 건너뜀 (member resolver 없음) + PythonMultiplatform 체크아웃 없음 91 passed, 73 skipped + python-multiplatform develop d6d39787+ 163 passed, 1 skipped + describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전) + 그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (member resolver 없음) ``` +`describe(module, name)`(python-multiplatform #36)이 없는 바인더에서는 묶음 상수를 확인하는 13 개가, member resolver(python-multiplatform `ba4c6f49`)가 없는 바인더에서는 proxy 의 snake_case 메서드를 부르는 -5 개가 건너뛴다. 두 환경 모두의 건너뜀 1 개는 worktree 에 없는 `UI.ipynb` 의 테스트다. 건너뜀은 통과가 아니다. -체크아웃 없이 통과하는 84 개 중 30 개는 타입 스텁과 wheel 을 검사하고(SPEC S1.2), 나머지 다수는 *부재*(옛 토큰·삭제된 +5 개가 더 건너뛴다. 두 환경 모두의 건너뜀 1 개는 worktree 에 없는 `UI.ipynb` 의 테스트다. 건너뜀은 통과가 아니다. +체크아웃 없이 통과하는 91 개 중 37 개는 타입 스텁과 wheel 을 검사하고(SPEC S1.2), 나머지 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지 않는다. ### 마일스톤 diff --git a/README.md b/README.md index ed14c27..eca187c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ proofs are still pending. See [Status](#-status). exactly as it does in Kotlin. Keyword arguments to such a method are still Kotlin's own (`m.padding(paddingValues=...)`); the module function takes `padding(m, padding_values=...)`. - **Kotlin objects are namespaces.** `Alignment.Center` and `Arrangement.End` are read without - parentheses; functions inside them are `snake_case`: `Arrangement.spaced_by(8)`. + parentheses; functions inside them are `snake_case`: `Arrangement.spaced_by(8)`. Constants can + also be reached grouped by their declared type, as the notebook writes them: + `Alignment.Horizontal.End` is `Alignment.End`. - **`@Composable` stays.** Screens are decorated Python functions. - **A real package.** `pythonx/` is ordinary Python source that imports `androidx.compose.*` and reshapes it. The binder never renames anything — renaming is this package's job. @@ -150,7 +152,7 @@ standalone desktop toolkit. | `Column`, `Row`, `Spacer` from `material3` as well as `layout` | ✅ implemented and tested | | Method keyword arguments in `snake_case` | 🟡 partial — module functions yes; methods still take Kotlin's keywords | | The empty `Modifier` | 🟡 partial — `Modifier.padding(16)` from the class needs an app-supplied factory against real Compose | -| `Alignment` / `Arrangement` | 🟡 partial — `Alignment.Center`, `Arrangement.spaced_by(8)` work; grouped `Alignment.Horizontal.End` pending | +| `Alignment` / `Arrangement` | ✅ implemented and tested — `Alignment.Center`, `Arrangement.spaced_by(8)`, and grouped `Alignment.Horizontal.End` beside `Alignment.End` | | 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` | diff --git a/docs/SPEC.md b/docs/SPEC.md index b48368d..2f8796c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -14,24 +14,26 @@ Each item carries a status: A behaviour change starts here, then becomes a failing test, then code (`AGENTS.md` §5). -## 0. Test baseline (2026-10-03, after issue #12) +## 0. Test baseline (2026-10-03, after issue #9's grouped constants) Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CPython 3.13), where `UI.ipynb` is absent and its one test skips: | Environment | Result | |---|---| -| No `PythonMultiplatform` checkout found | **84 passed, 60 skipped**, 115 subtests passed | -| python-multiplatform `develop` at `ba4c6f49` or later (has `add_member_resolver`) | **143 passed, 1 skipped**, 139 subtests passed | -| an older checkout, without `add_member_resolver` | the same, with 5 more skipped | - -Without a checkout, the 59 tests that install the binder's layers through `tests/adapter.py` skip. -Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the binder -returned skip, because that needs its member resolver (python-multiplatform #17). The 1 skip in both +| No `PythonMultiplatform` checkout found | **91 passed, 73 skipped**, 122 subtests passed | +| python-multiplatform `develop` at `d6d39787` or later (has `add_member_resolver` and `describe(module, name)`) | **163 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. +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 1 skip in both rows is `UI.ipynb`'s (§2). `tests/test_typing.py` skips where mypy is not installed. A skip is not a pass. -Of the 84 that pass without a checkout, 30 check the type stubs and the wheel (S1.2); most of the +Of the 91 that pass without a checkout, 37 check the type stubs and the wheel (S1.2); most of the rest assert **absence** (a retired token, a deleted file, a docstring that exists). Those are listed in §9 and are not counted as features. @@ -261,7 +263,7 @@ a name is adapted once and then lives in the module dict; `dir()` reports the Py without the binding layer a name read says the host never installed it; dropping a proxy releases its Kotlin handle. Evidence: `tests/test_chain.py::Laziness` (5 tests), `::Handles` (1 test). -## 7. Layout constants — `Alignment` and `Arrangement` — `partial` +## 7. Layout constants — `Alignment` and `Arrangement` — `implemented` The bound constant names are documented in `pythonx/compose/ui/alignment.py` (15 `Alignment` names) and `pythonx/compose/layout/arrangement.py` (8 `Arrangement` names), checked by @@ -277,11 +279,55 @@ the Kotlin module does not list is tried as a Kotlin object sub-package and serv `KotlinObject` namespace by the §3 rule. Constants keep their Kotlin spelling and are read again on every access; a function inside the object is snake_case (`Arrangement.spaced_by`). Evidence: `tests/test_chain.py::ObjectNamespaces` (6 tests); without the sub-package step 6 fail. The binder -will list these objects itself (python-multiplatform #35). - -Decided (INTENT §5.3) and not built: the notebook's grouping by type, `Alignment.Horizontal.End`. -It needs a constant's declared type without reading it, `python_multiplatform.describe(module, -name)` (python-multiplatform #36; issue #9). +now lists these objects itself (python-multiplatform #35), and either way reaches them. + +### S7.1 Grouped constants — `Alignment.Horizontal.End` — `implemented` + +INTENT §5.3: the notebook groups constants by type, `Alignment.Horizontal.End`, and Kotlin writes +them flat, `Alignment.End` (which *is* an `Alignment.Horizontal`). Both spellings are served, by one +rule that names no declaration: + +- Inside the `KotlinObject` for a Kotlin object `P`, an upper-case name `G` that is **not** a member + of `P` but is the simple name of a type nested in `P` -- some constant of `P` is declared as + exactly `P.G` -- is a **group**: a namespace whose members are exactly the constants of `P` whose + declared type is `P.G`. `Alignment.Horizontal` holds `CenterHorizontally`, `End`, `Start`; + `Alignment.Vertical` holds `Bottom`, `CenterVertically`, `Top`; `Arrangement.HorizontalOrVertical` + holds `Center`, `SpaceAround`, `SpaceBetween`, `SpaceEvenly`. +- A constant's declared type is read with `python_multiplatform.describe(module, name)` + (python-multiplatform #36), which answers from the binder's table and never runs the getter. A + constant's value is never read to classify it. The classification is metadata and is computed + once per object; values are not cached. +- Reading a group member reads that constant then and there, exactly as the flat spelling does: + `Alignment.Horizontal.End` and `Alignment.End` are the same read of the same Kotlin getter. +- **A group holds a constant only under its exact declared type.** A constant declared as a + supertype is in no narrower group: `Alignment.Center` (declared `Alignment`) is in none, and + `Arrangement.SpaceBetween` (declared `Arrangement.HorizontalOrVertical`) is in + `Arrangement.HorizontalOrVertical` only, not in `Arrangement.Horizontal`, although Kotlin accepts + it wherever an `Arrangement.Horizontal` is expected. The notebook groups by declared type, and the + declared type is what the binder describes; subtyping is not modelled. +- **Kotlin members win.** If `P` has a member named `G` (a constant, a function, a nested object + the binder lists), that member is what `P.G` reads, and there is no group of that name. +- `dir()` of an object lists its group names next to its members; `dir()` of a group lists its + constants. A name a group does not hold raises `AttributeError` naming the group's Kotlin type + (`androidx.compose.ui.Alignment.Horizontal`), so `Alignment.Horizontal.Top` fails: `Top` is an + `Alignment.Vertical`. +- On a binder without `describe(module, name)` there are no groups; the flat spelling is unchanged. +- **Stubs.** `tools/gen_stubs.py` emits each group as a class nested in the object's stub class, + holding the same `ClassVar` constants, grouped by the declared type the upstream stub's docstring + carries (`"""Kotlin: androidx.compose.ui.Alignment.End(): androidx.compose.ui.Alignment.Horizontal"""`), + so a type checker accepts `Alignment.Horizontal.End` and rejects `Alignment.Horizontal.Top`. A + reference to the Kotlin type `Alignment.Horizontal` in a signature stays `typing.Any`: the group + class is a namespace, not the type of the constants. + +Evidence: `tests/test_chain.py::GroupedObjectConstants` (12 tests, runtime, against the fake host; +with the group lookup disabled 11 fail, and the one that stays green checks the binder without +`describe(module, name)`), `tests/test_stubs.py::TheConstantGroups` (5 tests, generator), +`::TheCommittedStubs::test_the_committed_alignment_and_arrangement_carry_their_groups`, +`::TheStubMatchesTheRuntime::test_a_group_in_the_stub_holds_what_the_runtime_group_holds`, and +`tests/test_typing.py::TheStubsTypeCheck::test_a_grouped_constant_type_checks` (mypy). In the real +`kotlin-stubs` artefact only `Alignment` and `Arrangement` have such groups. Nothing in the walked +Compose surface is an extension receiver named `Alignment.Horizontal` or `Arrangement.Horizontal`, so +the binder lists no member that would win over these groups. ## 8. The notebook surface not yet covered — `planned` @@ -330,7 +376,7 @@ Found in the repository; not covered by `docs/INTENT.md`, or in conflict with it `pythonx.compose.foundation.layout` (plus `pythonx.compose.foundation` and `pythonx.compose`) "while the spelling settles". 5. *(Resolved, #31.)* `material3/icon.py` and `color_scheme.py`, dead reflection code, are deleted. -6. *(Decided, INTENT §5.3.)* Grouped alignment constants: both spellings are to be served; the grouped one is not built yet (§7). +6. *(Resolved, INTENT §5.3, issue #9.)* Grouped alignment constants: both spellings are served (§7, S7.1). 7. The submodule `pythonx/compose/native` → `thisisthepy/swing-graalvm-demo`, which INTENT does not mention. (The 29 empty `material3/*.py` files are deleted, #31.) 8. **The `test/` directory** — a 2023–2024 Kotlin Multiplatform sample (`pycomposeui`, chaquopy diff --git a/docs/guide/concepts.html b/docs/guide/concepts.html index e33a507..abc4f25 100644 --- a/docs/guide/concepts.html +++ b/docs/guide/concepts.html @@ -80,8 +80,9 @@

Kotlin objectsKotlin 객체 Alignment.Center Arrangement.End -Arrangement.spaced_by(8) -

The notebook's grouping by type, Alignment.Horizontal.End, is decided but not built yet; write Alignment.End.

노트북의 타입별 묶음 표기 Alignment.Horizontal.End 는 결정되었지만 아직 만들어지지 않았습니다. Alignment.End 로 쓰세요.

+Arrangement.spaced_by(8) +Alignment.Horizontal.End # the same constant as Alignment.End +

A constant can also be read grouped by its declared type, as the notebook writes it: Alignment.Horizontal.End is Alignment.End, read the same way. A group holds only the constants declared as exactly that type, so Alignment.Center (declared Alignment) is in no group, and Arrangement.SpaceBetween is under Arrangement.HorizontalOrVertical, not Arrangement.Horizontal.

노트북처럼 상수를 선언된 타입별로 묶어서 읽을 수도 있습니다. Alignment.Horizontal.End 는 Alignment.End 이며 같은 방식으로 읽힙니다. 묶음에는 정확히 그 타입으로 선언된 상수만 들어가므로 Alignment.Center(선언 타입 Alignment)는 어느 묶음에도 없고, Arrangement.SpaceBetween 은 Arrangement.Horizontal 이 아니라 Arrangement.HorizontalOrVertical 아래에 있습니다.

diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index 44ec5d9..a67dffe 100644 --- a/docs/guide/getting-started.html +++ b/docs/guide/getting-started.html @@ -43,7 +43,7 @@

1. Get the source1. 소스

2. Run the tests2. 테스트 실행

python3 -m pytest tests -q
-

Expect 84 passed, 60 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:

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

+

Expect 91 passed, 73 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:

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

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

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

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

diff --git a/docs/guide/guide-composables.html b/docs/guide/guide-composables.html index 032a94c..d93d2e1 100644 --- a/docs/guide/guide-composables.html +++ b/docs/guide/guide-composables.html @@ -73,7 +73,6 @@

Spellings that changed바 modifier=modifiermodifier=ModifierIn Kotlin the empty modifier is Modifier; there is no lower-case second name.Kotlin 에서 빈 모디파이어는 Modifier 이며, 소문자로 된 두 번째 이름은 없습니다. color=0xFFFF0000color=Color(0xFFFF0000)Color packs several fields; a raw number would decode as something else.Color 는 여러 필드를 담으므로, 숫자를 넣으면 다른 값으로 해석됩니다. Spacer(start=…, top=…)Spacer(modifier=Modifier.padding(…))Spacer has no such parameters in Kotlin.Kotlin 의 Spacer 에는 그런 매개변수가 없습니다. -Alignment.Horizontal.EndAlignment.EndThe grouped spelling is decided but not built yet.묶음 표기는 결정되었지만 아직 만들어지지 않았습니다.

diff --git a/docs/guide/status.html b/docs/guide/status.html index ab77b01..a695aa0 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 에서 앱이 제공하는 팩토리가 필요
partial부분Alignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8) work; grouped Alignment.Horizontal.End pendingAlignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8) 동작; 묶음 표기 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 포함
planned계획A declared app root and Pythonic state, with no update call (#11)갱신 호출 없는 선언형 앱 루트와 파이썬다운 상태(#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 포함
planned계획A declared app root and Pythonic state, with no update call (#11)갱신 호출 없는 선언형 앱 루트와 파이썬다운 상태(#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, 코루틴 스코프
-

Test baseline: 84 passed, 60 skipped without a python-multiplatform checkout; 143 passed, 1 skipped with a current one. The one skip in both is the test that reads the maintainer's UI.ipynb.

테스트 기준선: python-multiplatform 체크아웃이 없으면 84 passed, 60 skipped, 최신 체크아웃이면 143 passed, 1 skipped. 두 경우 모두의 건너뜀 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트입니다.

+

Test baseline: 91 passed, 73 skipped without a python-multiplatform checkout; 163 passed, 1 skipped with a current one. The one skip in both is the test that reads the maintainer's UI.ipynb.

테스트 기준선: python-multiplatform 체크아웃이 없으면 91 passed, 73 skipped, 최신 체크아웃이면 163 passed, 1 skipped. 두 경우 모두의 건너뜀 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트입니다.

FAQ

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index 79dc6cc..76fdc7a 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -48,7 +48,8 @@ def Greeting(): 체이닝됩니다. 이런 메서드의 키워드 인자는 아직 Kotlin 그대로이며(`m.padding(paddingValues=...)`), 모듈 함수는 `padding(m, padding_values=...)` 를 받습니다. - **Kotlin 객체는 네임스페이스.** `Alignment.Center`, `Arrangement.End` 는 괄호 없이 읽고, 그 안의 - 함수는 `snake_case` 입니다: `Arrangement.spaced_by(8)`. + 함수는 `snake_case` 입니다: `Arrangement.spaced_by(8)`. 노트북처럼 선언된 타입별로 묶어서 읽을 + 수도 있습니다: `Alignment.Horizontal.End` 는 `Alignment.End` 입니다. - **`@Composable` 은 그대로.** 화면은 데코레이터가 붙은 파이썬 함수입니다. - **실제 패키지.** `pythonx/` 는 `androidx.compose.*` 를 import 해 재구성하는 평범한 파이썬 소스입니다. 바인더는 아무 이름도 바꾸지 않습니다 — 이름을 바꾸는 것은 이 패키지의 일입니다. @@ -149,7 +150,7 @@ pip 패키지 **`pythonx-compose`**(파이썬 3.11 이상)로 배포되며, impo | `layout` 뿐 아니라 `material3` 에서도 쓰는 `Column`, `Row`, `Spacer` | ✅ 구현, 테스트됨 | | `snake_case` 메서드 키워드 인자 | 🟡 부분 — 모듈 함수는 됨; 메서드는 아직 Kotlin 키워드 | | 빈 `Modifier` | 🟡 부분 — 클래스에서 시작하는 `Modifier.padding(16)` 은 실제 Compose 에서 앱이 제공하는 팩토리가 필요 | -| `Alignment` / `Arrangement` | 🟡 부분 — `Alignment.Center`, `Arrangement.spaced_by(8)` 동작; 묶음 표기 `Alignment.Horizontal.End` 대기 | +| `Alignment` / `Arrangement` | ✅ 구현 및 테스트 — `Alignment.Center`, `Arrangement.spaced_by(8)`, 그리고 `Alignment.End` 와 나란히 묶음 표기 `Alignment.Horizontal.End` | | Material 3 위젯 (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 부분 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) | | 실제 Compose 1.11.1 에서 생성한 타입 스텁(`.pyi`), `py.typed` | ✅ 구현, mypy 로 검사; 아직 많은 타입이 `Any` 이고 메서드 키워드는 Kotlin 이름(#12) | | 배포 (`pythonx-compose`) | 🟡 부분 — wheel 에 매니페스트, 재노출 규칙, 스텁, `py.typed` 포함 | diff --git a/pythonx/compose/_reexport.py b/pythonx/compose/_reexport.py index 58c128d..0214a81 100644 --- a/pythonx/compose/_reexport.py +++ b/pythonx/compose/_reexport.py @@ -27,6 +27,11 @@ `Modifier.padding(16).fill_max_width()` reaches `fillMaxWidth` on a proxy Kotlin returned. The binder caches the alias in its own registry; its proxy classes keep Kotlin names in `dir()`. +- **Grouped constants.** Inside a Kotlin object served as a namespace, a type nested in it that + is not one of its members groups the constants declared as exactly that type, so the notebook's + `Alignment.Horizontal.End` reads `Alignment.End` (`ConstantGroup`). Declared types come from + `python_multiplatform.describe(module, name)`, which never runs a constant's getter. + A module may also answer for names the manifest's `[aliases]` section lends it from another mapped module (`material3` answers for `Column`, `Row`, `Spacer` from `layout`, as the notebook imports them); the object is the same one either way. @@ -156,6 +161,26 @@ def _name_table(kotlin) -> dict: return table +def _declared_type(kotlin, kotlin_name: str): + """`(kind, declared return type)` of the bound name [kotlin_name] in [kotlin], without reading it. + + `python_multiplatform.describe(module, name)` answers from the binder's table and never runs a + constant's getter (python-multiplatform #36). A binder without the two-argument form, or a name + that is not a single bound declaration (an overload set, a child package), answers `None`. + """ + root = sys.modules.get(ROOT_MODULE) + describe = getattr(root, "describe", None) + if describe is None: + return None + try: + described = describe(kotlin, kotlin_name) + except (TypeError, AttributeError): + return None + if not isinstance(described, dict): + return None + return described.get("kind"), described.get("returns") + + def _describe(fn): root = sys.modules.get(ROOT_MODULE) if root is None or not hasattr(root, "describe"): @@ -247,18 +272,51 @@ def _resolve(kotlin, name: str): return None +_STATIC_GETTER = "STATIC_GETTER" + + +def _constant_groups(kotlin) -> dict: + """Group name -> {Pythonic constant name: Kotlin name}, for the Kotlin object [kotlin]. + + A group is a type nested in the object, `P.G`, that some constant of `P` is declared as; it holds + exactly the constants declared as `P.G` (SPEC S7.1). A constant declared as `P` itself or as any + other type is in no group. Declared types come from `describe(module, name)`, never from a value. + A name the object already has is a Kotlin member and wins, so it is never a group. + """ + prefix = kotlin.__name__ + "." + members = _name_table(kotlin) + groups: dict = {} + for pythonic, kotlin_name in members.items(): + declared = _declared_type(kotlin, kotlin_name) + if declared is None or declared[0] != _STATIC_GETTER or not declared[1]: + continue + group = declared[1][len(prefix):] if declared[1].startswith(prefix) else "" + if not group[:1].isupper() or "." in group or group in members: + continue + groups.setdefault(group, {})[pythonic] = kotlin_name + return groups + + class KotlinObject: """A Kotlin object (`Arrangement`, `Alignment`) served as a namespace, read by the module rule. Constants are read again on every access, as the binder serves them; functions inside the object - are snake_case (`Arrangement.spaced_by`) and are resolved once. + are snake_case (`Arrangement.spaced_by`) and are resolved once. A type nested in the object that + is no member of it groups the constants declared as that type (`Alignment.Horizontal.End`, + `ConstantGroup`); which constants those are is worked out once per object, from declared types. """ - __slots__ = ("_kotlin", "_cache") + __slots__ = ("_kotlin", "_cache", "_groups") def __init__(self, kotlin_module): object.__setattr__(self, "_kotlin", kotlin_module) object.__setattr__(self, "_cache", {}) + object.__setattr__(self, "_groups", None) + + def _constant_groups(self) -> dict: + if self._groups is None: + object.__setattr__(self, "_groups", _constant_groups(self._kotlin)) + return self._groups def __getattr__(self, name): if name.startswith("__") and name.endswith("__"): @@ -268,9 +326,13 @@ def __getattr__(self, name): return cached resolved = _resolve(self._kotlin, name) if resolved is None: - raise AttributeError( - f"{self._kotlin.__name__} has nothing spelled {name!r} under pythonx's naming rule" - ) + members = self._constant_groups().get(name) if name[:1].isupper() else None + if members is None: + raise AttributeError( + f"{self._kotlin.__name__} has nothing spelled {name!r} under pythonx's naming rule" + ) + # The grouping is metadata and is kept; the constants inside are read on each access. + resolved = ConstantGroup(self, f"{self._kotlin.__name__}.{name}", members), True value, cacheable = resolved if cacheable: self._cache[name] = value @@ -280,12 +342,48 @@ def __setattr__(self, name, value): raise AttributeError(f"{self._kotlin.__name__} is a Kotlin object; its members are read-only here") def __dir__(self): - return sorted(_name_table(self._kotlin)) + return sorted(set(_name_table(self._kotlin)) | set(self._constant_groups())) def __repr__(self): return f"" +class ConstantGroup: + """The constants of a Kotlin object declared as one type nested in it: `Alignment.Horizontal`. + + The notebook's grouped spelling (INTENT section 5.3). Reading `Alignment.Horizontal.End` reads + `Alignment.End` through its `KotlinObject`, so both spellings are the same live read of the same + Kotlin getter. Only constants whose *declared* type is exactly this one are here. + """ + + __slots__ = ("_owner", "_kotlin_type", "_members") + + def __init__(self, owner, kotlin_type, members): + object.__setattr__(self, "_owner", owner) + object.__setattr__(self, "_kotlin_type", kotlin_type) + object.__setattr__(self, "_members", dict(members)) + + def __getattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + if name not in self._members: + raise AttributeError( + f"{self._kotlin_type} groups no constant spelled {name!r}; it holds the constants of " + f"{self._owner._kotlin.__name__} declared as {self._kotlin_type}: " + + ", ".join(sorted(self._members)) + ) + return getattr(self._owner, name) + + def __setattr__(self, name, value): + raise AttributeError(f"{self._kotlin_type} is a group of Kotlin constants; they are read-only here") + + def __dir__(self): + return sorted(self._members) + + def __repr__(self): + return f"" + + def _aliases(module_name: str) -> dict: """Name -> the `pythonx.compose` module it is borrowed from, per the manifest's `[aliases]`.""" borrowed = {} diff --git a/pythonx/compose/layout/__init__.pyi b/pythonx/compose/layout/__init__.pyi index 98e9e2e..69d2166 100644 --- a/pythonx/compose/layout/__init__.pyi +++ b/pythonx/compose/layout/__init__.pyi @@ -552,6 +552,28 @@ class Arrangement: """Kotlin: androidx.compose.foundation.layout.Arrangement.Start(): androidx.compose.foundation.layout.Arrangement.Horizontal""" Top: _t.ClassVar[_t.Any] """Kotlin: androidx.compose.foundation.layout.Arrangement.Top(): androidx.compose.foundation.layout.Arrangement.Vertical""" + class Horizontal: + """Kotlin: the constants of androidx.compose.foundation.layout.Arrangement declared as androidx.compose.foundation.layout.Arrangement.Horizontal""" + End: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.End(): androidx.compose.foundation.layout.Arrangement.Horizontal""" + Start: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.Start(): androidx.compose.foundation.layout.Arrangement.Horizontal""" + class HorizontalOrVertical: + """Kotlin: the constants of androidx.compose.foundation.layout.Arrangement declared as androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical""" + Center: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.Center(): androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical""" + SpaceAround: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.SpaceAround(): androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical""" + SpaceBetween: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.SpaceBetween(): androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical""" + SpaceEvenly: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.SpaceEvenly(): androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical""" + class Vertical: + """Kotlin: the constants of androidx.compose.foundation.layout.Arrangement declared as androidx.compose.foundation.layout.Arrangement.Vertical""" + Bottom: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.Bottom(): androidx.compose.foundation.layout.Arrangement.Vertical""" + Top: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.foundation.layout.Arrangement.Top(): androidx.compose.foundation.layout.Arrangement.Vertical""" class ContextualFlowColumnOverflow: """Kotlin: androidx.compose.foundation.layout.ContextualFlowColumnOverflow""" diff --git a/pythonx/compose/ui/__init__.pyi b/pythonx/compose/ui/__init__.pyi index 83202af..26f48fe 100644 --- a/pythonx/compose/ui/__init__.pyi +++ b/pythonx/compose/ui/__init__.pyi @@ -800,6 +800,22 @@ class Alignment: """Kotlin: androidx.compose.ui.Alignment.TopEnd(): androidx.compose.ui.Alignment""" TopStart: _t.ClassVar[_t.Any] """Kotlin: androidx.compose.ui.Alignment.TopStart(): androidx.compose.ui.Alignment""" + class Horizontal: + """Kotlin: the constants of androidx.compose.ui.Alignment declared as androidx.compose.ui.Alignment.Horizontal""" + CenterHorizontally: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.ui.Alignment.CenterHorizontally(): androidx.compose.ui.Alignment.Horizontal""" + End: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.ui.Alignment.End(): androidx.compose.ui.Alignment.Horizontal""" + Start: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.ui.Alignment.Start(): androidx.compose.ui.Alignment.Horizontal""" + class Vertical: + """Kotlin: the constants of androidx.compose.ui.Alignment declared as androidx.compose.ui.Alignment.Vertical""" + Bottom: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.ui.Alignment.Bottom(): androidx.compose.ui.Alignment.Vertical""" + CenterVertically: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.ui.Alignment.CenterVertically(): androidx.compose.ui.Alignment.Vertical""" + Top: _t.ClassVar[_t.Any] + """Kotlin: androidx.compose.ui.Alignment.Top(): androidx.compose.ui.Alignment.Vertical""" class ComposeUiFlags: """Kotlin: androidx.compose.ui.ComposeUiFlags""" diff --git a/pythonx/compose/ui/alignment.py b/pythonx/compose/ui/alignment.py index f2bb44f..07343d6 100644 --- a/pythonx/compose/ui/alignment.py +++ b/pythonx/compose/ui/alignment.py @@ -29,7 +29,19 @@ `pythonx.compose.ui.Alignment` is the Kotlin object `androidx.compose.ui.Alignment` seen through the re-export rule (`pythonx/compose/_reexport.py`, `KotlinObject`): constants keep their Kotlin -spelling and are read again on every access, as the binder serves them. The notebook's grouped -spelling, `Alignment.Horizontal.End` (INTENT section 5.3), waits for the binder to describe a -constant's declared type without reading it (python-multiplatform #36). +spelling and are read again on every access, as the binder serves them. + +## Grouped by declared type + +The notebook's grouped spelling works beside Kotlin's flat one (INTENT section 5.3): + + Alignment.Horizontal.End # the same read as Alignment.End + Alignment.Vertical.Top # the same read as Alignment.Top + +`Alignment.Horizontal` holds the constants declared as `Alignment.Horizontal` (`CenterHorizontally`, +`End`, `Start`) and `Alignment.Vertical` those declared as `Alignment.Vertical` (`Bottom`, +`CenterVertically`, `Top`). A constant declared as `Alignment` itself (`Center`, `TopStart`, ...) is +in neither. The grouping is the re-export rule's (`ConstantGroup`), read from the declared types +`python_multiplatform.describe(module, name)` reports without running a getter (python-multiplatform +#36), so no list here drives it. """ diff --git a/tests/test_chain.py b/tests/test_chain.py index 8fdbeb3..bde16c8 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -390,6 +390,140 @@ def test_an_unknown_constant_says_where_it_looked(self): self.assertIn("androidx.compose.ui.Alignment", str(raised.exception)) +class GroupedObjectConstants(AdapterCase): + """INTENT 5.3, SPEC S7.1: `Alignment.Horizontal.End` beside Kotlin's flat `Alignment.End`. + + A group is the constants of an object whose declared type is one type nested in it, read with + `python_multiplatform.describe(module, name)` -- never by reading a constant. + """ + + def setUp(self): + super().setUp() + import python_multiplatform + + if len(inspect.signature(python_multiplatform.describe).parameters) < 2: + self.skipTest("the binder has no describe(module, name) yet (python-multiplatform #36)") + + def label_of(self, proxy): + return self.host._object(proxy._pm_handle).label + + def test_the_grouped_spelling_reads_the_constant(self): + import pythonx.compose.layout as layout + import pythonx.compose.ui as ui + + self.assertEqual("End", self.label_of(ui.Alignment.Horizontal.End)) + self.assertEqual("Top", self.label_of(ui.Alignment.Vertical.Top)) + self.assertEqual("Start", self.label_of(layout.Arrangement.Horizontal.Start)) + self.assertEqual("SpaceBetween", self.label_of(layout.Arrangement.HorizontalOrVertical.SpaceBetween)) + + def test_both_spellings_are_the_same_live_read(self): + import pythonx.compose.ui as ui + + flat, grouped, again = ui.Alignment.End, ui.Alignment.Horizontal.End, ui.Alignment.Horizontal.End + self.assertEqual(self.label_of(flat), self.label_of(grouped)) + self.assertIsNot(grouped, again) # read again on every access, like the flat spelling + self.assertIs(type(flat), type(grouped)) + + def test_a_group_holds_exactly_the_constants_of_its_declared_type(self): + import pythonx.compose.layout as layout + import pythonx.compose.ui as ui + + self.assertEqual(["CenterHorizontally", "End"], dir(ui.Alignment.Horizontal)) + self.assertEqual(["Top"], dir(ui.Alignment.Vertical)) + self.assertEqual(["End", "Start"], dir(layout.Arrangement.Horizontal)) + self.assertEqual(["Top"], dir(layout.Arrangement.Vertical)) + self.assertEqual(["SpaceBetween"], dir(layout.Arrangement.HorizontalOrVertical)) + + def test_a_constant_declared_as_a_supertype_is_in_no_narrower_group(self): + import pythonx.compose.layout as layout + import pythonx.compose.ui as ui + + self.assertIn("End", dir(ui.Alignment.Horizontal)) + self.assertIn("SpaceBetween", dir(layout.Arrangement.HorizontalOrVertical)) + with self.assertRaises(AttributeError): + ui.Alignment.Horizontal.Center # noqa: B018 -- declared `Alignment` + with self.assertRaises(AttributeError): + layout.Arrangement.Horizontal.SpaceBetween # noqa: B018 -- declared `HorizontalOrVertical` + + def test_a_name_the_group_does_not_hold_names_the_groups_kotlin_type(self): + import pythonx.compose.ui as ui + + with self.assertRaises(AttributeError) as raised: + ui.Alignment.Horizontal.Top # noqa: B018 -- `Top` is an `Alignment.Vertical` + self.assertIn("androidx.compose.ui.Alignment.Horizontal", str(raised.exception)) + self.assertIn("'Top'", str(raised.exception)) + + def test_a_name_that_is_no_nested_type_is_still_unknown(self): + import pythonx.compose.ui as ui + + self.assertIn("Horizontal", dir(ui.Alignment)) + with self.assertRaises(AttributeError): + ui.Alignment.Diagonal # noqa: B018 + + def test_dir_of_the_object_lists_its_groups(self): + import pythonx.compose.layout as layout + import pythonx.compose.ui as ui + + self.assertTrue({"Horizontal", "Vertical", "End", "Center"} <= set(dir(ui.Alignment))) + self.assertTrue({"Horizontal", "Vertical", "HorizontalOrVertical"} <= set(dir(layout.Arrangement))) + + def test_classifying_reads_no_constant(self): + import pythonx.compose.ui as ui + + before = self.host._next_handle + dir(ui.Alignment) + ui.Alignment.Horizontal # noqa: B018 + self.assertEqual(before, self.host._next_handle, "a constant's getter ran to classify it") + + def test_the_classification_is_computed_once_per_object(self): + import python_multiplatform + import pythonx.compose.ui as ui + + original, asked = python_multiplatform.describe, [] + + def counting(*args): + asked.append(args) + return original(*args) + + python_multiplatform.describe = counting + self.addCleanup(setattr, python_multiplatform, "describe", original) + ui.Alignment.Horizontal # noqa: B018 + first = len(asked) + ui.Alignment.Vertical # noqa: B018 + dir(ui.Alignment) + self.assertGreater(first, 0) + self.assertEqual(first, len(asked)) + + def test_a_kotlin_member_wins_over_a_group(self): + # A fictional constant of `Arrangement` named like one of its nested types. + self.host._constant(f"{fake_host.ARRANGEMENT}.Vertical", fake_host.ARRANGEMENT_VERTICAL) + self.host.register(self.binding) + import pythonx.compose.layout as layout + + self.assertEqual("Vertical", self.label_of(layout.Arrangement.Vertical)) + self.assertEqual(["End", "Start"], dir(layout.Arrangement.Horizontal)) + + def test_a_group_is_read_only(self): + import pythonx.compose.ui as ui + + group = ui.Alignment.Horizontal + with self.assertRaises(AttributeError): + group.End = None + self.assertEqual("End", self.label_of(group.End)) + + def test_without_describe_by_name_there_are_no_groups_and_the_flat_spelling_stays(self): + import python_multiplatform + import pythonx.compose.ui as ui + + original = python_multiplatform.describe + python_multiplatform.describe = lambda fn: original(fn) # the one-argument binder + self.addCleanup(setattr, python_multiplatform, "describe", original) + self.assertEqual("End", self.label_of(ui.Alignment.End)) + with self.assertRaises(AttributeError): + ui.Alignment.Horizontal # noqa: B018 + self.assertNotIn("Horizontal", dir(ui.Alignment)) + + class ManifestAliases(AdapterCase): """INTENT 5.2: `Column`, `Row`, `Spacer` from `material3`, as the notebook imports them.""" diff --git a/tests/test_stubs.py b/tests/test_stubs.py index 3a56f94..cdb7808 100644 --- a/tests/test_stubs.py +++ b/tests/test_stubs.py @@ -298,6 +298,75 @@ def test_every_stub_is_valid_python(self): compile(text, str(path), "exec") +def _nested(cls: ast.ClassDef) -> dict[str, ast.ClassDef]: + return {node.name: node for node in cls.body if isinstance(node, ast.ClassDef)} + + +def _constants(cls: ast.ClassDef) -> set[str]: + return {n.target.id for n in cls.body if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name)} + + +class TheConstantGroups(unittest.TestCase): + """SPEC S7.1: an object's constants grouped by the nested type their docstring declares.""" + + OBJECT_IN_PARENT = ( + "import typing as _t\n\n" + "class Obj:\n" + " \"\"\"Kotlin: a.b.Obj\"\"\"\n" + " Left: _t.ClassVar[_t.Any]\n" + " \"\"\"Kotlin: a.b.Obj.Left(): a.b.Obj.Side\"\"\"\n" + " Right: _t.ClassVar[_t.Any]\n" + " \"\"\"Kotlin: a.b.Obj.Right(): a.b.Obj.Side\"\"\"\n" + " Whole: _t.ClassVar[_t.Any]\n" + " \"\"\"Kotlin: a.b.Obj.Whole(): a.b.Obj\"\"\"\n" + " Deep: _t.ClassVar[_t.Any]\n" + " \"\"\"Kotlin: a.b.Obj.Deep(): a.b.Obj.Side.Inner\"\"\"\n" + " Other: _t.ClassVar[_t.Any]\n" + " \"\"\"Kotlin: a.b.Obj.Other(): a.c.Side\"\"\"\n" + ) + + def setUp(self): + self.stubs = _generated(KOTLIN_STUBS_V2) + + def convert(self, source: str) -> dict[str, ast.ClassDef]: + return _classes(gen_stubs.Converter(gen_stubs.KotlinStubs(KOTLIN_STUBS_V2)).convert(source, "a.b")) + + def test_constants_are_grouped_by_their_declared_nested_type(self): + arrangement = _classes(self.stubs[LAYOUT])["Arrangement"] + groups = _nested(arrangement) + self.assertEqual(["Horizontal", "HorizontalOrVertical", "Vertical"], sorted(groups)) + self.assertEqual({"End", "Start"}, _constants(groups["Horizontal"])) + self.assertEqual({"Top"}, _constants(groups["Vertical"])) + self.assertEqual({"SpaceBetween"}, _constants(groups["HorizontalOrVertical"])) + self.assertEqual( + "_t.ClassVar[_t.Any]", ast.unparse(_members(groups["Horizontal"])["End"].annotation) + ) + + def test_the_flat_constants_stay(self): + arrangement = _members(_classes(self.stubs[LAYOUT])["Arrangement"]) + self.assertTrue({"End", "Start", "Top", "SpaceBetween", "spaced_by"} <= set(arrangement)) + + def test_an_object_class_in_the_parent_stub_is_grouped_too(self): + obj = self.convert(self.OBJECT_IN_PARENT)["Obj"] + self.assertEqual(["Side"], sorted(_nested(obj))) + self.assertEqual({"Left", "Right"}, _constants(_nested(obj)["Side"])) + self.assertEqual({"Left", "Right", "Whole", "Deep", "Other"}, _constants(obj)) + + def test_a_member_named_like_the_type_wins(self): + source = self.OBJECT_IN_PARENT + ( + " Side: _t.ClassVar[_t.Any]\n" + " \"\"\"Kotlin: a.b.Obj.Side(): a.b.Obj.Side\"\"\"\n" + ) + obj = self.convert(source)["Obj"] + self.assertEqual({}, _nested(obj)) + self.assertIn("Side", _constants(obj)) + + def test_a_class_with_no_such_constants_gets_no_groups(self): + for name, cls in _classes(self.stubs[UI]).items(): + with self.subTest(cls=name): + self.assertEqual({}, _nested(cls)) + + class TheCommittedStubs(unittest.TestCase): """What ships is what the generator makes of python-multiplatform's `kotlin-stubs` artefact.""" @@ -323,6 +392,15 @@ def test_the_committed_stubs_are_exactly_what_the_artefact_generates(self): with self.subTest(stub=str(path.relative_to(REPO))): self.assertEqual(text, path.read_text(encoding="utf-8")) + def test_the_committed_alignment_and_arrangement_carry_their_groups(self): + alignment = _nested(_classes(UI.read_text(encoding="utf-8"))["Alignment"]) + self.assertEqual({"CenterHorizontally", "End", "Start"}, _constants(alignment["Horizontal"])) + self.assertEqual({"Bottom", "CenterVertically", "Top"}, _constants(alignment["Vertical"])) + arrangement = _nested(_classes(LAYOUT.read_text(encoding="utf-8"))["Arrangement"]) + self.assertEqual( + {"Center", "SpaceAround", "SpaceBetween", "SpaceEvenly"}, _constants(arrangement["HorizontalOrVertical"]) + ) + class TheStubMatchesTheRuntime(unittest.TestCase): """What an editor reads from the stub is what `inspect.signature` reports at run time.""" @@ -379,6 +457,18 @@ def test_an_object_constant_in_the_stub_is_read_at_run_time(self): with self.subTest(constant=f"{name}.{constant}"): self.assertIsNotNone(getattr(getattr(module, name), constant)) + def test_a_group_in_the_stub_holds_what_the_runtime_group_holds(self): + import python_multiplatform + import pythonx.compose.layout as layout + + if len(inspect.signature(python_multiplatform.describe).parameters) < 2: + self.skipTest("the binder has no describe(module, name) yet (python-multiplatform #36)") + groups = _nested(_classes(_generated(KOTLIN_STUBS_V2)[LAYOUT])["Arrangement"]) + self.assertTrue(groups) + for name, group in groups.items(): + with self.subTest(group=name): + self.assertEqual(sorted(_constants(group)), dir(getattr(layout.Arrangement, name))) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_typing.py b/tests/test_typing.py index dcecfb6..7177b6f 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -119,6 +119,22 @@ def test_a_misspelled_function_fails(self): Modifier.padding(16).fillMaxWidth() """, '"fillMaxWidth"', "[attr-defined]") + def test_a_grouped_constant_type_checks(self): + """SPEC S7.1: `Alignment.Horizontal.End` beside `Alignment.End`; `Top` is no `Horizontal`.""" + self.assertPasses(""" + from pythonx.compose.ui import Alignment + from pythonx.compose.layout import Arrangement + + a = Alignment.Horizontal.End + b = Alignment.End + c = Arrangement.HorizontalOrVertical.SpaceBetween + """) + self.assertFails(""" + from pythonx.compose.ui import Alignment + + Alignment.Horizontal.Top + """, '"Top"', "[attr-defined]") + def test_a_kotlin_spelled_module_function_fails(self): self.assertFails(""" from pythonx.compose.layout import fillMaxWidth diff --git a/tools/gen_stubs.py b/tools/gen_stubs.py index 6a13413..3f43e41 100644 --- a/tools/gen_stubs.py +++ b/tools/gen_stubs.py @@ -25,6 +25,9 @@ (python-multiplatform #44). A sub-package directory holding only `__init__.pyi` would be a namespace package at run time and shadow the `KotlinObject` the re-export rule serves. An object whose name the parent uses for a function is left out: at run time that name is the function; +- an object's constants are also grouped by the type nested in it that their docstring declares + them as (`Alignment.End(): Alignment.Horizontal`), as a class nested in the object's class + (`Alignment.Horizontal.End`), the runtime's `ConstantGroup` rule; a member of that name wins; - a reference to another Kotlin package names the `pythonx.compose` module the manifest lists first for it, and its import is rewritten to match; a package the manifest does not map, or a name the target stub does not declare as a class, becomes `typing.Any`; @@ -203,6 +206,44 @@ def _mark_ignores(tree: ast.Module) -> None: node.type_ignore = tags[node.lineno] +_DECLARED_CONSTANT = re.compile(r"Kotlin: (?P[\w.]+)\.\w+\(\): (?P[\w.]+)$") + + +def _add_constant_groups(cls: ast.ClassDef, kotlin_name: str) -> None: + """Nest in [cls] a class per type nested in it that its constants are declared as (SPEC S7.1). + + The runtime's `ConstantGroup` rule, read off the stub: a constant whose docstring + (`Kotlin: .End(): .Horizontal`) declares it as exactly + `.`, for one simple upper-case name `G`, is repeated in `class G`, which is + emitted unless [cls] already has a member named `G` (a Kotlin member wins). A constant declared + as anything else -- [cls] itself, a deeper or a foreign type -- stays flat only. + """ + members = {_name_of(node) for node in cls.body} - {None} + groups: dict[str, list[ast.stmt]] = {} + for index, node in enumerate(cls.body): + if not (isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)): + continue + following = cls.body[index + 1] if index + 1 < len(cls.body) else None + if not (isinstance(following, ast.Expr) and isinstance(following.value, ast.Constant) + and isinstance(following.value.value, str)): + continue + declared = _DECLARED_CONSTANT.match(following.value.value.strip()) + if declared is None or declared["owner"] != kotlin_name: + continue + group = declared["type"][len(kotlin_name) + 1:] if declared["type"].startswith(kotlin_name + ".") else "" + if not group[:1].isupper() or "." in group or group in members: + continue + groups.setdefault(group, []).extend( + ast.parse(ast.unparse(statement)).body[0] for statement in (node, following) + ) + for group in sorted(groups): + doc = f"Kotlin: the constants of {kotlin_name} declared as {kotlin_name}.{group}" + cls.body.append(ast.ClassDef( + name=group, bases=[], keywords=[], decorator_list=[], + body=[ast.Expr(ast.Constant(doc))] + groups[group], + )) + + # -------------------------------------------------------------------------------------- references @@ -310,6 +351,9 @@ def convert(self, source: str, kotlin_package: str = "", module_name: str = "") for name in self.stubs.objects(kotlin_package) if kotlin_package else (): self._add_object(body, kotlin_package, name, imported) + for node in body: + if isinstance(node, ast.ClassDef) and not _is_protocol(node): + _add_constant_groups(node, f"{kotlin_package}.{node.name}") self._check_unique(body, kotlin_package) references = _References(self, kotlin_package, imported)