Skip to content

Commit 91f015e

Browse files
authored
Merge pull request #45 from thisisthepy/work/grouped-alignment
Feat: Read Alignment.Horizontal.End as the notebook writes it
2 parents 49f02d6 + 7204436 commit 91f015e

17 files changed

Lines changed: 528 additions & 44 deletions

‎AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,9 @@ worktree so a test can see it. Read it in place.
188188
`.tmp/` (rule 2), not in the home directory.
189189
- Tests that exercise the binder's adaptation layer read it, read-only, from a `PythonMultiplatform`
190190
checkout: the sibling directory `../PythonMultiplatform`, or `PYTHONMULTIPLATFORM_HOME`. Without
191-
one they **skip**; with a checkout older than python-multiplatform `ba4c6f49`, 5 of them skip for
192-
want of its member resolver (see `docs/SPEC.md` §0). Report both numbers and say which environment you ran in. A skip is not
191+
one they **skip**; against an older python-multiplatform some still skip, each saying which binder
192+
feature it needs (the member resolver, `ba4c6f49`; `describe(module, name)`, #36; see
193+
`docs/SPEC.md` §0). Report both numbers and say which environment you ran in. A skip is not
193194
a pass.
194195
- The suite runs on GitHub Actions (`.github/workflows/tests.yml`) in both environments, without and
195196
with a python-multiplatform checkout, for every pull request into `develop`. Local runs are for

‎PROJECT.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,24 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`
2727

2828
| 상태 | 항목 |
2929
|---|---|
30-
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1 에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사) |
31-
| 부분 | 배포 설정, proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨), `Alignment`/`Arrangement` 상수(`pythonx` 에서 읽힘; 묶음 표기는 python-multiplatform #36 대기) |
30+
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1 에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사), `Alignment`/`Arrangement` 상수(평면 `Alignment.End` 와 묶음 `Alignment.Horizontal.End` 모두, #9) |
31+
| 부분 | 배포 설정, proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨) |
3232
| 계획 | 선언형 앱 루트(#11), `remember_saveable`·`DefaultIcons`·코루틴 스코프 |
3333

3434
### 테스트 기준선
3535

3636
```
3737
python3 -m pytest tests -q
38-
PythonMultiplatform 체크아웃 없음 84 passed, 60 skipped
39-
python-multiplatform develop ba4c6f49+ 143 passed, 1 skipped
40-
그보다 오래된 체크아웃 같은 수, 그중 5 개 건너뜀 (member resolver 없음)
38+
PythonMultiplatform 체크아웃 없음 91 passed, 73 skipped
39+
python-multiplatform develop d6d39787+ 163 passed, 1 skipped
40+
describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전)
41+
그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (member resolver 없음)
4142
```
4243

44+
`describe(module, name)`(python-multiplatform #36)이 없는 바인더에서는 묶음 상수를 확인하는 13 개가,
4345
member resolver(python-multiplatform `ba4c6f49`)가 없는 바인더에서는 proxy 의 snake_case 메서드를 부르는
44-
5 개가 건너뛴다. 두 환경 모두의 건너뜀 1 개는 worktree 에 없는 `UI.ipynb` 의 테스트다. 건너뜀은 통과가 아니다.
45-
체크아웃 없이 통과하는 84 개 중 30 개는 타입 스텁과 wheel 을 검사하고(SPEC S1.2), 나머지 다수는 *부재*(옛 토큰·삭제된
46+
5 개가 더 건너뛴다. 두 환경 모두의 건너뜀 1 개는 worktree 에 없는 `UI.ipynb` 의 테스트다. 건너뜀은 통과가 아니다.
47+
체크아웃 없이 통과하는 91 개 중 37 개는 타입 스텁과 wheel 을 검사하고(SPEC S1.2), 나머지 다수는 *부재*(옛 토큰·삭제된
4648
파일이 없음)를 확인하는 것이라 기능 진척으로 세지 않는다.
4749

4850
### 마일스톤

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ proofs are still pending. See [Status](#-status).</sub>
4848
exactly as it does in Kotlin. Keyword arguments to such a method are still Kotlin's own
4949
(`m.padding(paddingValues=...)`); the module function takes `padding(m, padding_values=...)`.
5050
- **Kotlin objects are namespaces.** `Alignment.Center` and `Arrangement.End` are read without
51-
parentheses; functions inside them are `snake_case`: `Arrangement.spaced_by(8)`.
51+
parentheses; functions inside them are `snake_case`: `Arrangement.spaced_by(8)`. Constants can
52+
also be reached grouped by their declared type, as the notebook writes them:
53+
`Alignment.Horizontal.End` is `Alignment.End`.
5254
- **`@Composable` stays.** Screens are decorated Python functions.
5355
- **A real package.** `pythonx/` is ordinary Python source that imports `androidx.compose.*` and
5456
reshapes it. The binder never renames anything — renaming is this package's job.
@@ -150,7 +152,7 @@ standalone desktop toolkit.
150152
| `Column`, `Row`, `Spacer` from `material3` as well as `layout` | ✅ implemented and tested |
151153
| Method keyword arguments in `snake_case` | 🟡 partial — module functions yes; methods still take Kotlin's keywords |
152154
| The empty `Modifier` | 🟡 partial — `Modifier.padding(16)` from the class needs an app-supplied factory against real Compose |
153-
| `Alignment` / `Arrangement` | 🟡 partial — `Alignment.Center`, `Arrangement.spaced_by(8)` work; grouped `Alignment.Horizontal.End` pending |
155+
| `Alignment` / `Arrangement` | ✅ implemented and tested — `Alignment.Center`, `Arrangement.spaced_by(8)`, and grouped `Alignment.Horizontal.End` beside `Alignment.End` |
154156
| Material 3 widgets (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 partial — re-exported by rule; render proofs per widget pending (#9) |
155157
| 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) |
156158
| Distribution (`pythonx-compose`) | 🟡 partial — the wheel carries the manifest, the re-export rule, the stubs and `py.typed` |

‎docs/SPEC.md‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,26 @@ Each item carries a status:
1414

1515
A behaviour change starts here, then becomes a failing test, then code (`AGENTS.md` §5).
1616

17-
## 0. Test baseline (2026-10-03, after issue #12)
17+
## 0. Test baseline (2026-10-03, after issue #9's grouped constants)
1818

1919
Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CPython 3.13), where
2020
`UI.ipynb` is absent and its one test skips:
2121

2222
| Environment | Result |
2323
|---|---|
24-
| No `PythonMultiplatform` checkout found | **84 passed, 60 skipped**, 115 subtests passed |
25-
| python-multiplatform `develop` at `ba4c6f49` or later (has `add_member_resolver`) | **143 passed, 1 skipped**, 139 subtests passed |
26-
| an older checkout, without `add_member_resolver` | the same, with 5 more skipped |
27-
28-
Without a checkout, the 59 tests that install the binder's layers through `tests/adapter.py` skip.
29-
Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the binder
30-
returned skip, because that needs its member resolver (python-multiplatform #17). The 1 skip in both
24+
| No `PythonMultiplatform` checkout found | **91 passed, 73 skipped**, 122 subtests passed |
25+
| python-multiplatform `develop` at `d6d39787` or later (has `add_member_resolver` and `describe(module, name)`) | **163 passed, 1 skipped**, 149 subtests passed |
26+
| an older checkout, without `describe(module, name)` (python-multiplatform #36) | the same, with 13 more skipped |
27+
| an older one still, without `add_member_resolver` | with 5 more skipped again |
28+
29+
Without a checkout, the 72 tests that install the binder's layers through `tests/adapter.py` skip.
30+
Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1)
31+
skip. Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the
32+
binder returned skip as well, because that needs its member resolver (python-multiplatform #17). The 1 skip in both
3133
rows is `UI.ipynb`'s (§2). `tests/test_typing.py` skips where mypy is not installed. A skip is not a
3234
pass.
3335

34-
Of the 84 that pass without a checkout, 30 check the type stubs and the wheel (S1.2); most of the
36+
Of the 91 that pass without a checkout, 37 check the type stubs and the wheel (S1.2); most of the
3537
rest assert **absence** (a retired token, a deleted file, a docstring that exists). Those are listed
3638
in §9 and are not counted as features.
3739

@@ -261,7 +263,7 @@ a name is adapted once and then lives in the module dict; `dir()` reports the Py
261263
without the binding layer a name read says the host never installed it; dropping a proxy releases
262264
its Kotlin handle. Evidence: `tests/test_chain.py::Laziness` (5 tests), `::Handles` (1 test).
263265

264-
## 7. Layout constants — `Alignment` and `Arrangement` — `partial`
266+
## 7. Layout constants — `Alignment` and `Arrangement` — `implemented`
265267

266268
The bound constant names are documented in `pythonx/compose/ui/alignment.py` (15 `Alignment`
267269
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
277279
`KotlinObject` namespace by the §3 rule. Constants keep their Kotlin spelling and are read again on
278280
every access; a function inside the object is snake_case (`Arrangement.spaced_by`). Evidence:
279281
`tests/test_chain.py::ObjectNamespaces` (6 tests); without the sub-package step 6 fail. The binder
280-
will list these objects itself (python-multiplatform #35).
281-
282-
Decided (INTENT §5.3) and not built: the notebook's grouping by type, `Alignment.Horizontal.End`.
283-
It needs a constant's declared type without reading it, `python_multiplatform.describe(module,
284-
name)` (python-multiplatform #36; issue #9).
282+
now lists these objects itself (python-multiplatform #35), and either way reaches them.
283+
284+
### S7.1 Grouped constants — `Alignment.Horizontal.End` — `implemented`
285+
286+
INTENT §5.3: the notebook groups constants by type, `Alignment.Horizontal.End`, and Kotlin writes
287+
them flat, `Alignment.End` (which *is* an `Alignment.Horizontal`). Both spellings are served, by one
288+
rule that names no declaration:
289+
290+
- Inside the `KotlinObject` for a Kotlin object `P`, an upper-case name `G` that is **not** a member
291+
of `P` but is the simple name of a type nested in `P` -- some constant of `P` is declared as
292+
exactly `P.G` -- is a **group**: a namespace whose members are exactly the constants of `P` whose
293+
declared type is `P.G`. `Alignment.Horizontal` holds `CenterHorizontally`, `End`, `Start`;
294+
`Alignment.Vertical` holds `Bottom`, `CenterVertically`, `Top`; `Arrangement.HorizontalOrVertical`
295+
holds `Center`, `SpaceAround`, `SpaceBetween`, `SpaceEvenly`.
296+
- A constant's declared type is read with `python_multiplatform.describe(module, name)`
297+
(python-multiplatform #36), which answers from the binder's table and never runs the getter. A
298+
constant's value is never read to classify it. The classification is metadata and is computed
299+
once per object; values are not cached.
300+
- Reading a group member reads that constant then and there, exactly as the flat spelling does:
301+
`Alignment.Horizontal.End` and `Alignment.End` are the same read of the same Kotlin getter.
302+
- **A group holds a constant only under its exact declared type.** A constant declared as a
303+
supertype is in no narrower group: `Alignment.Center` (declared `Alignment`) is in none, and
304+
`Arrangement.SpaceBetween` (declared `Arrangement.HorizontalOrVertical`) is in
305+
`Arrangement.HorizontalOrVertical` only, not in `Arrangement.Horizontal`, although Kotlin accepts
306+
it wherever an `Arrangement.Horizontal` is expected. The notebook groups by declared type, and the
307+
declared type is what the binder describes; subtyping is not modelled.
308+
- **Kotlin members win.** If `P` has a member named `G` (a constant, a function, a nested object
309+
the binder lists), that member is what `P.G` reads, and there is no group of that name.
310+
- `dir()` of an object lists its group names next to its members; `dir()` of a group lists its
311+
constants. A name a group does not hold raises `AttributeError` naming the group's Kotlin type
312+
(`androidx.compose.ui.Alignment.Horizontal`), so `Alignment.Horizontal.Top` fails: `Top` is an
313+
`Alignment.Vertical`.
314+
- On a binder without `describe(module, name)` there are no groups; the flat spelling is unchanged.
315+
- **Stubs.** `tools/gen_stubs.py` emits each group as a class nested in the object's stub class,
316+
holding the same `ClassVar` constants, grouped by the declared type the upstream stub's docstring
317+
carries (`"""Kotlin: androidx.compose.ui.Alignment.End(): androidx.compose.ui.Alignment.Horizontal"""`),
318+
so a type checker accepts `Alignment.Horizontal.End` and rejects `Alignment.Horizontal.Top`. A
319+
reference to the Kotlin type `Alignment.Horizontal` in a signature stays `typing.Any`: the group
320+
class is a namespace, not the type of the constants.
321+
322+
Evidence: `tests/test_chain.py::GroupedObjectConstants` (12 tests, runtime, against the fake host;
323+
with the group lookup disabled 11 fail, and the one that stays green checks the binder without
324+
`describe(module, name)`), `tests/test_stubs.py::TheConstantGroups` (5 tests, generator),
325+
`::TheCommittedStubs::test_the_committed_alignment_and_arrangement_carry_their_groups`,
326+
`::TheStubMatchesTheRuntime::test_a_group_in_the_stub_holds_what_the_runtime_group_holds`, and
327+
`tests/test_typing.py::TheStubsTypeCheck::test_a_grouped_constant_type_checks` (mypy). In the real
328+
`kotlin-stubs` artefact only `Alignment` and `Arrangement` have such groups. Nothing in the walked
329+
Compose surface is an extension receiver named `Alignment.Horizontal` or `Arrangement.Horizontal`, so
330+
the binder lists no member that would win over these groups.
285331

286332
## 8. The notebook surface not yet covered — `planned`
287333

@@ -330,7 +376,7 @@ Found in the repository; not covered by `docs/INTENT.md`, or in conflict with it
330376
`pythonx.compose.foundation.layout` (plus `pythonx.compose.foundation` and `pythonx.compose`)
331377
"while the spelling settles".
332378
5. *(Resolved, #31.)* `material3/icon.py` and `color_scheme.py`, dead reflection code, are deleted.
333-
6. *(Decided, INTENT §5.3.)* Grouped alignment constants: both spellings are to be served; the grouped one is not built yet (§7).
379+
6. *(Resolved, INTENT §5.3, issue #9.)* Grouped alignment constants: both spellings are served (§7, S7.1).
334380
7. The submodule `pythonx/compose/native` → `thisisthepy/swing-graalvm-demo`, which INTENT does not
335381
mention. (The 29 empty `material3/*.py` files are deleted, #31.)
336382
8. **The `test/` directory** — a 2023–2024 Kotlin Multiplatform sample (`pycomposeui`, chaquopy

‎docs/guide/concepts.html‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ <h2><span data-lang="en">Kotlin objects</span><span data-lang="ko">Kotlin 객체
8080

8181
Alignment.Center
8282
Arrangement.End
83-
Arrangement.spaced_by(8)</code></pre>
84-
<p data-lang="en">The notebook's grouping by type, <code>Alignment.Horizontal.End</code>, is decided but not built yet; write <code>Alignment.End</code>.</p><p data-lang="ko">노트북의 타입별 묶음 표기 <code>Alignment.Horizontal.End</code> 는 결정되었지만 아직 만들어지지 않았습니다. <code>Alignment.End</code> 로 쓰세요.</p>
83+
Arrangement.spaced_by(8)
84+
Alignment.Horizontal.End # the same constant as Alignment.End</code></pre>
85+
<p data-lang="en">A constant can also be read grouped by its declared type, as the notebook writes it: <code>Alignment.Horizontal.End</code> is <code>Alignment.End</code>, read the same way. A group holds only the constants declared as exactly that type, so <code>Alignment.Center</code> (declared <code>Alignment</code>) is in no group, and <code>Arrangement.SpaceBetween</code> is under <code>Arrangement.HorizontalOrVertical</code>, not <code>Arrangement.Horizontal</code>.</p><p data-lang="ko">노트북처럼 상수를 선언된 타입별로 묶어서 읽을 수도 있습니다. <code>Alignment.Horizontal.End</code> 는 <code>Alignment.End</code> 이며 같은 방식으로 읽힙니다. 묶음에는 정확히 그 타입으로 선언된 상수만 들어가므로 <code>Alignment.Center</code>(선언 타입 <code>Alignment</code>)는 어느 묶음에도 없고, <code>Arrangement.SpaceBetween</code> 은 <code>Arrangement.Horizontal</code> 이 아니라 <code>Arrangement.HorizontalOrVertical</code> 아래에 있습니다.</p>
8586
</section>
8687

8788
<section>

‎docs/guide/getting-started.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ <h2><span data-lang="en">1. Get the source</span><span data-lang="ko">1. 소스
4343
<section>
4444
<h2><span data-lang="en">2. Run the tests</span><span data-lang="ko">2. 테스트 실행</span></h2>
4545
<pre><code class="language-bash">python3 -m pytest tests -q</code></pre>
46-
<p data-lang="en">Expect <strong>84 passed, 60 skipped</strong>. One skipped test reads the maintainer's <code>UI.ipynb</code>, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> checkout beside this one, or:</p><p data-lang="ko"><strong>84 passed, 60 skipped</strong> 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 <code>UI.ipynb</code> 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
46+
<p data-lang="en">Expect <strong>91 passed, 73 skipped</strong>. One skipped test reads the maintainer's <code>UI.ipynb</code>, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> checkout beside this one, or:</p><p data-lang="ko"><strong>91 passed, 73 skipped</strong> 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 <code>UI.ipynb</code> 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
4747
<pre><code class="language-bash">PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q</code></pre>
4848
<p data-lang="en">With a current one (<code>ba4c6f49</code> or later) <strong>143 pass</strong> and only the <code>UI.ipynb</code> test skips; an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.</p><p data-lang="ko">최신 체크아웃(<code>ba4c6f49</code> 이후)이면 <strong>143 개가 통과</strong>하고 <code>UI.ipynb</code> 테스트만 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 <strong>건너뜁니다</strong>. 예상된 상태이며 기록되어 있습니다.</p>
4949
</section>

0 commit comments

Comments
 (0)