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 체크아웃이 있거나 다음이 필요합니다:

PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q

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 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.

diff --git a/docs/guide/status.html b/docs/guide/status.html index 55e8f92..4e416d5 100644 --- a/docs/guide/status.html +++ b/docs/guide/status.html @@ -36,7 +36,7 @@ State상태Item항목 implemented구현Mapping manifest pythonx-map.toml매핑 매니페스트 pythonx-map.tomlimplemented구현@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 체인, 오버로드 디스패치, 숫자 Dppartial부분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.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonxplanned계획.pyi stubs in the wheelwheel 안의 .pyi 스텁planned계획remember_saveable, DefaultIcons, coroutine scopesremember_saveable, DefaultIcons, 코루틴 스코프 -

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 `..`, returning the constant's declared type. for constant, declared in ( diff --git a/tests/test_chain.py b/tests/test_chain.py index a1b66a7..9ae5cfc 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -390,6 +390,30 @@ def test_an_unknown_constant_says_where_it_looked(self): self.assertIn("androidx.compose.ui.Alignment", str(raised.exception)) +class ManifestAliases(AdapterCase): + """INTENT 5.2: `Column`, `Row`, `Spacer` from `material3`, as the notebook imports them.""" + + def test_the_notebook_import_resolves_to_the_layout_object(self): + from pythonx.compose.layout import Column as from_layout + from pythonx.compose.material3 import Column, Row, Spacer + + self.assertIs(from_layout, Column) + self.assertIsNotNone(Row) + self.assertIsNotNone(Spacer) + + def test_dir_of_material3_lists_the_aliases(self): + import pythonx.compose.material3 as material3 + + for name in ("Column", "Row", "Spacer"): + self.assertIn(name, dir(material3)) + + def test_a_name_not_listed_is_not_borrowed(self): + import pythonx.compose.material3 as material3 + + with self.assertRaises(AttributeError): + material3.padding_values_of # noqa: B018 + + class Handles(AdapterCase): """The binder's proxy owns a handle; dropping it gives the handle back.""" diff --git a/tests/test_material3_module.py b/tests/test_material3_module.py index 27c4239..8543470 100644 --- a/tests/test_material3_module.py +++ b/tests/test_material3_module.py @@ -196,6 +196,32 @@ def test_material3_package_does_not_import_text_field(self): self.assertNotIn("from .text_field import", source) +class ThePackageDoesNotImportTheRecords(unittest.TestCase): + """`icon.py` / `color_scheme.py` are not imported by the package, so they shadow nothing. + + Star-importing them made `import pythonx.compose.material3` reach for `androidx.compose.material3` + at load time and put a dead `Icon` class in front of the one the re-export rule serves. + """ + + def test_the_package_init_does_not_import_them(self): + source = MATERIAL3_INIT.read_text(encoding="utf-8") + self.assertNotIn("from .icon import", source) + self.assertNotIn("from .color_scheme import", source) + + def test_the_package_imports_with_no_binder_installed(self): + import importlib + import sys + + for name in [n for n in sys.modules if n == "pythonx.compose.material3" or n.startswith("androidx")]: + del sys.modules[name] + sys.path.insert(0, str(REPO)) + try: + module = importlib.import_module("pythonx.compose.material3") + finally: + sys.path.remove(str(REPO)) + self.assertNotIn("Icon", vars(module)) + + class TheTwoUnreachableWrappersLoadWithoutCrashing(unittest.TestCase): """`icon.py` (`Icon`) and `color_scheme.py` (`ColorScheme`/`lightColorScheme`/`darkColorScheme`) are the two declarations `PythonMultiplatform` commit `a6742a1c` pinned as *unreachable* through diff --git a/tests/test_pythonx_map.py b/tests/test_pythonx_map.py index 0f4b5e7..45fd63a 100644 --- a/tests/test_pythonx_map.py +++ b/tests/test_pythonx_map.py @@ -76,6 +76,21 @@ def test_the_short_layout_spelling_drops_foundation(self) -> None: ) +class TestAliasSection(unittest.TestCase): + def test_every_alias_points_between_mapped_modules(self) -> None: + manifest = _manifest() + modules = manifest["modules"] + for owner, sources in manifest.get("aliases", {}).items(): + with self.subTest(owner=owner): + self.assertIn(owner, modules) + for source in sources: + self.assertIn(source, modules) + + def test_material3_answers_for_the_layout_composables_the_notebook_imports(self) -> None: + aliases = _manifest()["aliases"]["pythonx.compose.material3"]["pythonx.compose.layout"] + self.assertEqual(["Column", "Row", "Spacer"], aliases) + + class TestValueClassSection(unittest.TestCase): def test_dp_is_allowed_and_packed_classes_are_not(self) -> None: allowed = _manifest()["value-classes"]["raw-primitive-allowed"]