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
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install pytest setuptools wheel
- run: pip install pytest mypy setuptools wheel
- name: Run the suite
run: python -m pytest tests -q -rs

Expand All @@ -57,6 +57,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install pytest setuptools wheel
- run: pip install pytest mypy setuptools wheel
- name: Run the suite
run: python -m pytest tests -q -rs
16 changes: 8 additions & 8 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,23 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`

| 상태 | 항목 |
|---|---|
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록) |
| 부분 | 배포 설정(스텁·`py.typed` 없음, 매니페스트가 패키지 밖), proxy 메서드의 키워드 인자는 아직 Kotlin 이름(`m.padding(paddingValues=...)`), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨), `Alignment`/`Arrangement` 상수(`pythonx` 에서 읽힘; 묶음 표기는 python-multiplatform #36 대기) |
| 계획 | `.pyi` 동봉, 선언형 앱 루트(#11), `remember_saveable`·`DefaultIcons`·코루틴 스코프 |
| 구현 | 매핑 매니페스트 `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 대기) |
| 계획 | 선언형 앱 루트(#11), `remember_saveable`·`DefaultIcons`·코루틴 스코프 |

### 테스트 기준선

```
python3 -m pytest tests -q
PythonMultiplatform 체크아웃 없음 55 passed, 56 skipped
python-multiplatform develop ba4c6f49+ 111 passed
PythonMultiplatform 체크아웃 없음 84 passed, 60 skipped
python-multiplatform develop ba4c6f49+ 143 passed, 1 skipped
그보다 오래된 체크아웃 같은 수, 그중 5 개 건너뜀 (member resolver 없음)
```

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

### 마일스톤

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ flowchart LR
px --> ax["androidx.compose.*<br/>Kotlin names, exposed by python-multiplatform"]
ax --> compose["Jetpack / Compose Multiplatform"]
map["pythonx-map.toml"] -.-> px
map -.-> pyi[".pyi stubs<br/>(for the wheel, planned)"]
map -.-> pyi[".pyi stubs<br/>(in the wheel)"]
```

| Python module | Kotlin package |
Expand Down Expand Up @@ -134,8 +134,8 @@ from a sibling [python-multiplatform](https://github.com/thisisthepy/python-mult
## 📦 Installation

Distributed as the pip package **`pythonx-compose`** (Python 3.11 or later), providing the import
package `pythonx.compose`, with the manifest inside the wheel; `.pyi` stubs generated from real
Compose and `py.typed` are planned. It runs inside an app that embeds CPython
package `pythonx.compose`, with the manifest inside the wheel, and typed: `.pyi` stubs generated from
real Compose 1.11.1 and `py.typed` ship with it. It runs inside an app that embeds CPython
through [python-multiplatform](https://github.com/thisisthepy/python-multiplatform); it is not a
standalone desktop toolkit.

Expand All @@ -152,7 +152,8 @@ standalone desktop toolkit.
| 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 |
| Material 3 widgets (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 partial — re-exported by rule; render proofs per widget pending (#9) |
| Distribution (`pythonx-compose`) | 🟡 partial — the wheel carries the manifest; no stubs from real Compose, no `py.typed` (#12) |
| 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` |
| Declared app root and Pythonic state, no update call | ⏳ planned (#11) |
| `TextField` input-method (IME) handling | ⏳ planned (#10) |
| `Icon`, `DefaultIcons`, colour schemes | ⏳ planned (python-multiplatform #37) |
Expand Down
89 changes: 67 additions & 22 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +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 #8)
## 0. Test baseline (2026-10-03, after issue #12)

Run from a worktree with `python3 -m pytest tests -q` (pytest 8, CPython 3.13):
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 | **55 passed, 56 skipped**, 66 subtests passed |
| python-multiplatform `develop` at `ba4c6f49` or later (has `add_member_resolver`) | **111 passed**, 72 subtests passed |
| an older checkout, without `add_member_resolver` | the same, with 5 of them skipped |
| 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 56 tests that install the binder's layers through `tests/adapter.py` skip.
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). A skip is not a
returned skip, 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 55 that pass without a checkout, most assert **absence** (a retired token, a deleted file, a docstring that
exists). Those are listed in §9 and are not counted as features.
Of the 84 that pass without a checkout, 30 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.

---

Expand All @@ -45,24 +48,66 @@ exists). Those are listed in §9 and are not counted as features.
- Shipped and tested: `tests/test_wheel.py` builds the wheel from a copy of the sources and finds
`pythonx/compose/pythonx-map.toml`, the re-export rule, and an `__init__.py` for every mapped
module (issue #13). The manifest lives inside the package directory so `package-data` carries it.
- Not yet true: no `.pyi` and no `py.typed` exist (S1.2, issue #12).
- Typed: the wheel carries `py.typed` and the generated `.pyi` stubs (S1.2, issue #12).

### S1.2 Type stubs ship in the wheel — `partial`
### S1.2 Type stubs ship in the wheel — `implemented`

Stubs carry the Pythonic names and signatures the runtime resolves, so the name an editor completes
and the name the interpreter resolves cannot drift.

- In this repository: `tools/gen_stubs.py` converts python-multiplatform's Kotlin-named stubs
(`PythonStubsTask` output, one `androidx/.../__init__.pyi` per package) into
`pythonx/compose/**/__init__.pyi`, using the runtime's own `python_name` / `snake_case`; an
overload set also gets `@overload`s of its base name; a package's own definitions
(`runtime.Composable`) are carried over. `tests/test_stubs.py` converts a fixture holding the fake
host's declarations and checks each stub signature against `inspect.signature` at run time.
Overloads come fewest parameters first, because a type checker takes the first match. Disabling
the parameter rename fails 2 tests, the overloads 1, the name rule 3, the ordering 1.
- Not yet true: no stub is generated from real Compose and none ships, and there is no `py.typed`.
That needs the Kotlin-named stubs for Compose 1.11.1 from python-multiplatform, and object types
better than `int` for completion to be useful (issue #12).
- **What is generated.** `pythonx/compose/**/__init__.pyi` for every module in `pythonx-map.toml`,
beside its `__init__.py`, and an empty `pythonx/compose/py.typed`. The stubs are committed;
regenerating them from the same input produces no diff.
- **From what.** python-multiplatform's CI artefact `kotlin-stubs` (workflow run 37077627980,
commit `86012ca0`): Kotlin-named stubs for Compose 1.11.1, one `androidx/compose/.../__init__.pyi`
per Kotlin package. `python3 tools/gen_stubs.py <kotlin-stubs.zip or directory>` converts them;
the first line of every generated stub records the artefact, run and commit it came from.
- **The rule is the runtime's** (`_reexport.py`), applied by `tools/gen_stubs.py`:
- a module function or constant is renamed with `python_name` (upper-case names kept, others
snake_case, an explicit overload keeps its suffix: `padding__Dp`); its parameters are
snake_case, except the positional-only `receiver` and anonymous `__aN` slots; keyword-only
markers and the binder's `@overload` sets, in the binder's order, are kept;
- a stub class keeps its name. Its extension members (`Modifier.fill_max_width`, a
`ClassVar` of a callable `Protocol`) are renamed with `python_name`, the member resolver's rule,
but the protocol's parameter names stay Kotlin's, because the runtime does not translate a
method's keywords yet (§3); every generated stub says so in its header;
- a Kotlin object served as a sub-package (`Alignment`, `Arrangement`) becomes a class of that
name in its parent module's stub, its constants `ClassVar`s and its functions snake_case
static methods. A sub-package directory holding only `__init__.pyi` would be a namespace
package at run time and would shadow the `KotlinObject` the re-export rule serves. The
generator reads both upstream layouts: the object as its own `<Object>/__init__.pyi`, or as a
class inside the parent stub (python-multiplatform #44). An object whose name the parent module
also uses for a function (`Color`, `TextStyle`, `PaddingValues`, `TextUnit`) is left out,
because at run time that name reaches the function;
- a reference to another Kotlin package is rewritten to the `pythonx.compose` module the
manifest lists first for it (`androidx.compose.foundation.layout` → `pythonx.compose.layout`);
a reference to a package the manifest does not map, or to a name the target stub does not
declare, becomes `typing.Any`;
- a module that maps the same Kotlin package as an earlier one (`pythonx.compose.foundation.layout`)
re-exports it (`from pythonx.compose.layout import *`), since at run time both names reach the
same objects; `[aliases]` become re-exports too (`material3` exports `Column`, `Row`, `Spacer`
from `pythonx.compose.layout`); names a package defines itself (`runtime.Composable`) are
carried over.
- **How it is verified.** `tests/test_stubs.py` converts two fixtures holding the fake host's
declarations, in the old and the current upstream format (and the current format with an object
in its parent stub), and checks the generated signatures against `inspect.signature` at run time.
`TheCommittedStubs` regenerates from `.tmp/kotlin-stubs.zip` and requires the committed files to
be identical (it skips where the artefact is absent, which includes CI). `tests/test_typing.py`
runs mypy over small programs against the committed stubs: correct code passes, a misspelled
keyword or function fails. `tests/test_wheel.py` finds `py.typed` and the stubs in the built wheel.
Disabling a step of the generator fails tests: folding objects into their parent 11, rewriting
references 9, keeping the binder's `# type: ignore` comments 7, renaming class members 5, the
second-module re-export 2, the aliases 2, the shadowed-object check 1.
- **Still missing.**
- Many parameter and return types are `Any`: upstream emits `Any` for a class that shares its
name with a function (`PaddingValues`, `TextStyle`, `Color`) and for packages it does not stub;
`Dp` is `float`.
- A method's keywords are Kotlin's in the stub because they are Kotlin's at run time (S4.1).
- The `pythonx.compose` root module maps `androidx.compose`, which has no stub, so its stub is
empty.
- Object constants named like a Python keyword (`FilterQuality.None`) cannot be written as an
attribute, and are left out.
- Regenerating needs the artefact downloaded by hand into `.tmp/`; CI does not fetch it.

## 2. The mapping manifest (`pythonx-map.toml`) — `implemented`

Expand Down
Loading
Loading