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")