From 37feac039bffa942895c37c944c289f7532996dd Mon Sep 17 00:00:00 2001
From: BREW <81044362+b-re-w@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:26:49 +0900
Subject: [PATCH] Feat: Answer for Column, Row and Spacer from material3, as
the notebook imports them
INTENT 5.2: the notebook writes `from pythonx.compose.material3 import Column,
Row, Spacer`, while Kotlin declares them in foundation.layout. The manifest
gains an [aliases] section -- data, not wrappers -- and the re-export rule
answers a listed name from the module it is lent from, so material3.Column is
the same object as layout.Column. dir(material3) lists them; an unlisted
layout name is not borrowed.
Making that testable exposed a defect: material3/__init__.py star-imported
icon.py and color_scheme.py, which import androidx.compose.material3 at load.
So `import pythonx.compose.material3` failed wherever that Kotlin package was
not bound, and their dead Icon / ColorScheme classes sat in front of the
binder's real ones. The package no longer imports them; the files stay, as
SPEC's open question 5 says.
A name read from a module whose Kotlin package is not bound is now an
AttributeError naming the cause, rather than an ImportError from an attribute
access.
feature added: manifest aliases.
defect fixed: material3 no longer imports the two records at load.
test added: ManifestAliases (3), TestAliasSection (2),
ThePackageDoesNotImportTheRecords (2). Red: 3 failed for the aliases
(ModuleNotFoundError at import), 2 failed for the guard.
documentation corrected: SPEC 0, 8 and Outside intent 5-6, PROJECT.md, guide.
Suite against python-multiplatform ba4c6f49 and the current sibling: 104 ->
111 passed. Without a checkout: 51 passed, 53 skipped -> 55 passed, 56
skipped. Each module passes alone. check_guide.py: 0.
Refs #9
---
PROJECT.md | 6 +++---
docs/SPEC.md | 15 +++++++++------
docs/guide/getting-started.html | 2 +-
docs/guide/status.html | 2 +-
pythonx/compose/_reexport.py | 27 +++++++++++++++++++++++++--
pythonx/compose/material3/__init__.py | 8 +++++---
pythonx/compose/pythonx-map.toml | 7 +++++++
tests/fake_host.py | 8 ++++++++
tests/test_chain.py | 24 ++++++++++++++++++++++++
tests/test_material3_module.py | 26 ++++++++++++++++++++++++++
tests/test_pythonx_map.py | 15 +++++++++++++++
11 files changed, 124 insertions(+), 16 deletions(-)
diff --git a/PROJECT.md b/PROJECT.md
index bc07e53..fdfc105 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -35,14 +35,14 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`
```
python3 -m pytest tests -q
- PythonMultiplatform 체크아웃 없음 51 passed, 53 skipped
- python-multiplatform develop ba4c6f49+ 104 passed
+ PythonMultiplatform 체크아웃 없음 55 passed, 56 skipped
+ python-multiplatform develop ba4c6f49+ 111 passed
그보다 오래된 체크아웃 같은 수, 그중 5 개 건너뜀 (member resolver 없음)
```
member resolver(python-multiplatform `ba4c6f49`)가 없는 바인더에서는 proxy 의 snake_case 메서드를 부르는
5 개가 건너뛴다. 건너뜀은 통과가 아니다.
-체크아웃 없이 통과하는 51 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
+체크아웃 없이 통과하는 55 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
않는다.
### 마일스톤
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 16e194f..1a10280 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -20,16 +20,16 @@ Run from a worktree with `python3 -m pytest tests -q` (pytest 8, CPython 3.13):
| Environment | Result |
|---|---|
-| No `PythonMultiplatform` checkout found | **51 passed, 53 skipped**, 66 subtests passed |
-| python-multiplatform `develop` at `ba4c6f49` or later (has `add_member_resolver`) | **104 passed**, 72 subtests passed |
+| No `PythonMultiplatform` checkout found | **55 passed, 56 skipped**, 67 subtests passed |
+| python-multiplatform `develop` at `ba4c6f49` or later (has `add_member_resolver`) | **111 passed**, 73 subtests passed |
| an older checkout, without `add_member_resolver` | the same, with 5 of them skipped |
-Without a checkout, the 53 tests that install the binder's layers through `tests/adapter.py` skip.
+Without a checkout, the 56 tests that install the binder's layers through `tests/adapter.py` skip.
Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the binder
returned skip, because that needs its member resolver (python-multiplatform #17). A skip is not a
pass.
-Of the 51 that pass without a checkout, most assert **absence** (a retired token, a deleted file, a docstring that
+Of the 55 that pass without a checkout, most assert **absence** (a retired token, a deleted file, a docstring that
exists). Those are listed in §9 and are not counted as features.
---
@@ -248,7 +248,7 @@ name)` (python-multiplatform #36; issue #9).
| `DefaultCoroutineScope`, `MainCoroutineScope` | imported from `pythonx.compose.runtime` | |
| `DefaultIcons` | `DefaultIcons.Add()` | needs `Icon` (S5.3) |
| `modifier` | lower-case instance from `pythonx.compose.ui` | INTENT §4 open question 1 |
-| `Column`, `Row`, `Spacer` | imported from `pythonx.compose.material3` | INTENT §5.2: served from both `material3` and `layout` |
+| `Column`, `Row`, `Spacer` | imported from `pythonx.compose.material3` | INTENT §5.2: served from both, by the manifest's `[aliases]` (`tests/test_chain.py::ManifestAliases`); render proof pending (#9) |
| `Card`, `Button`, `Text`, `TextField` | as above | S5.2 |
| `main.App`, `App.update(...)` | live screen replacement from a cell | INTENT §5.1: a declared root that a redefinition replaces, no update function (issue #11) |
@@ -283,7 +283,10 @@ Found in the repository; not covered by `docs/INTENT.md`, or in conflict with it
5. **A removed mechanism kept as a record.** `material3/icon.py` and `color_scheme.py` still import
`androidx.compose.material3.IconKt` and scan for mangled JVM names (`"Icon-"`), the reflection
approach INTENT §3 excludes.
-6. *(Decided, INTENT §5.3.)* Grouped alignment constants: both spellings are served (§7).
+ The package no longer imports them: star-importing them made `import pythonx.compose.material3`
+ fail wherever `androidx.compose.material3` was not bound, and shadowed the binder's `Icon`.
+ Whether the files stay is still open.
+6. *(Decided, INTENT §5.3.)* Grouped alignment constants: both spellings are to be served; the grouped one is not built yet (§7).
7. **28 empty `material3/*.py` files** (`checkbox.py`, `switch.py`, `scaffold.py`, ...), and the
submodule `pythonx/compose/native` → `thisisthepy/swing-graalvm-demo`, which INTENT does not
mention.
diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html
index 61215a8..b980a55 100644
--- a/docs/guide/getting-started.html
+++ b/docs/guide/getting-started.html
@@ -43,7 +43,7 @@
1. Get the source1. 소스
2. Run the tests2. 테스트 실행
python3 -m pytest tests -q
-
Expect 51 passed, 53 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
51 passed, 53 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
+
Expect 55 passed, 56 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
55 passed, 56 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
With a current one (ba4c6f49 or later) all pass; an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.
최신 체크아웃(ba4c6f49 이후)이면 전부 통과하고, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.
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 — done for modules and method names; method keywords are still Kotlin'ssnake_case 이름·키워드·시그니처 — 모듈과 메서드 이름 완료, 메서드 키워드는 아직 Kotlin 이름
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 — reached from pythonx; grouped spelling pendingAlignment / Arrangement 상수 — pythonx 에서 읽힘; 묶음 표기 대기
implemented구현
pythonx as a real on-disk package re-exporting androidx.compose.* by one ruleandroidx.compose.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonx
Test baseline: 51 passed, 53 skipped without a python-multiplatform checkout; 104 passed with a current one.
테스트 기준선: python-multiplatform 체크아웃이 없으면 51 passed, 53 skipped, 최신 체크아웃이면 104 passed.
+
Test baseline: 55 passed, 56 skipped without a python-multiplatform checkout; 111 passed with a current one.
테스트 기준선: python-multiplatform 체크아웃이 없으면 55 passed, 56 skipped, 최신 체크아웃이면 111 passed.
FAQ
diff --git a/pythonx/compose/_reexport.py b/pythonx/compose/_reexport.py
index 29a08cc..552f6f2 100644
--- a/pythonx/compose/_reexport.py
+++ b/pythonx/compose/_reexport.py
@@ -27,6 +27,10 @@
`Modifier.padding(16).fill_max_width()` reaches `fillMaxWidth` on a proxy Kotlin returned. The
binder caches the alias in its own registry; its proxy classes keep Kotlin names in `dir()`.
+A module may also answer for names the manifest's `[aliases]` section lends it from another mapped
+module (`material3` answers for `Column`, `Row`, `Spacer` from `layout`, as the notebook imports
+them); the object is the same one either way.
+
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.
@@ -276,6 +280,15 @@ def __repr__(self):
return f""
+def _aliases(module_name: str) -> dict:
+ """Name -> the `pythonx.compose` module it is borrowed from, per the manifest's `[aliases]`."""
+ borrowed = {}
+ for source, names in manifest().get("aliases", {}).get(module_name, {}).items():
+ for name in names:
+ borrowed[name] = source
+ return borrowed
+
+
def reexport(module_name: str):
"""The module-level `__getattr__` and `__dir__` for the mapped package `module_name`.
@@ -286,7 +299,17 @@ def reexport(module_name: str):
def __getattr__(name):
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
- kotlin = kotlin_module(module_name)
+ source = _aliases(module_name).get(name)
+ if source is not None:
+ value = getattr(importlib.import_module(source), name)
+ setattr(sys.modules[module_name], name, value)
+ return value
+ try:
+ kotlin = kotlin_module(module_name)
+ except ImportError as missing:
+ raise AttributeError(
+ f"module {module_name!r} has no attribute {name!r} ({missing})"
+ ) from missing
resolved = _resolve(kotlin, name)
if resolved is None:
raise AttributeError(
@@ -299,7 +322,7 @@ def __getattr__(name):
return value
def __dir__():
- own = set(vars(sys.modules[module_name]))
+ own = set(vars(sys.modules[module_name])) | set(_aliases(module_name))
try:
own.update(_name_table(kotlin_module(module_name)))
except (RuntimeError, ImportError):
diff --git a/pythonx/compose/material3/__init__.py b/pythonx/compose/material3/__init__.py
index f7ddf31..5c43820 100644
--- a/pythonx/compose/material3/__init__.py
+++ b/pythonx/compose/material3/__init__.py
@@ -1,6 +1,8 @@
-# Hand-written fallback wrappers for declarations unreachable through adaptation layer alone
-from .color_scheme import *
-from .icon import *
+# `icon.py` and `color_scheme.py` are records, not part of this package's surface, and are not
+# imported here. Star-importing them made `import pythonx.compose.material3` import
+# `androidx.compose.material3` at load time -- failing wherever that Kotlin package is not bound --
+# and put their dead `Icon` / `ColorScheme` classes in front of the binder's real ones, which the
+# re-export rule below serves. Whether the two files stay is an open question (SPEC, Outside intent 5).
# `Text` is not here. `pythonx.compose.material3.Text` is a walked declaration -- the adaptation
diff --git a/pythonx/compose/pythonx-map.toml b/pythonx/compose/pythonx-map.toml
index b2c5e21..2e0b8bc 100644
--- a/pythonx/compose/pythonx-map.toml
+++ b/pythonx/compose/pythonx-map.toml
@@ -45,3 +45,10 @@
raw-primitive-allowed = [
"androidx.compose.ui.unit.Dp",
]
+
+[aliases."pythonx.compose.material3"]
+# Names a module also answers for, because the notebook imports them from there (INTENT section 5.2):
+# `from pythonx.compose.material3 import Column, Row, Spacer`, while Kotlin declares them in
+# `androidx.compose.foundation.layout`. Each is the same object as in its home module; this is data,
+# not a wrapper, and the rule that serves it is the one in `_reexport.py`.
+"pythonx.compose.layout" = ["Column", "Row", "Spacer"]
diff --git a/tests/fake_host.py b/tests/fake_host.py
index fc8c203..3a0c374 100644
--- a/tests/fake_host.py
+++ b/tests/fake_host.py
@@ -267,6 +267,14 @@ def _build(self):
"STRING", "kotlin.String", False, None, (False,),
lambda args: "url:" + args[0],
)
+ for layout_composable in ("Column", "Row", "Spacer"):
+ # Shape only: the notebook imports these from material3 and Kotlin declares them in
+ # foundation.layout. Nothing here composes; the tests check which object a name reaches.
+ self._add(
+ f"androidx.compose.foundation.layout.{layout_composable}", 1, ("modifier",),
+ ("OBJECT",), (MODIFIER,), "UNIT", "kotlin.Unit", False, None, (True,),
+ lambda args: None,
+ )
# Object constants, the way `ArtifactScanner.constantsOf` binds them: a `STATIC_GETTER` with no
# parameters, named `.