Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
않는다.

### 마일스톤
Expand Down
15 changes: 9 additions & 6 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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) |

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/getting-started.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h2><span data-lang="en">1. Get the source</span><span data-lang="ko">1. 소스
<section>
<h2><span data-lang="en">2. Run the tests</span><span data-lang="ko">2. 테스트 실행</span></h2>
<pre><code class="language-bash">python3 -m pytest tests -q</code></pre>
<p data-lang="en">Expect <strong>51 passed, 53 skipped</strong>. The skipped tests drive the binder's adaptation layer and need a <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> checkout beside this one, or:</p><p data-lang="ko"><strong>51 passed, 53 skipped</strong> 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
<p data-lang="en">Expect <strong>55 passed, 56 skipped</strong>. The skipped tests drive the binder's adaptation layer and need a <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> checkout beside this one, or:</p><p data-lang="ko"><strong>55 passed, 56 skipped</strong> 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
<pre><code class="language-bash">PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q</code></pre>
<p data-lang="en">With a current one (<code>ba4c6f49</code> 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.</p><p data-lang="ko">최신 체크아웃(<code>ba4c6f49</code> 이후)이면 전부 통과하고, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 <strong>건너뜁니다</strong>. 예상된 상태이며 기록되어 있습니다.</p>
</section>
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/status.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<thead><tr><th><span data-lang="en">State</span><span data-lang="ko">상태</span></th><th><span data-lang="en">Item</span><span data-lang="ko">항목</span></th></tr></thead>
<tbody><tr><td><span class="chip ok"><span data-lang="en">implemented</span><span data-lang="ko">구현</span></span></td><td><span data-lang="en">Mapping manifest <code>pythonx-map.toml</code></span><span data-lang="ko">매핑 매니페스트 <code>pythonx-map.toml</code></span></td></tr><tr><td><span class="chip ok"><span data-lang="en">implemented</span><span data-lang="ko">구현</span></span></td><td><span data-lang="en"><code>@Composable</code> identity decorator</span><span data-lang="ko"><code>@Composable</code> 항등 데코레이터</span></td></tr><tr><td><span class="chip partial"><span data-lang="en">partial</span><span data-lang="ko">부분</span></span></td><td><span data-lang="en">Distribution as <code>pythonx-compose</code> — configured; no stubs yet, manifest outside the package directory</span><span data-lang="ko"><code>pythonx-compose</code> 배포 — 설정됨; 스텁 없음, 매니페스트가 패키지 디렉터리 밖</span></td></tr><tr><td><span class="chip ok"><span data-lang="en">implemented</span><span data-lang="ko">구현</span></span></td><td><span data-lang="en"><code>Modifier</code> chains, overload dispatch, <code>Dp</code> as a number</span><span data-lang="ko"><code>Modifier</code> 체인, 오버로드 디스패치, 숫자 <code>Dp</code></span></td></tr><tr><td><span class="chip partial"><span data-lang="en">partial</span><span data-lang="ko">부분</span></span></td><td><span data-lang="en">Snake-case names, keywords and signatures — done for modules and method names; method keywords are still Kotlin's</span><span data-lang="ko">snake_case 이름·키워드·시그니처 — 모듈과 메서드 이름 완료, 메서드 키워드는 아직 Kotlin 이름</span></td></tr><tr><td><span class="chip partial"><span data-lang="en">partial</span><span data-lang="ko">부분</span></span></td><td><span data-lang="en">Material 3 widgets — re-exported by rule; render proofs per widget pending (#9)</span><span data-lang="ko">Material 3 위젯 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9)</span></td></tr><tr><td><span class="chip partial"><span data-lang="en">partial</span><span data-lang="ko">부분</span></span></td><td><span data-lang="en"><code>Icon</code>, colour schemes — importable, not callable</span><span data-lang="ko"><code>Icon</code>, 색 스킴 — import 가능, 호출 불가</span></td></tr><tr><td><span class="chip partial"><span data-lang="en">partial</span><span data-lang="ko">부분</span></span></td><td><span data-lang="en"><code>Alignment</code> / <code>Arrangement</code> constants — reached from <code>pythonx</code>; grouped spelling pending</span><span data-lang="ko"><code>Alignment</code> / <code>Arrangement</code> 상수 — <code>pythonx</code> 에서 읽힘; 묶음 표기 대기</span></td></tr><tr><td><span class="chip ok"><span data-lang="en">implemented</span><span data-lang="ko">구현</span></span></td><td><span data-lang="en"><code>pythonx</code> as a real on-disk package re-exporting <code>androidx.compose.*</code> by one rule</span><span data-lang="ko"><code>androidx.compose.*</code> 를 규칙 하나로 재노출하는 실제 디스크 패키지 <code>pythonx</code></span></td></tr><tr><td><span class="chip planned"><span data-lang="en">planned</span><span data-lang="ko">계획</span></span></td><td><span data-lang="en"><code>.pyi</code> stubs in the wheel</span><span data-lang="ko">wheel 안의 <code>.pyi</code> 스텁</span></td></tr><tr><td><span class="chip planned"><span data-lang="en">planned</span><span data-lang="ko">계획</span></span></td><td><span data-lang="en"><code>remember_saveable</code>, <code>DefaultIcons</code>, coroutine scopes</span><span data-lang="ko"><code>remember_saveable</code>, <code>DefaultIcons</code>, 코루틴 스코프</span></td></tr></tbody>
</table></div>
<p data-lang="en" class="muted">Test baseline: <code>51 passed, 53 skipped</code> without a python-multiplatform checkout; <code>104 passed</code> with a current one.</p><p data-lang="ko" class="muted">테스트 기준선: python-multiplatform 체크아웃이 없으면 <code>51 passed, 53 skipped</code>, 최신 체크아웃이면 <code>104 passed</code>.</p>
<p data-lang="en" class="muted">Test baseline: <code>55 passed, 56 skipped</code> without a python-multiplatform checkout; <code>111 passed</code> with a current one.</p><p data-lang="ko" class="muted">테스트 기준선: python-multiplatform 체크아웃이 없으면 <code>55 passed, 56 skipped</code>, 최신 체크아웃이면 <code>111 passed</code>.</p>
</section>
<section>
<h2>FAQ</h2>
Expand Down
27 changes: 25 additions & 2 deletions pythonx/compose/_reexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -276,6 +280,15 @@ def __repr__(self):
return f"<pythonx view of Kotlin object {self._kotlin.__name__}>"


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`.

Expand All @@ -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(
Expand All @@ -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):
Expand Down
8 changes: 5 additions & 3 deletions pythonx/compose/material3/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions pythonx/compose/pythonx-map.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
8 changes: 8 additions & 0 deletions tests/fake_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package>.<Object>.<Constant>`, returning the constant's declared type.
for constant, declared in (
Expand Down
24 changes: 24 additions & 0 deletions tests/test_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
26 changes: 26 additions & 0 deletions tests/test_material3_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_pythonx_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down