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,13 +35,13 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`

```
python3 -m pytest tests -q
PythonMultiplatform 체크아웃 없음 43 passed, 45 skipped
PYTHONMULTIPLATFORM_HOME 지정 84 passed, 4 skipped (4 개는 python-multiplatform #17 대기)
PythonMultiplatform 체크아웃 없음 50 passed, 46 skipped
PYTHONMULTIPLATFORM_HOME 지정 92 passed, 4 skipped (4 개는 python-multiplatform #17 대기)
```

건너뛰는 4 개는 바인더가 돌려준 proxy 위의 camelCase 확장을 snake_case 이름으로 부르는 테스트다.
바인더의 member resolver(python-multiplatform #17)가 들어오면 실행된다. 건너뜀은 통과가 아니다.
체크아웃 없이 통과하는 43 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
체크아웃 없이 통과하는 50 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
않는다.

### 마일스톤
Expand Down
29 changes: 19 additions & 10 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ Run from a worktree with `python3 -m pytest tests -q` (pytest 8, CPython 3.13):

| Environment | Result |
|---|---|
| No `PythonMultiplatform` checkout found | **43 passed, 45 skipped**, 44 subtests passed |
| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **84 passed, 4 skipped**, 44 subtests passed |
| No `PythonMultiplatform` checkout found | **50 passed, 46 skipped**, 66 subtests passed |
| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **92 passed, 4 skipped**, 72 subtests passed |

Without a checkout, the 45 tests that install the binder's layers through `tests/adapter.py` skip.
Without a checkout, the 46 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 43 that pass without a checkout, most assert **absence** (a retired token, a deleted file, a docstring that
Of the 50 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 All @@ -45,12 +45,21 @@ exists). Those are listed in §9 and are not counted as features.
module (issue #13). The manifest lives inside the package directory so `package-data` carries it.
- Not yet true: no `.pyi` and no `py.typed` exist (S1.2, issue #12).

### S1.2 Type stubs ship in the wheel — `planned`

Stubs carry the Pythonic names and signatures the runtime resolves, generated from the same
manifest (S2) so the name an editor completes and the name the interpreter resolves cannot drift.
The generator lives in `python-multiplatform`'s Gradle plugin; nothing in this repository produces
or contains a stub yet.
### S1.2 Type stubs ship in the wheel — `partial`

Stubs carry the Pythonic names and signatures the runtime resolves, so the name an editor completes
and the name the interpreter resolves cannot drift.

- In this repository: `tools/gen_stubs.py` converts python-multiplatform's Kotlin-named stubs
(`PythonStubsTask` output, one `androidx/.../__init__.pyi` per package) into
`pythonx/compose/**/__init__.pyi`, using the runtime's own `python_name` / `snake_case`; an
overload set also gets `@overload`s of its base name; a package's own definitions
(`runtime.Composable`) are carried over. `tests/test_stubs.py` converts a fixture holding the fake
host's declarations and checks each stub signature against `inspect.signature` at run time.
Disabling the parameter rename fails 2 tests, the overloads 1, the name rule 3.
- Not yet true: no stub is generated from real Compose and none ships, and there is no `py.typed`.
That needs the Kotlin-named stubs for Compose 1.11.1 from python-multiplatform, and object types
better than `int` for completion to be useful (issue #12).

## 2. The mapping manifest (`pythonx-map.toml`) — `implemented`

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>43 passed, 45 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>43 passed, 45 skipped</strong> 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
<p data-lang="en">Expect <strong>50 passed, 46 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>50 passed, 46 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 it, all but 4 pass; those 4 <strong>skip</strong> until the binder can resolve a snake_case method on a proxy it returned (python-multiplatform #17). That is expected and recorded.</p><p data-lang="ko">지정하면 4 개를 뺀 전부가 통과합니다. 그 4 개는 바인더가 돌려준 proxy 의 snake_case 메서드를 바인더가 풀 수 있을 때까지(python-multiplatform #17) <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 — module level done; methods on a returned proxy wait for python-multiplatform #17</span><span data-lang="ko">snake_case 이름·키워드·시그니처 — 모듈 수준 완료, 반환된 proxy 의 메서드는 python-multiplatform #17 대기</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 — read without parentheses; grouped spelling pending (#9)</span><span data-lang="ko"><code>Alignment</code> / <code>Arrangement</code> 상수 — 괄호 없이 읽음; 묶음 표기 대기(#9)</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>43 passed, 45 skipped</code> without a python-multiplatform checkout; <code>84 passed, 4 skipped</code> with one.</p><p data-lang="ko" class="muted">테스트 기준선: python-multiplatform 체크아웃이 없으면 <code>43 passed, 45 skipped</code>, 있으면 <code>84 passed, 4 skipped</code>.</p>
<p data-lang="en" class="muted">Test baseline: <code>50 passed, 46 skipped</code> without a python-multiplatform checkout; <code>92 passed, 4 skipped</code> with one.</p><p data-lang="ko" class="muted">테스트 기준선: python-multiplatform 체크아웃이 없으면 <code>50 passed, 46 skipped</code>, 있으면 <code>92 passed, 4 skipped</code>.</p>
</section>
<section>
<h2>FAQ</h2>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# GENERATED by python-multiplatform-gradle-plugin. Do not edit.

End: int
"""Kotlin: androidx.compose.foundation.layout.Arrangement.End(): androidx.compose.foundation.layout.Arrangement.Horizontal"""

SpaceBetween: int
"""Kotlin: androidx.compose.foundation.layout.Arrangement.SpaceBetween(): androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# GENERATED by python-multiplatform-gradle-plugin. Do not edit.

def padding__Dp(receiver: int, /, all: float) -> int:
"""Kotlin: androidx.compose.foundation.layout.padding(all: androidx.compose.ui.unit.Dp): androidx.compose.ui.Modifier"""
...

def padding__Dp_Dp(receiver: int, /, horizontal: float = ..., vertical: float = ...) -> int:
"""Kotlin: androidx.compose.foundation.layout.padding(horizontal: androidx.compose.ui.unit.Dp, vertical: androidx.compose.ui.unit.Dp): androidx.compose.ui.Modifier"""
...

def padding__Dp_Dp_Dp_Dp(receiver: int, /, start: float = ..., top: float = ..., end: float = ..., bottom: float = ...) -> int:
"""Kotlin: androidx.compose.foundation.layout.padding(start, top, end, bottom): androidx.compose.ui.Modifier"""
...

def padding__PaddingValues(receiver: int, /, paddingValues: int) -> int:
"""Kotlin: androidx.compose.foundation.layout.padding(paddingValues: androidx.compose.foundation.layout.PaddingValues): androidx.compose.ui.Modifier"""
...

def paddingValuesOf(all: float) -> int:
"""Kotlin: androidx.compose.foundation.layout.paddingValuesOf(all: androidx.compose.ui.unit.Dp): androidx.compose.foundation.layout.PaddingValues"""
...

def size__Dp(receiver: int, /, size: float) -> int:
"""Kotlin: androidx.compose.foundation.layout.size(size: androidx.compose.ui.unit.Dp): androidx.compose.ui.Modifier"""
...

def fillMaxWidth(receiver: int, /) -> int:
"""Kotlin: androidx.compose.foundation.layout.fillMaxWidth(): androidx.compose.ui.Modifier"""
...

def scan(receiver: int, first: float, __a1: float, /, last: float = ...) -> int:
"""A parameter named like a Python keyword: positional-only and anonymous, never renamed."""
...
9 changes: 9 additions & 0 deletions tests/fixtures/kotlin_stubs/androidx/compose/ui/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# GENERATED by python-multiplatform-gradle-plugin. Do not edit.

def describeModifier(modifier: int) -> str:
"""Kotlin: androidx.compose.ui.describeModifier(modifier: androidx.compose.ui.Modifier): kotlin.String"""
...

def toURLString(raw: str) -> str:
"""Kotlin: androidx.compose.ui.toURLString(raw: kotlin.String): kotlin.String"""
...
118 changes: 118 additions & 0 deletions tests/test_stubs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Pythonic stubs from the binder's Kotlin-named ones, by the rule the runtime resolves names with.

The input here is a fixture shaped like python-multiplatform's `PythonStubsTask` output, holding the
same declarations `fake_host.py` binds. So the second class can do what a stub is for and check it:
the signature an editor reads from the stub is the signature `inspect.signature` reports at run time.
"""

from __future__ import annotations

import ast
import inspect
import sys
import unittest
from pathlib import Path

HERE = Path(__file__).resolve().parent
REPO = HERE.parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(REPO / "tools"))
sys.path.insert(0, str(REPO))

import adapter as adapter_loader # noqa: E402
import fake_host # noqa: E402
import gen_stubs # noqa: E402

KOTLIN_STUBS = HERE / "fixtures" / "kotlin_stubs"
PACKAGE = REPO / "pythonx"


def _generated() -> dict[Path, str]:
return gen_stubs.generate(KOTLIN_STUBS, PACKAGE)


def _functions(text: str) -> dict[str, list[ast.FunctionDef]]:
found: dict[str, list[ast.FunctionDef]] = {}
for node in ast.parse(text).body:
if isinstance(node, ast.FunctionDef):
found.setdefault(node.name, []).append(node)
return found


def _parameter_names(function: ast.FunctionDef) -> list[str]:
a = function.args
return [p.arg for p in (*a.posonlyargs, *a.args, *a.kwonlyargs)]


LAYOUT = PACKAGE / "compose" / "foundation" / "layout" / "__init__.pyi"


class TheConversion(unittest.TestCase):

def setUp(self):
self.stubs = _generated()

def test_every_manifest_module_gets_a_stub_beside_its_init(self):
for module_name in gen_stubs.manifest()["modules"]:
path = PACKAGE.joinpath(*module_name.split(".")[1:]) / "__init__.pyi"
with self.subTest(module=module_name):
self.assertIn(path, self.stubs)

def test_names_and_parameters_follow_the_runtime_rule(self):
functions = _functions(self.stubs[LAYOUT])
self.assertIn("padding_values_of", functions)
self.assertIn("fill_max_width", functions)
self.assertNotIn("paddingValuesOf", functions)
self.assertEqual(["receiver", "padding_values"], _parameter_names(functions["padding__PaddingValues"][0]))

def test_an_overload_set_gets_its_base_name_as_overloads(self):
padding = _functions(self.stubs[LAYOUT])["padding"]
self.assertEqual(4, len(padding))
for variant in padding:
self.assertEqual(["overload"], [ast.unparse(d) for d in variant.decorator_list])

def test_anonymous_and_receiver_slots_keep_their_names(self):
scan = _functions(self.stubs[LAYOUT])["scan"][0]
self.assertEqual(["receiver", "first", "__a1", "last"], _parameter_names(scan))

def test_a_nested_object_stub_is_converted_one_level_down(self):
nested = PACKAGE / "compose" / "foundation" / "layout" / "Arrangement" / "__init__.pyi"
self.assertIn("SpaceBetween: int", self.stubs[nested])

def test_names_the_package_defines_itself_are_carried_over(self):
runtime = self.stubs[PACKAGE / "compose" / "runtime" / "__init__.pyi"]
self.assertIn("def Composable(target)", runtime)

def test_every_stub_is_valid_python(self):
for path, text in self.stubs.items():
with self.subTest(stub=str(path.relative_to(REPO))):
compile(text, str(path), "exec")


class TheStubMatchesTheRuntime(unittest.TestCase):
"""What an editor reads from the stub is what `inspect.signature` reports at run time."""

def setUp(self):
try:
self.binding = adapter_loader.install()
except adapter_loader.AdapterUnavailable as unavailable:
self.skipTest(str(unavailable))
self.host = fake_host.FakeHost()
self.host.bind()
self.host.register(self.binding)
self.addCleanup(self.host.unbind)
self.addCleanup(adapter_loader.uninstall)

def test_each_single_declaration_has_the_same_parameter_names(self):
import pythonx.compose.foundation.layout as layout

functions = _functions(_generated()[LAYOUT])
for name in ("padding__Dp", "padding__Dp_Dp", "padding__PaddingValues", "padding_values_of",
"size__Dp", "fill_max_width"):
with self.subTest(name=name):
runtime = list(inspect.signature(getattr(layout, name)).parameters)
self.assertEqual(runtime, _parameter_names(functions[name][0]))


if __name__ == "__main__":
unittest.main()
Loading