From 98529cb3a8f38f10169244be5fa780c53bf2ea8f Mon Sep 17 00:00:00 2001
From: BREW <81044362+b-re-w@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:10:42 +0900
Subject: [PATCH] Feat: Re-export every manifest module by one naming rule over
the binder's Kotlin names
The binder serves androidx.compose.* under Kotlin names only and publishes
inspect.signature and python_multiplatform.describe for a Pythonic layer to
read. This is that layer, as one rule in pythonx/compose/_reexport.py: each
module in pythonx-map.toml is a file on disk whose __init__ hands its name to
reexport(), and a name read from it resolves on first use.
- Upper-case names (types, objects, composables) keep their Kotlin spelling;
every other name is reached by its snake_case spelling only, and an explicit
overload keeps its suffix. The rule is applied forward to the Kotlin
module's real names and looked up, never inverted, so toURLString is
to_url_string and a collision is refused by name.
- Keywords are matched to the declaration's own parameter names from
describe(), every overload's; an unmatched keyword reaches the binder's own
refusal unchanged. inspect.signature reports snake_case names and keeps
KOTLIN_DEFAULT.
- The manifest's raw-primitive allowlist reaches the binder once per
installed binding layer.
- Names a package defines itself (runtime.Composable) win; resolution is lazy,
so importing a pythonx.compose module needs no binder.
foundation/, foundation/layout/, ui/text/ and ui/graphics/ get the one-line
__init__ their manifest rows already promised. ui/modifier.py drops the
synthetic-pythonx lookup and reaches Modifier through pythonx.compose.ui.
Methods on a proxy the binder returned (m.fill_max_width()) are the binder's
class's attributes and wait for its member resolver (python-multiplatform
#17, issue #20); the 4 tests that need it skip.
feature added: the re-export rule, the four missing package files.
test added/rewritten: test_chain.py against pythonx (Names 8, Laziness 5,
OverloadDispatch 7, ValueClasses 6, TheChain 6, Handles 1, sources 3),
test_modifier_module.py and test_runtime_module.py import normally.
Red: the new tests against develop, 31 failed, 18 passed, 4 skipped.
Disabling the name rule fails 15, the keyword mapping 1, the allowlist 12.
documentation corrected: SPEC 0/3/S4.1/S5.1/S5.2/S6.x, PROJECT.md, AGENTS.md
section 14, guide status and baseline, three package docstrings.
Suite with ../PythonMultiplatform (develop d00f413f): 44 passed, 33 failed ->
81 passed, 4 skipped. Without a checkout: 40 passed, 37 skipped -> 40 passed,
45 skipped. Each test module run separately passes. check_guide.py: 0.
Refs #8
---
AGENTS.md | 4 +-
PROJECT.md | 19 +-
docs/SPEC.md | 97 +++++---
docs/guide/getting-started.html | 4 +-
docs/guide/status.html | 4 +-
pythonx/compose/__init__.py | 4 +
pythonx/compose/_reexport.py | 230 ++++++++++++++++++
pythonx/compose/foundation/__init__.py | 5 +
pythonx/compose/foundation/layout/__init__.py | 5 +
pythonx/compose/layout/__init__.py | 8 +-
pythonx/compose/material3/__init__.py | 4 +
pythonx/compose/runtime/__init__.py | 4 +
pythonx/compose/ui/__init__.py | 9 +-
pythonx/compose/ui/graphics/__init__.py | 5 +
pythonx/compose/ui/modifier.py | 46 ++--
pythonx/compose/ui/text/__init__.py | 5 +
pythonx/compose/ui/unit/__init__.py | 10 +-
tests/fake_host.py | 5 +-
tests/test_chain.py | 167 +++++++++----
tests/test_modifier_module.py | 23 +-
tests/test_runtime_module.py | 16 +-
21 files changed, 511 insertions(+), 163 deletions(-)
create mode 100644 pythonx/compose/_reexport.py
create mode 100644 pythonx/compose/foundation/__init__.py
create mode 100644 pythonx/compose/foundation/layout/__init__.py
create mode 100644 pythonx/compose/ui/graphics/__init__.py
create mode 100644 pythonx/compose/ui/text/__init__.py
diff --git a/AGENTS.md b/AGENTS.md
index b2c52b1..7e5336d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -184,8 +184,8 @@ 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 one, 33 of them currently **fail** until the re-export lands (issue #8;
- see `docs/SPEC.md` §0). Report both numbers and say which environment you ran in. A skip is not
+ one they **skip**; with one, 4 of them still skip until the binder's member resolver lands
+ (python-multiplatform #17; see `docs/SPEC.md` §0). Report both numbers and say which environment you ran in. A skip is not
a pass.
- `PythonMultiplatform` is read-only from this repository. Never write to it from here.
- Record the before and after counts of every change; a change that only touches documentation
diff --git a/PROJECT.md b/PROJECT.md
index bc8d308..b91b45c 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -21,28 +21,27 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`
노트북의 함수 시그니처는 **이름 예시**다. 실제 규칙은 "Kotlin 원래 매개변수를 `snake_case` 로
노출한다" 이다 (`onclick` 이 아니라 `on_click`).
-## 3. 현황 (2026-10-02)
+## 3. 현황 (2026-10-03)
`docs/SPEC.md` 기준. 구현 = 이 저장소의 코드 + 읽고 통과를 확인한 테스트가 있는 것.
| 상태 | 항목 |
|---|---|
-| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터 |
-| 부분 | 배포 설정(스텁·`py.typed` 없음, 매니페스트가 패키지 밖), `Modifier` 체인·오버로드·`Dp` 숫자 허용(바인더 계층 대상 테스트가 실패 중), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨), `Alignment`/`Arrangement` 상수(괄호 없이 읽는 표기로 확정, `pythonx.*` 재노출 전) |
-| 계획 | `pythonx` 를 실제 디스크 패키지로 재구성, `.pyi` 동봉, `remember_saveable`·`DefaultIcons`·코루틴 스코프 |
+| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록) |
+| 부분 | 배포 설정(스텁·`py.typed` 없음, 매니페스트가 패키지 밖), proxy 위의 snake_case 확장 메서드(`m.fill_max_width()`, python-multiplatform #17 대기), Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`·색 스킴(import 만 됨), `Alignment`/`Arrangement` 상수(괄호 없이 읽음, 묶음 표기는 #9) |
+| 계획 | `.pyi` 동봉, 선언형 앱 루트(#11), `remember_saveable`·`DefaultIcons`·코루틴 스코프 |
### 테스트 기준선
```
python3 -m pytest tests -q
- PythonMultiplatform 체크아웃 없음 40 passed, 37 skipped
- PYTHONMULTIPLATFORM_HOME 지정 44 passed, 33 failed (재노출 전, #8)
+ PythonMultiplatform 체크아웃 없음 40 passed, 45 skipped
+ PYTHONMULTIPLATFORM_HOME 지정 81 passed, 4 skipped (4 개는 python-multiplatform #17 대기)
```
-33 개 실패는 **예상된 것**이다. `test_chain.py`·`test_modifier_module.py` 가 바인더의 Kotlin 이름
-표면을 `pythonx` 처럼 다루는 옛 하네스 호출을 쓰고 있어서, 이 패키지의 재노출 규칙(#8)에 맞춰 다시
-써야 한다. 그래서 해당 항목은 "부분" 으로 기록한다.
-통과하는 40 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
+건너뛰는 4 개는 바인더가 돌려준 proxy 위의 camelCase 확장을 snake_case 이름으로 부르는 테스트다.
+바인더의 member resolver(python-multiplatform #17)가 들어오면 실행된다. 건너뜀은 통과가 아니다.
+체크아웃 없이 통과하는 40 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
않는다.
### 마일스톤
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 439f5ab..7ad43c7 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -14,24 +14,20 @@ 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)
+## 0. Test baseline (2026-10-03, after issue #8)
Run from a worktree with `python3 -m pytest tests -q` (pytest 8, CPython 3.13):
| Environment | Result |
|---|---|
-| No `PythonMultiplatform` checkout found | **40 passed, 37 skipped**, 44 subtests passed |
-| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **44 passed, 33 failed**, 44 subtests passed |
-
-Without a checkout, the 37 tests that install the binder's layers through `tests/adapter.py` skip.
-With one, 4 of them pass (`test_runtime_module.py::TheModuleIsTheFileOnDisk`: 2,
-`test_ui_init_module.py::TheUiInitModuleIsTheFileOnDisk`: 2) and 33 fail (`test_chain.py`: 28,
-`test_modifier_module.py::TheModifierSeam`: 5). The 33 still call the old harness signature and
-exercise the binder's Kotlin-named surface as if it were `pythonx`; they are rewritten against this
-package's re-export rule (issue #8). That failure is expected and is why the items below that depend
-on those tests are `partial`, not `implemented`.
-
-Of the 40 that pass, most assert **absence** (a retired token, a deleted file, a docstring that
+| No `PythonMultiplatform` checkout found | **40 passed, 45 skipped**, 44 subtests passed |
+| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **81 passed, 4 skipped**, 44 subtests passed |
+
+Without a checkout, the 45 tests that install the binder's layers through `tests/adapter.py` skip.
+With one, all pass except 4 that skip because they call a camelCase extension on a binder proxy by
+its snake_case name, which needs python-multiplatform #17 (§3). A skip is not a pass.
+
+Of the 40 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.
---
@@ -82,34 +78,48 @@ notebook is git-ignored, so in a worktree or CI checkout it is absent, the expec
and **the test passes without checking anything.** It is only meaningful in the maintainer's main
checkout.
-## 3. `pythonx` is a real package — `planned`
+## 3. `pythonx` is a real package — `implemented` (module level)
Required by INTENT §2.2: `import pythonx.compose.material3` (and every other mapped module) loads
**this repository's files on disk**, and those files import the binder-exposed
`androidx.compose.*` modules and present them Pythonically.
-Half of it holds. `import pythonx.compose.runtime` and `import pythonx.compose.ui` load this
-repository's files with the binder installed (`TheModuleIsTheFileOnDisk`, `TheUiInitModuleIsTheFileOnDisk`),
-because PythonMultiplatform `d00f413f` moved its layer to `python_multiplatform.binding`. The other
-half has not landed: no on-disk module re-exports anything from `androidx.compose.*` yet (issue #8).
+Every module in `pythonx-map.toml` is a file on disk whose `__init__.py` hands its name to one rule,
+`pythonx/compose/_reexport.py` (issue #8). A name read from it resolves, on first use, against the
+Kotlin package the manifest maps it to: upper-case names keep their Kotlin spelling, every other
+name is reached by its snake_case spelling only, keywords are matched to the declaration's own
+parameter names, `inspect.signature` reports snake_case names, and the manifest's value-class
+allowlist is handed to the binder. Names the package defines itself (`runtime.Composable`) win.
+
+Evidence: `tests/test_runtime_module.py::TheModuleIsTheFileOnDisk`,
+`tests/test_ui_init_module.py::TheUiInitModuleIsTheFileOnDisk`, and `tests/test_chain.py`
+(`Names`, `Laziness`, `OverloadDispatch::test_the_module_function_dispatches_with_snake_case_keywords`,
+`ValueClasses::test_the_manifest_allowlist_is_what_lets_a_number_through`), against the binder's
+real Python and the fake host. Disabling the name rule fails 15 tests, the keyword mapping 1, the
+allowlist priming 12.
+
+Not covered by this: **methods on a proxy the binder returned** (`m.fill_max_width()`). Those are
+attributes of the binder's own class, which this package can only reach through the member-resolver
+hook python-multiplatform #17 adds; until then a camelCase extension is reachable on a proxy only by
+its Kotlin name, and four tests skip (S4.1, S6.1, S6.4).
## 4. Naming
### S4.1 Kotlin parameters, `snake_case` — `partial`
`onClick` → `on_click`, `fillMaxWidth` → `fill_max_width`, `zIndex` → `z_index`,
-`toURLString` → `to_url_string`; type names (`Modifier`) unchanged; a name the reverse rule cannot
-invert still resolves.
+`toURLString` → `to_url_string`; type names (`Modifier`) unchanged; an explicit overload keeps its
+type suffix (`padding__Dp_Dp`); a name the reverse rule cannot invert still resolves, because the
+rule is only ever applied forward to the module's real names; the camelCase spelling is not a second
+name.
-Evidence: `tests/test_chain.py::Names` (4 tests). These exercise the conversion in the binder's
-adaptation layer through `tests/adapter.py` and are currently skipped or failing (§0). No code in
-this repository performs the conversion.
+Implemented for module-level names and keywords (`tests/test_chain.py::Names`, 8 tests). Partial
+because receiver methods on a binder proxy need python-multiplatform #17 (§3, issue #20).
### S4.2 The notebook's spellings are examples, not the contract — `implemented` as a rule
Where `UI.ipynb` writes `onclick`, the surface is `on_click`
-(`tests/test_chain.py::Names::test_on_click_not_onclick` records the decision; it is subject to the
-same failure as S4.1).
+(`tests/test_chain.py::Names::test_on_click_not_onclick` records the decision).
## 5. Composables
@@ -120,7 +130,7 @@ given, which still runs and keeps its name and docstring. Composer threading is
job.
Tests: `tests/test_runtime_module.py::TheRuntimeSeam` (4 tests) and `::TheChaquopyMechanismIsGone`
-(3 tests). The module is loaded **by file path** in these tests, not by `import`, because of §3.
+(3 tests), importing `pythonx.compose.runtime` the ordinary way.
### S5.2 Material 3 composables reach Python without per-widget wrappers — `partial`
@@ -134,7 +144,9 @@ toggle family, `TextField`, `Checkbox` and `Switch` are meant to be reached thro
- The evidence that the widgets actually render and deliver callbacks is in `python-multiplatform`
(commits `a6742a1c`, `3fde8bd6`, `cac8243f`, cited in `pythonx/compose/material3/__init__.py`)
and was not re-run for this document.
-- Nothing in this repository yet re-exports them under `pythonx.compose.material3` (§3).
+- `pythonx.compose.material3` re-exports `androidx.compose.material3` by the §3 rule, but the fake
+ host binds no material3 declaration, so no test here exercises one; the render proof per widget
+ is issue #9.
### S5.3 `Icon` and colour schemes — `partial`
@@ -148,13 +160,14 @@ the two colour-scheme factories take 36 `Color` parameters. The notebook uses `I
### S6.1 `Modifier` extensions are methods on the receiver proxy — `partial`
-`Modifier.padding(16).size(24)` chains; each link returns a new receiver; a method is attached to
-the proxy type once; an unbound name raises `AttributeError` naming where it looked; the
-class-object spelling without a registered empty factory raises `TypeError` naming
-`register_empty`.
+`Modifier.padding(16).size(24)` chains; each link returns a new receiver; an unbound name raises
+`AttributeError` naming where it looked; the class-object spelling without a registered empty
+factory raises `TypeError` naming `register_empty`; `pythonx.compose.ui.Modifier` is the binder's
+proxy class itself.
-Evidence: `tests/test_chain.py::TheChain` (6 tests) and
-`tests/test_modifier_module.py::TheModifierSeam` (5 tests) — currently skipped or failing (§0).
+Evidence: `tests/test_chain.py::TheChain` (6 tests, 1 skipped) and
+`tests/test_modifier_module.py::TheModifierSeam` (5 tests). Partial because a camelCase extension
+(`fill_max_width`) on a proxy waits for python-multiplatform #17.
### S6.2 The empty-`Modifier` seam — `partial`
@@ -166,24 +179,28 @@ one-line Kotlin factory. The instance spelling (`m.padding(16)` on a `Modifier`
does not need it. `tests/test_modifier_module.py::TheShellIsGone` (3 tests, passing) checks the old
hand-written shell is gone.
-### S6.3 Overload dispatch — `partial`
+### S6.3 Overload dispatch — `implemented`
Among Kotlin overloads of one name, a call is dispatched by keyword name, argument count, then
declared type; a non-match names the candidates; an explicit overload spelling (`padding__Dp`)
-bypasses dispatch. Evidence: `tests/test_chain.py::OverloadDispatch` (6 tests), skipped/failing.
+bypasses dispatch; the module function takes snake_case keywords. Evidence:
+`tests/test_chain.py::OverloadDispatch` (7 tests). Status: `implemented`.
### S6.4 Value classes — `partial`
A raw number is accepted for a `Dp` parameter; a `Dp` value is accepted too; a plain `Float`
parameter is not treated as a value class; a packed value class (`TextUnit`) refuses a raw number and
says why; the allow-list can be extended at run time. The allow-list itself is the manifest's
-(S2, implemented). Runtime evidence: `tests/test_chain.py::ValueClasses` (5 tests), skipped/failing.
+(S2, implemented) and reaches the binder when a `pythonx.compose` module first resolves a name.
+Runtime evidence: `tests/test_chain.py::ValueClasses` (6 tests, 3 skipped until python-multiplatform
+#17, because they call camelCase extensions on a proxy).
-### S6.5 Lazy resolution and handle lifetime — `partial`
+### S6.5 Lazy resolution and handle lifetime — `implemented`
-A mapped package with bindings is importable, one without is not; a name is adapted once and then
-lives in the module dict; `dir()` reports what is bound; dropping a proxy releases its Kotlin
-handle. Evidence: `tests/test_chain.py::Laziness` (4 tests), `::Handles` (1 test), skipped/failing.
+A mapped module is a file on disk and imports without a binder; one with no file is not importable;
+a name is adapted once and then lives in the module dict; `dir()` reports the Pythonic names only;
+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`
diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html
index ffbad21..0b7e47f 100644
--- a/docs/guide/getting-started.html
+++ b/docs/guide/getting-started.html
@@ -43,9 +43,9 @@
1. Get the source1. 소스
2. Run the tests2. 테스트 실행
python3 -m pytest tests -q
-Expect 40 passed, 37 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
40 passed, 37 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
+Expect 40 passed, 45 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
40 passed, 45 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q
-With it, 4 of them pass and 33 currently fail: the binder no longer renames namespaces, and this package's re-export is not wired yet. That is expected and recorded.
지정하면 그중 4 개는 통과하고 33 개는 현재 실패합니다: 바인더는 더 이상 네임스페이스 이름을 바꾸지 않고, 이 패키지의 재노출은 아직 연결되지 않았습니다. 예상된 상태이며 기록되어 있습니다.
+With it, all but 4 pass; those 4 skip until the binder can resolve a snake_case method on a proxy it returned (python-multiplatform #17). That is expected and recorded.
지정하면 4 개를 뺀 전부가 통과합니다. 그 4 개는 바인더가 돌려준 proxy 의 snake_case 메서드를 바인더가 풀 수 있을 때까지(python-multiplatform #17) 건너뜁니다. 예상된 상태이며 기록되어 있습니다.
diff --git a/docs/guide/status.html b/docs/guide/status.html
index 525e528..dc9005a 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-map.toml매핑 매니페스트 pythonx-map.toml |
| implemented구현 | @Composable identity decorator@Composable 항등 데코레이터 |
| partial부분 | Distribution as pythonx-compose — configured; no stubs yet, manifest outside the package directorypythonx-compose 배포 — 설정됨; 스텁 없음, 매니페스트가 패키지 디렉터리 밖 |
| partial부분 | Modifier chains, overload dispatch, Dp as a number — tests failing while the runtime is rewiredModifier 체인, 오버로드 디스패치, 숫자 Dp — 런타임 재연결 중 테스트 실패 |
| partial부분 | Snake-case naming rule — tested against the binder's layer onlysnake_case 이름 규칙 — 바인더 계층 대상으로만 테스트 |
| partial부분 | Material 3 widgets — rendered in python-multiplatform, not yet re-exported hereMaterial 3 위젯 — python-multiplatform 에서 렌더링, 여기서는 재노출 전 |
| partial부분 | Icon, colour schemes — importable, not callableIcon, 색 스킴 — import 가능, 호출 불가 |
| partial부분 | Alignment / Arrangement constants — read without parentheses; not yet re-exported under pythonxAlignment / Arrangement 상수 — 괄호 없이 읽음; pythonx 재노출 전 |
| planned계획 | pythonx as a real on-disk package importing androidx.compose.*androidx.compose.* 를 import 하는 실제 디스크 패키지 pythonx |
| planned계획 | .pyi stubs in the wheelwheel 안의 .pyi 스텁 |
| planned계획 | remember_saveable, DefaultIcons, coroutine scopesremember_saveable, DefaultIcons, 코루틴 스코프 |
+| implemented구현 | Mapping manifest pythonx-map.toml매핑 매니페스트 pythonx-map.toml |
| implemented구현 | @Composable identity decorator@Composable 항등 데코레이터 |
| partial부분 | Distribution as pythonx-compose — configured; no stubs yet, manifest outside the package directorypythonx-compose 배포 — 설정됨; 스텁 없음, 매니페스트가 패키지 디렉터리 밖 |
| implemented구현 | Modifier chains, overload dispatch, Dp as a numberModifier 체인, 오버로드 디스패치, 숫자 Dp |
| partial부분 | Snake-case names, keywords and signatures — module level done; methods on a returned proxy wait for python-multiplatform #17snake_case 이름·키워드·시그니처 — 모듈 수준 완료, 반환된 proxy 의 메서드는 python-multiplatform #17 대기 |
| partial부분 | Material 3 widgets — re-exported by rule; render proofs per widget pending (#9)Material 3 위젯 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) |
| partial부분 | Icon, colour schemes — importable, not callableIcon, 색 스킴 — import 가능, 호출 불가 |
| partial부분 | Alignment / Arrangement constants — read without parentheses; grouped spelling pending (#9)Alignment / Arrangement 상수 — 괄호 없이 읽음; 묶음 표기 대기(#9) |
| implemented구현 | pythonx as a real on-disk package re-exporting androidx.compose.* by one ruleandroidx.compose.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonx |
| planned계획 | .pyi stubs in the wheelwheel 안의 .pyi 스텁 |
| planned계획 | remember_saveable, DefaultIcons, coroutine scopesremember_saveable, DefaultIcons, 코루틴 스코프 |
-Test baseline: 40 passed, 37 skipped without a python-multiplatform checkout; 44 passed, 33 failed with one.
테스트 기준선: python-multiplatform 체크아웃이 없으면 40 passed, 37 skipped, 있으면 44 passed, 33 failed.
+Test baseline: 40 passed, 45 skipped without a python-multiplatform checkout; 81 passed, 4 skipped with one.
테스트 기준선: python-multiplatform 체크아웃이 없으면 40 passed, 45 skipped, 있으면 81 passed, 4 skipped.
FAQ
diff --git a/pythonx/compose/__init__.py b/pythonx/compose/__init__.py
index e69de29..e23ca37 100644
--- a/pythonx/compose/__init__.py
+++ b/pythonx/compose/__init__.py
@@ -0,0 +1,4 @@
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/_reexport.py b/pythonx/compose/_reexport.py
new file mode 100644
index 0000000..08b01de
--- /dev/null
+++ b/pythonx/compose/_reexport.py
@@ -0,0 +1,230 @@
+"""The one rule that makes a `pythonx.compose.*` module a Pythonic view of a Kotlin package.
+
+The binder (`python-multiplatform`) serves every bound Kotlin package under its **Kotlin** name --
+`androidx.compose.foundation.layout` -- with Kotlin's own surface: declaration names, keyword
+arguments by Kotlin parameter name, Kotlin defaults, overload dispatch. It renames nothing, and it
+publishes what a Pythonic layer needs to rename by rule: `inspect.signature(fn)` with the Kotlin
+parameter names, and `python_multiplatform.describe(fn)` with one dict per overload.
+
+This module is that layer. A mapped package's `__init__.py` is one line::
+
+ __getattr__, __dir__ = reexport(__name__)
+
+and every name in it then resolves against the Kotlin package `pythonx-map.toml` maps it to:
+
+- **Names.** A name that starts upper-case -- a type, an object, a `@Composable` -- keeps its Kotlin
+ spelling (`Modifier`, `Text`). Any other name is reached by its snake_case spelling only
+ (`fillMaxWidth` -> `fill_max_width`, `toURLString` -> `to_url_string`), and an explicit overload
+ keeps its suffix (`padding__Dp`). The inverse is never computed: the module's real Kotlin names
+ are converted forward and looked up, so a name the reverse rule could not invert still resolves.
+- **Keywords.** `on_click=` reaches the parameter Kotlin calls `onClick`, matched against the
+ declaration's own parameter names (every overload's), never derived.
+- **Signatures.** A re-exported function answers `inspect.signature` with the snake_case names;
+ defaults stay `python_multiplatform.KOTLIN_DEFAULT`.
+- **Value classes.** The manifest's `raw-primitive-allowed` list is handed to the binder once per
+ installed binding layer, so `padding(16)` reaches a `Dp` parameter.
+
+Nothing here names a Kotlin declaration. Names defined in the package's own `__init__.py` (such as
+`runtime.Composable`) are ordinary module attributes and win, because `__getattr__` only runs for a
+name the module does not already have.
+"""
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import re
+import sys
+import weakref
+from functools import lru_cache
+from pathlib import Path
+
+ROOT_MODULE = "python_multiplatform"
+BINDING_MODULE = "python_multiplatform.binding"
+
+_MANIFEST = Path(__file__).resolve().parents[2] / "pythonx-map.toml"
+
+_LOWER_UPPER = re.compile(r"([a-z0-9])([A-Z])")
+_ACRONYM_WORD = re.compile(r"([A-Z]+)([A-Z][a-z])")
+
+
+def snake_case(kotlin_name: str) -> str:
+ """`fillMaxWidth` -> `fill_max_width`, `toURLString` -> `to_url_string`, `zIndex` -> `z_index`."""
+ return _LOWER_UPPER.sub(r"\1_\2", _ACRONYM_WORD.sub(r"\1_\2", kotlin_name)).lower()
+
+
+def python_name(kotlin_name: str) -> str:
+ """The one Pythonic spelling of a Kotlin declaration name.
+
+ Upper-case names are types, objects and composables and keep their Kotlin spelling. An explicit
+ overload (`padding__Dp`) converts its base and keeps the type suffix, which names Kotlin types.
+ """
+ if kotlin_name[:1].isupper():
+ return kotlin_name
+ base, sep, suffix = kotlin_name.partition("__")
+ return snake_case(base) + sep + suffix
+
+
+@lru_cache(maxsize=1)
+def manifest() -> dict:
+ """`pythonx-map.toml`, parsed once."""
+ import tomllib
+
+ with _MANIFEST.open("rb") as handle:
+ return tomllib.load(handle)
+
+
+def kotlin_package_of(module_name: str) -> str:
+ try:
+ return manifest()["modules"][module_name]
+ except KeyError:
+ raise LookupError(
+ f"{module_name} has no row in pythonx-map.toml; a re-exported module needs one"
+ ) from None
+
+
+def binding_layer():
+ """The binder's installed binding module, or a `RuntimeError` that says what did not happen."""
+ binding = sys.modules.get(BINDING_MODULE)
+ if binding is None:
+ raise RuntimeError(
+ "the python-multiplatform binding layer is not installed: the Kotlin host must run "
+ "PythonxAdapter.install() before pythonx.compose can resolve a Kotlin declaration"
+ )
+ return binding
+
+
+_PRIMED = weakref.WeakSet()
+
+
+def _prime(binding) -> None:
+ """Hand the manifest's value-class allowlist to this binding layer, once per layer."""
+ if binding in _PRIMED:
+ return
+ for kotlin_type in manifest().get("value-classes", {}).get("raw-primitive-allowed", ()):
+ binding.allow_raw_primitive(kotlin_type)
+ _PRIMED.add(binding)
+
+
+def kotlin_module(module_name: str):
+ """The binder's module for the Kotlin package `module_name` maps to."""
+ _prime(binding_layer())
+ return importlib.import_module(kotlin_package_of(module_name))
+
+
+def _name_table(kotlin) -> dict:
+ """Pythonic name -> Kotlin name for everything the Kotlin module reports, with collisions refused."""
+ table = {}
+ for kotlin_name in dir(kotlin):
+ if kotlin_name.startswith("_"):
+ continue
+ pythonic = python_name(kotlin_name)
+ other = table.get(pythonic)
+ if other is not None and other != kotlin_name:
+ raise AttributeError(
+ f"{pythonic!r} would name both {other!r} and {kotlin_name!r} in {kotlin.__name__}; "
+ "reach one by its explicit overload spelling"
+ )
+ table[pythonic] = kotlin_name
+ return table
+
+
+def _describe(fn):
+ root = sys.modules.get(ROOT_MODULE)
+ if root is None or not hasattr(root, "describe"):
+ return ()
+ try:
+ return root.describe(fn)
+ except (TypeError, AttributeError):
+ return ()
+
+
+class PythonicFunction:
+ """A binder callable seen through the naming rule: snake_case keywords and signature.
+
+ Calls go straight to the binder's callable with the keywords translated; overload selection,
+ defaults and value classes are the binder's. A keyword that matches no parameter is passed on
+ unchanged, so the binder's own refusal -- which lists the candidates -- is what the caller sees.
+ """
+
+ __slots__ = ("_kotlin", "_keywords", "__name__", "__qualname__")
+
+ def __init__(self, kotlin_fn, name):
+ self._kotlin = kotlin_fn
+ self.__name__ = name
+ self.__qualname__ = name
+ keywords = {}
+ for row in _describe(kotlin_fn):
+ for parameter in row.get("parameters", ()):
+ kotlin_parameter = parameter["name"]
+ keywords.setdefault(snake_case(kotlin_parameter), kotlin_parameter)
+ self._keywords = keywords
+
+ @property
+ def __kotlin__(self):
+ """The binder's callable this wraps."""
+ return self._kotlin
+
+ @property
+ def __kotlin_rows__(self):
+ return self._kotlin.__kotlin_rows__
+
+ @property
+ def __signature__(self):
+ kotlin_signature = inspect.signature(self._kotlin)
+ return kotlin_signature.replace(parameters=[
+ parameter if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD)
+ else parameter.replace(name=snake_case(parameter.name))
+ for parameter in kotlin_signature.parameters.values()
+ ])
+
+ def __call__(self, *args, **kwargs):
+ if kwargs:
+ kwargs = {self._keywords.get(key, key): value for key, value in kwargs.items()}
+ return self._kotlin(*args, **kwargs)
+
+ def __repr__(self):
+ return f" {self._kotlin!r}>"
+
+
+def _pythonic(value, name):
+ if isinstance(value, type) or not callable(value):
+ return value
+ return PythonicFunction(value, name)
+
+
+def reexport(module_name: str):
+ """The module-level `__getattr__` and `__dir__` for the mapped package `module_name`.
+
+ Nothing is resolved here, only when a name is first read, so importing a `pythonx.compose`
+ module needs neither the manifest row's Kotlin package nor an installed binder.
+ """
+
+ def __getattr__(name):
+ if name.startswith("__") and name.endswith("__"):
+ raise AttributeError(name)
+ kotlin = kotlin_module(module_name)
+ kotlin_name = _name_table(kotlin).get(name)
+ if kotlin_name is None:
+ raise AttributeError(
+ f"module {module_name!r} has no attribute {name!r} (nothing in Kotlin package "
+ f"{kotlin.__name__} is spelled that way under pythonx's naming rule)"
+ )
+ value = getattr(kotlin, kotlin_name)
+ if kotlin_name not in vars(kotlin):
+ # The binder declined to cache it -- a live property, read again on every access -- so
+ # this layer does not freeze it either.
+ return value
+ adapted = _pythonic(value, name)
+ setattr(sys.modules[module_name], name, adapted)
+ return adapted
+
+ def __dir__():
+ own = set(vars(sys.modules[module_name]))
+ try:
+ own.update(_name_table(kotlin_module(module_name)))
+ except (RuntimeError, ImportError):
+ pass
+ return sorted(own)
+
+ return __getattr__, __dir__
diff --git a/pythonx/compose/foundation/__init__.py b/pythonx/compose/foundation/__init__.py
new file mode 100644
index 0000000..174a2af
--- /dev/null
+++ b/pythonx/compose/foundation/__init__.py
@@ -0,0 +1,5 @@
+"""`pythonx.compose.foundation` -- the Pythonic view of `androidx.compose.foundation` (see `pythonx/compose/_reexport.py`)."""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/foundation/layout/__init__.py b/pythonx/compose/foundation/layout/__init__.py
new file mode 100644
index 0000000..d736780
--- /dev/null
+++ b/pythonx/compose/foundation/layout/__init__.py
@@ -0,0 +1,5 @@
+"""`pythonx.compose.foundation.layout` -- the Pythonic view of `androidx.compose.foundation.layout` (see `pythonx/compose/_reexport.py`)."""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/layout/__init__.py b/pythonx/compose/layout/__init__.py
index fb43495..be61559 100644
--- a/pythonx/compose/layout/__init__.py
+++ b/pythonx/compose/layout/__init__.py
@@ -10,8 +10,8 @@
`import pythonx.compose.layout` loads this file. The binder serves the declarations (`Arrangement`,
`Column`, `Row`, `Spacer`, ...) under their Kotlin package, `androidx.compose.foundation.layout`, and
-renames nothing; re-exporting them here under Pythonic names is this package's job and has not
-landed yet (SPEC section 3, issue #8).
+renames nothing; this file re-exports them under Pythonic names by the one rule in
+`pythonx/compose/_reexport.py` (SPEC section 3).
## Calling convention for constants
@@ -19,3 +19,7 @@
are read as attributes, **without parentheses** (`Arrangement.Start`, `TextStyle.Default`), since
PythonMultiplatform `46be0212`.
"""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/material3/__init__.py b/pythonx/compose/material3/__init__.py
index 3fa1947..f7ddf31 100644
--- a/pythonx/compose/material3/__init__.py
+++ b/pythonx/compose/material3/__init__.py
@@ -76,3 +76,7 @@
# is exactly what `CallbackDrivenRenderTest.kt` exercises above -- and the empty files cost nothing
# and assert nothing; they are left as they are rather than deleted, so as not to conflate "always
# empty" with "emptied on evidence" in the file history.
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/runtime/__init__.py b/pythonx/compose/runtime/__init__.py
index d48b878..f936566 100644
--- a/pythonx/compose/runtime/__init__.py
+++ b/pythonx/compose/runtime/__init__.py
@@ -55,3 +55,7 @@ def Composable(target):
what used to be here and why it is gone.
"""
return target
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/ui/__init__.py b/pythonx/compose/ui/__init__.py
index 091b645..f576918 100644
--- a/pythonx/compose/ui/__init__.py
+++ b/pythonx/compose/ui/__init__.py
@@ -10,9 +10,12 @@
`import pythonx.compose.ui` loads this file; it is an ordinary package on disk. The binder serves the
Kotlin package under its Kotlin name, `androidx.compose.ui`, as a separate module, and renames
-nothing. Re-exporting its declarations here under Pythonic names (`Modifier`, `Alignment`, ...) is
-this package's job and has not landed yet (SPEC section 3, issue #8). Until then this file carries
-no code.
+nothing. This file re-exports it under Pythonic names (`Modifier`, `describe_modifier`, ...) by the
+one rule in `pythonx/compose/_reexport.py` (SPEC section 3); it names no declaration itself.
"""
from __future__ import annotations
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/ui/graphics/__init__.py b/pythonx/compose/ui/graphics/__init__.py
new file mode 100644
index 0000000..a0dfd8d
--- /dev/null
+++ b/pythonx/compose/ui/graphics/__init__.py
@@ -0,0 +1,5 @@
+"""`pythonx.compose.ui.graphics` -- the Pythonic view of `androidx.compose.ui.graphics` (see `pythonx/compose/_reexport.py`)."""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/ui/modifier.py b/pythonx/compose/ui/modifier.py
index 539b14e..a551e7c 100644
--- a/pythonx/compose/ui/modifier.py
+++ b/pythonx/compose/ui/modifier.py
@@ -12,8 +12,9 @@
## What replaced it, and where that lives
-Not here. `Modifier` is an ordinary proxy the adaptation layer builds from the upcall table:
-`pythonx._proxy_type('androidx.compose.ui.Modifier')`, reached as `pythonx.compose.ui.Modifier`.
+Not here. `Modifier` is an ordinary proxy the binder builds from the upcall table and serves as
+`androidx.compose.ui.Modifier`; `pythonx.compose.ui.Modifier` is that same class, re-exported by
+the naming rule in `pythonx/compose/_reexport.py`.
Every one of its methods is a `Modifier` extension the artefact walker bound out of Compose's own
jars -- 104 of them at last count (`docs/kotlin-extensions-in-python.md` §3.2) -- and each is
attached to the proxy type on first read and never resolved again. So:
@@ -44,19 +45,20 @@
## How this module is reached
-By path, for now. Under the host adaptation layer `sys.modules['pythonx']` is built by
-`PythonxAdapter.DELIVERY` with `__path__ = []`, so no `pythonx/...` file on disk is importable at
-all; `docs/pythonx-adapter-design.md` §2.5 ("where `pythonx`'s own `.py` files live") is open and
-this file is inside it. Until it closes, an application loads this module with
-`importlib.util.spec_from_file_location` and calls [install]. Nothing here depends on being imported
-as `pythonx.compose.ui.modifier`.
+As `pythonx.compose.ui.modifier`, an ordinary module of this package. It needs the binder's layer
+(`python_multiplatform.binding`) to be installed by the host before [install] can do anything.
"""
from __future__ import annotations
+import importlib
import sys
-KOTLIN_TYPE = "androidx.compose.ui.Modifier"
+BINDING_MODULE = "python_multiplatform.binding"
+
+KOTLIN_PACKAGE = "androidx.compose.ui"
+
+KOTLIN_TYPE = KOTLIN_PACKAGE + ".Modifier"
"""The Kotlin type the proxy stands for. The walker reports it as `receiverTypeName`."""
PLACEHOLDER_EMPTY_FACTORY = "androidx.compose.ui.emptyModifier"
@@ -64,28 +66,26 @@
def adapter():
- """The installed adaptation layer, or a `RuntimeError` that says what did not happen.
+ """The binder's installed binding layer, or a `RuntimeError` that says what did not happen.
- `pythonx` is put into `sys.modules` by the Kotlin host, not by an import, so its absence means
- the host never ran `PythonxAdapter.install()` -- which is a different failure from a missing
- package and deserves a different message.
+ The Kotlin host installs it (`PythonxAdapter.install()`), not an import, so its absence means
+ the host never ran it -- a different failure from a missing package, with a different message.
"""
- module = sys.modules.get("pythonx")
- if module is None or not hasattr(module, "_proxy_type"):
+ module = sys.modules.get(BINDING_MODULE)
+ if module is None or not hasattr(module, "register_empty"):
raise RuntimeError(
- "the pythonx adaptation layer is not installed: the Kotlin host must run "
- "PythonxAdapter.install() (which execs it into sys.modules['pythonx']) before "
- "anything here can resolve a Modifier"
+ "the python-multiplatform binding layer is not installed: the Kotlin host must run "
+ "PythonxAdapter.install() before anything here can resolve a Modifier"
)
return module
def modifier_type(layer=None):
- """The `Modifier` proxy class -- the same object `pythonx.compose.ui.Modifier` is.
-
- Created on first call and cached by the layer, so this is an accessor and not a factory.
- """
- return (layer or adapter())._proxy_type(KOTLIN_TYPE)
+ """The `Modifier` proxy class -- the same object `pythonx.compose.ui.Modifier` is."""
+ if layer is None:
+ adapter()
+ # Through this package's own module, so the manifest's value-class allowlist is in force.
+ return importlib.import_module("pythonx.compose.ui").Modifier
def install(empty_factory=PLACEHOLDER_EMPTY_FACTORY, layer=None):
diff --git a/pythonx/compose/ui/text/__init__.py b/pythonx/compose/ui/text/__init__.py
new file mode 100644
index 0000000..2cc495b
--- /dev/null
+++ b/pythonx/compose/ui/text/__init__.py
@@ -0,0 +1,5 @@
+"""`pythonx.compose.ui.text` -- the Pythonic view of `androidx.compose.ui.text` (see `pythonx/compose/_reexport.py`)."""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/ui/unit/__init__.py b/pythonx/compose/ui/unit/__init__.py
index b2ae724..aba7cc4 100644
--- a/pythonx/compose/ui/unit/__init__.py
+++ b/pythonx/compose/ui/unit/__init__.py
@@ -6,6 +6,12 @@
## Dynamic unit handling
-`Dp` and value class conversions are handled natively by `pythonx.dp(16)` or dynamically by the
-adapter layer binding `androidx.compose.ui.unit.Dp`.
+A raw number reaches a `Dp` parameter because `pythonx-map.toml` allows it and the re-export rule
+hands that allowlist to the binder (`pythonx/compose/_reexport.py`). An explicit `Dp` value is
+`python_multiplatform.binding.value_of('androidx.compose.ui.unit.Dp', 16)`; a short `dp()` spelling
+is not provided yet.
"""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/tests/fake_host.py b/tests/fake_host.py
index 1086638..32b0a3e 100644
--- a/tests/fake_host.py
+++ b/tests/fake_host.py
@@ -243,8 +243,9 @@ def _build(self):
lambda a: f"paddingFromBaseline({_dp(a[0])})"),
)
self._add(
- # The name the snake -> camel rule cannot invert.
- "androidx.compose.ui.util.toURLString", 1, ("raw",), ("STRING",), ("kotlin.String",),
+ # The name the snake -> camel rule cannot invert. Fictional, and placed in a mapped
+ # package (`pythonx.compose.ui`) so the re-export rule is what reaches it.
+ "androidx.compose.ui.toURLString", 1, ("raw",), ("STRING",), ("kotlin.String",),
"STRING", "kotlin.String", False, None, (False,),
lambda args: "url:" + args[0],
)
diff --git a/tests/test_chain.py b/tests/test_chain.py
index f778c1a..ed75879 100644
--- a/tests/test_chain.py
+++ b/tests/test_chain.py
@@ -1,16 +1,18 @@
-"""The chain, and the four things `docs/pythonx-adapter-design.md` asks the adaptation layer for.
+"""The chain, the dispatcher and the naming rule, as an application reaches them: through `pythonx`.
-This is the test the 2024 tree never had. `pythonx/compose/ui/modifier.py` shipped a `padding()`
-that composed nothing and a `fill_max_size()` that returned `self`, and nothing in the repository
-could have noticed, because there was nothing to run.
+The binder serves Kotlin names (`androidx.compose.foundation.layout.paddingValuesOf`) and renames
+nothing. These tests go through this package's own modules (`pythonx.compose.*`), so what they
+check is the re-export rule in `pythonx/compose/_reexport.py` on top of the binder's real Python,
+read out of a `PythonMultiplatform` checkout by `tests/adapter.py`.
- python3 -m unittest discover -s tests -v
+ python3 -m pytest tests -q
Requires a `PythonMultiplatform` checkout beside this one, or `PYTHONMULTIPLATFORM_HOME`.
"""
from __future__ import annotations
+import inspect
import sys
import unittest
from pathlib import Path
@@ -22,17 +24,16 @@
class AdapterCase(unittest.TestCase):
- """One adaptation layer and one host per test, because a table install is destructive."""
+ """One binding layer and one host per test, because a table install is destructive."""
def setUp(self):
try:
- source = adapter_loader.read_adapter_source()
+ self.binding = adapter_loader.install()
except adapter_loader.AdapterUnavailable as unavailable:
self.skipTest(str(unavailable))
self.host = fake_host.FakeHost()
self.host.bind()
- self.pythonx = adapter_loader.install(source)
- self.host.register(self.pythonx)
+ self.host.register(self.binding)
self.addCleanup(self.host.unbind)
self.addCleanup(adapter_loader.uninstall)
@@ -48,7 +49,7 @@ def register_empty(self):
`test_modifier_module.py` is what tests that file; this keeps the chain tests independent of
it, so a break in one does not read as a break in the other.
"""
- self.pythonx.register_empty(fake_host.MODIFIER, fake_host.EMPTY_MODIFIER)
+ self.binding.register_empty(fake_host.MODIFIER, fake_host.EMPTY_MODIFIER)
return self.modifier_type()
def empty(self):
@@ -59,14 +60,30 @@ def describe(self, modifier):
return ui.describe_modifier(modifier)
+ def needs_member_resolver(self):
+ """Receiver methods by snake_case name need the binder's member-resolver hook.
-class TheAdapterSourceIsReadable(AdapterCase):
+ python-multiplatform #17 adds it; until a checkout has it, these tests skip rather than pass.
+ """
+ if not hasattr(self.binding, "add_member_resolver"):
+ self.skipTest("the binder has no add_member_resolver yet (python-multiplatform #17)")
+
+
+class TheBinderSourcesAreReadable(AdapterCase):
- def test_the_source_came_out_of_the_kotlin_literal_and_compiles(self):
+ def test_both_sources_came_out_of_the_kotlin_literals_and_compile(self):
+ binding = adapter_loader.read_adapter_source()
+ surface = adapter_loader.read_surface_source()
+ self.assertIn("class _Finder", binding)
+ self.assertIn("KOTLIN_DEFAULT", surface)
+ compile(binding, "python_multiplatform/binding.py", "exec")
+ compile(surface, "python_multiplatform/__init__.py", "exec")
+
+ def test_the_binder_renames_nothing(self):
+ """The rule lives here; the binder has no snake_case of its own to lean on."""
source = adapter_loader.read_adapter_source()
- self.assertIn("class _Finder", source)
- self.assertIn("def to_python_name(", source)
- compile(source, "pythonx/__init__.py", "exec")
+ self.assertNotIn("def to_python_name(", source)
+ self.assertNotIn("def register_package(", source)
def test_the_table_is_the_walked_shape(self):
"""`padding__Dp` here carries every field the real walked entry carries.
@@ -94,12 +111,18 @@ def test_the_table_is_the_walked_shape(self):
class TheChain(AdapterCase):
"""`Modifier.padding(16).size(24)` -- the thing the 2024 tree could not do."""
+ def test_modifier_is_the_binders_proxy_class(self):
+ import androidx.compose.ui as kotlin_ui
+
+ self.assertIs(kotlin_ui.Modifier, self.modifier_type())
+
def test_chain_from_the_class_object(self):
chained = self.register_empty().padding(16).size(24)
self.assertEqual("padding(16.0) -> size(24.0)", self.describe(chained))
self.assertEqual(["padding__Dp", "size__Dp"], self.host.calls)
def test_chain_from_an_instance(self):
+ self.needs_member_resolver()
chained = self.empty().padding(16).size(24).fill_max_width()
self.assertEqual(
"padding(16.0) -> size(24.0) -> fillMaxWidth", self.describe(chained)
@@ -113,13 +136,6 @@ def test_each_link_is_a_new_receiver_not_a_mutation(self):
self.assertEqual("padding(8.0) -> size(1.0)", self.describe(left))
self.assertEqual("padding(8.0) -> size(2.0)", self.describe(right))
- def test_a_method_is_attached_to_the_type_once(self):
- first = self.register_empty()
- self.assertNotIn("padding", vars(first))
- first.padding(1)
- self.assertIn("padding", vars(first))
- self.assertIs(first, self.modifier_type())
-
def test_an_unbound_name_is_an_attribute_error_that_says_where_it_looked(self):
with self.assertRaises(AttributeError) as raised:
self.empty().fill_max_size()
@@ -173,50 +189,96 @@ def test_the_explicit_spelling_bypasses_the_dispatcher(self):
getattr(self.empty(), "padding__Dp")(16)
self.assertEqual(["padding__Dp"], self.host.calls)
+ def test_the_module_function_dispatches_with_snake_case_keywords(self):
+ import pythonx.compose.foundation.layout as layout
+
+ values = layout.padding_values_of(8)
+ result = layout.padding(self.empty(), padding_values=values)
+ self.assertEqual(["padding__PaddingValues"], self.host.calls)
+ self.assertEqual("padding(pv(8.0))", self.describe(result))
+
class Names(AdapterCase):
- """§3: PascalCase for types, snake_case for everything else, forward by rule."""
+ """PascalCase for types, snake_case for everything else, forward by rule and never inverted."""
def test_camel_case_becomes_snake_case(self):
- self.assertEqual("fill_max_width", self.pythonx.to_python_name("fillMaxWidth"))
- self.assertEqual("z_index", self.pythonx.to_python_name("zIndex"))
- self.assertEqual("to_url_string", self.pythonx.to_python_name("toURLString"))
+ from pythonx.compose._reexport import python_name
+
+ self.assertEqual("fill_max_width", python_name("fillMaxWidth"))
+ self.assertEqual("z_index", python_name("zIndex"))
+ self.assertEqual("to_url_string", python_name("toURLString"))
+ self.assertEqual("padding__Dp_Dp", python_name("padding__Dp_Dp"))
def test_a_type_name_is_left_alone(self):
- self.assertEqual("Modifier", self.pythonx.to_python_name("Modifier"))
+ from pythonx.compose._reexport import python_name
+
+ self.assertEqual("Modifier", python_name("Modifier"))
def test_a_name_the_reverse_rule_cannot_invert_still_resolves(self):
- import pythonx.compose.ui.util as util
+ import pythonx.compose.ui as ui
+
+ self.assertEqual("url:x", ui.to_url_string("x"))
- self.assertEqual("url:x", util.to_url_string("x"))
- self.assertEqual("toUrlString", self.pythonx.to_kotlin_name("to_url_string"))
+ def test_the_kotlin_camel_case_spelling_is_not_a_second_name(self):
+ import pythonx.compose.foundation.layout as layout
+
+ with self.assertRaises(AttributeError):
+ layout.paddingValuesOf # noqa: B018
def test_on_click_not_onclick(self):
"""`UI.ipynb` writes `onclick`; the decision on record is that `pythonx` is the reference."""
- self.assertEqual("on_click", self.pythonx.to_python_name("onClick"))
+ from pythonx.compose._reexport import python_name
+
+ self.assertEqual("on_click", python_name("onClick"))
+
+ def test_the_signature_carries_snake_case_parameter_names(self):
+ import pythonx.compose.foundation.layout as layout
+
+ parameters = list(inspect.signature(layout.padding__PaddingValues).parameters)
+ self.assertEqual(["receiver", "padding_values"], parameters)
+
+ def test_a_defaulted_parameter_keeps_the_kotlin_default_marker(self):
+ import python_multiplatform
+ import pythonx.compose.foundation.layout as layout
+
+ signature = inspect.signature(layout.padding__Dp_Dp)
+ self.assertIs(python_multiplatform.KOTLIN_DEFAULT, signature.parameters["horizontal"].default)
+
+ def test_an_unknown_keyword_reaches_the_binders_refusal(self):
+ import pythonx.compose.foundation.layout as layout
+
+ with self.assertRaises(TypeError) as raised:
+ layout.padding_values_of(everything=8)
+ self.assertIn("everything", str(raised.exception))
class ValueClasses(AdapterCase):
- """§4.4: `Dp` takes a raw number, a packed wrapper must not."""
+ """`Dp` takes a raw number, a packed wrapper must not; the manifest says which."""
def test_a_raw_number_reaches_a_dp_parameter(self):
self.assertEqual("padding(16.0)", self.describe(self.empty().padding(16)))
+ def test_the_manifest_allowlist_is_what_lets_a_number_through(self):
+ """The binder alone refuses a raw number for `Dp`; importing through `pythonx` allows it."""
+ import androidx.compose.foundation.layout as kotlin_layout
+
+ with self.assertRaises(TypeError):
+ kotlin_layout.paddingValuesOf(8)
+ import pythonx.compose.foundation.layout as layout
+
+ result = self.empty().padding(layout.padding_values_of(8))
+ self.assertEqual("padding(pv(8.0))", self.describe(result))
+
def test_a_dp_proxy_reaches_the_same_parameter(self):
- # `pythonx.dp(...)` used to exist, defined in the binder itself -- which meant the language
- # boundary shipped a Compose spelling and any other library's value class needed a core
- # edit to get one. The binder now offers only the general form, and `dp` is a name this
- # package owes on top of it. Written the general way until there is somewhere to put the
- # short one: the disk modules under `pythonx/compose/` are never loaded (the adapter's
- # finder answers those imports), so a hand-written `dp` needs a home this package does not
- # have yet.
- result = self.empty().padding(self.pythonx.value_of("androidx.compose.ui.unit.Dp", 16))
+ result = self.empty().padding(self.binding.value_of(fake_host.DP, 16))
self.assertEqual("padding(16.0)", self.describe(result))
def test_a_plain_float_parameter_is_not_treated_as_a_value_class(self):
+ self.needs_member_resolver()
self.assertEqual("zIndex(1.5)", self.describe(self.empty().z_index(1.5)))
def test_a_packed_value_class_refuses_a_raw_number_and_says_why(self):
+ self.needs_member_resolver()
with self.assertRaises(TypeError) as raised:
self.empty().padding_from_baseline(16)
message = str(raised.exception)
@@ -224,21 +286,22 @@ def test_a_packed_value_class_refuses_a_raw_number_and_says_why(self):
self.assertIn("reinterpreted", message)
def test_the_allowlist_can_be_extended_at_run_time(self):
- self.pythonx.allow_raw_primitive(fake_host.TEXT_UNIT)
+ self.needs_member_resolver()
+ self.binding.allow_raw_primitive(fake_host.TEXT_UNIT)
self.assertEqual(
"paddingFromBaseline(16.0)", self.describe(self.empty().padding_from_baseline(16))
)
class Laziness(AdapterCase):
- """§2.3: a finder for the module, a `__getattr__` for the names inside it."""
+ """A mapped module is a file on disk; the names inside it resolve on first use."""
- def test_a_package_something_is_bound_under_is_importable(self):
+ def test_a_mapped_package_is_importable(self):
import pythonx.compose.foundation.layout as layout
self.assertEqual("pythonx.compose.foundation.layout", layout.__name__)
- def test_a_package_nothing_is_bound_under_is_not(self):
+ def test_a_package_with_no_file_is_not(self):
with self.assertRaises(ModuleNotFoundError):
import pythonx.compose.nothing.here # noqa: F401
@@ -250,17 +313,29 @@ def test_a_name_is_adapted_once_and_then_lives_in_the_module_dict(self):
self.assertIn("padding", vars(layout))
self.assertIs(first, layout.padding)
- def test_dir_reports_what_is_bound(self):
+ def test_dir_reports_what_is_bound_under_pythonic_names(self):
import pythonx.compose.foundation.layout as layout
names = dir(layout)
self.assertIn("padding", names)
self.assertIn("size", names)
self.assertIn("fill_max_width", names)
+ self.assertNotIn("fillMaxWidth", names)
+
+ def test_without_the_binding_layer_a_name_says_what_is_missing(self):
+ import pythonx.compose.foundation.layout as layout
+
+ adapter_loader.uninstall()
+ import pythonx.compose.foundation.layout as fresh
+
+ self.assertIsNot(layout, fresh)
+ with self.assertRaises(RuntimeError) as raised:
+ fresh.padding # noqa: B018
+ self.assertIn("PythonxAdapter.install()", str(raised.exception))
class Handles(AdapterCase):
- """§4.1's proxy is what owns a handle; `kotlin-extensions` §3.2 recorded three leaking."""
+ """The binder's proxy owns a handle; dropping it gives the handle back."""
def test_dropping_a_proxy_releases_its_handle(self):
modifier = self.empty().padding(16)
diff --git a/tests/test_modifier_module.py b/tests/test_modifier_module.py
index ea61aba..f8936d4 100644
--- a/tests/test_modifier_module.py
+++ b/tests/test_modifier_module.py
@@ -16,7 +16,6 @@
from __future__ import annotations
-import importlib.util
import sys
import unittest
from pathlib import Path
@@ -30,31 +29,19 @@
MODIFIER_PY = REPO / "pythonx" / "compose" / "ui" / "modifier.py"
-def load_by_path(path, name):
- """Load a `pythonx/...` file directly.
-
- Not `import pythonx.compose.ui.modifier`: the host builds `sys.modules['pythonx']` with
- `__path__ = []`, so nothing on disk under that name is importable.
- `docs/pythonx-adapter-design.md` §2.5 is why, and it is open.
- """
- spec = importlib.util.spec_from_file_location(name, path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-
class TheModifierSeam(unittest.TestCase):
def setUp(self):
try:
- source = adapter_loader.read_adapter_source()
+ self.binding = adapter_loader.install()
except adapter_loader.AdapterUnavailable as unavailable:
self.skipTest(str(unavailable))
self.host = fake_host.FakeHost()
self.host.bind()
- self.pythonx = adapter_loader.install(source)
- self.host.register(self.pythonx)
- self.modifier = load_by_path(MODIFIER_PY, "_repo_modifier")
+ self.host.register(self.binding)
+ import pythonx.compose.ui.modifier as modifier
+
+ self.modifier = modifier
self.addCleanup(self.host.unbind)
self.addCleanup(adapter_loader.uninstall)
diff --git a/tests/test_runtime_module.py b/tests/test_runtime_module.py
index 9ec0c00..52ecce7 100644
--- a/tests/test_runtime_module.py
+++ b/tests/test_runtime_module.py
@@ -38,7 +38,6 @@ class body executed:
from __future__ import annotations
-import importlib.util
import io
import sys
import tokenize
@@ -46,6 +45,7 @@ class body executed:
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import adapter as adapter_loader # noqa: E402
import fake_host # noqa: E402
@@ -54,14 +54,6 @@ class body executed:
RUNTIME_PY = REPO / "pythonx" / "compose" / "runtime" / "__init__.py"
-def load_by_path(path, name):
- """Load a `pythonx/...` file without importing its package, for the tests that need no binder."""
- spec = importlib.util.spec_from_file_location(name, path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-
class TheModuleIsTheFileOnDisk(unittest.TestCase):
"""`import pythonx.compose.runtime` loads this repository's file, with the binder installed.
@@ -97,7 +89,9 @@ class TheRuntimeSeam(unittest.TestCase):
"""What `Composable` does once loaded by path: nothing to the function it decorates."""
def setUp(self):
- self.runtime = load_by_path(RUNTIME_PY, "_repo_runtime")
+ import pythonx.compose.runtime as runtime
+
+ self.runtime = runtime
def test_composable_is_importable_and_callable(self):
self.assertTrue(callable(self.runtime.Composable))
@@ -152,7 +146,7 @@ def test_no_content_slot_is_detected_by_inspecting_code_objects(self):
def test_composable_takes_no_arguments_beyond_the_target(self):
import inspect
- runtime = load_by_path(RUNTIME_PY, "_repo_runtime_sig")
+ import pythonx.compose.runtime as runtime
sig = inspect.signature(runtime.Composable)
self.assertEqual(1, len(sig.parameters), "an identity decorator needs exactly one parameter")