From d3c7da212aa8091191cd7ecc04eb2031239d2eb9 Mon Sep 17 00:00:00 2001
From: BREW <81044362+b-re-w@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:17:06 +0900
Subject: [PATCH 1/2] Feat: Convert the binder's Kotlin-named stubs to pythonx
stubs by the runtime's rule
python-multiplatform's plugin now emits stubs under Kotlin names only, and its
Pythonic generator is gone (d00f413f). The Pythonic half is this package's.
tools/gen_stubs.py reads a directory of Kotlin-named stubs and writes
pythonx/compose/**/__init__.pyi for every manifest module:
- functions and constants are renamed with _reexport.python_name and
parameters with snake_case -- the functions the runtime resolves names with,
so an editor and the interpreter cannot disagree; receiver and anonymous
__aN slots keep their names;
- each explicit overload also becomes an @overload of its base name, which is
what the runtime dispatcher answers to;
- nested object stubs (Arrangement/) are converted one level down;
- a package's own definitions (runtime.Composable) are carried over, since a
.pyi replaces what a checker knows about the module.
No stub is generated or shipped yet: that needs the Kotlin-named stubs for
Compose 1.11.1 from python-multiplatform, and object types better than int,
both asked of the ecosystem lead. #12 stays open.
feature added: tools/gen_stubs.py.
test added: tests/test_stubs.py (8) over a fixture holding the fake host's
declarations, including a check that each stub signature equals the runtime
inspect.signature. The tests were written alongside the tool, not before it;
as the failing-path evidence, disabling the parameter rename fails 2, the
overloads 1, the name rule 3.
documentation corrected: SPEC S1.2 and baseline, PROJECT.md, guide baseline.
Suite with ../PythonMultiplatform: 84 passed, 4 skipped -> 92 passed, 4
skipped. Without a checkout: 43 passed, 45 skipped -> 50 passed, 46 skipped.
check_guide.py: 0.
Refs #12
---
PROJECT.md | 6 +-
docs/SPEC.md | 27 ++--
docs/guide/getting-started.html | 2 +-
docs/guide/status.html | 2 +-
.../layout/Arrangement/__init__.pyi | 7 +
.../compose/foundation/layout/__init__.pyi | 33 ++++
.../androidx/compose/ui/__init__.pyi | 9 ++
tests/test_stubs.py | 118 ++++++++++++++
tools/gen_stubs.py | 146 ++++++++++++++++++
9 files changed, 336 insertions(+), 14 deletions(-)
create mode 100644 tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/Arrangement/__init__.pyi
create mode 100644 tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/__init__.pyi
create mode 100644 tests/fixtures/kotlin_stubs/androidx/compose/ui/__init__.pyi
create mode 100644 tests/test_stubs.py
create mode 100644 tools/gen_stubs.py
diff --git a/PROJECT.md b/PROJECT.md
index 9bb02ce..6c7f778 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -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 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지
않는다.
### 마일스톤
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 0ff1683..9f22d2b 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -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**, 72 subtests passed (66 without a checkout) |
+| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **92 passed, 4 skipped**, 72 subtests passed (66 without a checkout) |
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 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.
---
@@ -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`
diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html
index 958e5bc..9d75ff9 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 43 passed, 45 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
43 passed, 45 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
+Expect 50 passed, 46 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
50 passed, 46 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q
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 0b85cbe..6547cf7 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.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: 43 passed, 45 skipped without a python-multiplatform checkout; 84 passed, 4 skipped with one.
테스트 기준선: python-multiplatform 체크아웃이 없으면 43 passed, 45 skipped, 있으면 84 passed, 4 skipped.
+Test baseline: 50 passed, 46 skipped without a python-multiplatform checkout; 92 passed, 4 skipped with one.
테스트 기준선: python-multiplatform 체크아웃이 없으면 50 passed, 46 skipped, 있으면 92 passed, 4 skipped.
FAQ
diff --git a/tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/Arrangement/__init__.pyi b/tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/Arrangement/__init__.pyi
new file mode 100644
index 0000000..9617e9c
--- /dev/null
+++ b/tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/Arrangement/__init__.pyi
@@ -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"""
diff --git a/tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/__init__.pyi b/tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/__init__.pyi
new file mode 100644
index 0000000..ce77526
--- /dev/null
+++ b/tests/fixtures/kotlin_stubs/androidx/compose/foundation/layout/__init__.pyi
@@ -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."""
+ ...
diff --git a/tests/fixtures/kotlin_stubs/androidx/compose/ui/__init__.pyi b/tests/fixtures/kotlin_stubs/androidx/compose/ui/__init__.pyi
new file mode 100644
index 0000000..26b27e5
--- /dev/null
+++ b/tests/fixtures/kotlin_stubs/androidx/compose/ui/__init__.pyi
@@ -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"""
+ ...
diff --git a/tests/test_stubs.py b/tests/test_stubs.py
new file mode 100644
index 0000000..48686be
--- /dev/null
+++ b/tests/test_stubs.py
@@ -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()
diff --git a/tools/gen_stubs.py b/tools/gen_stubs.py
new file mode 100644
index 0000000..bb62c72
--- /dev/null
+++ b/tools/gen_stubs.py
@@ -0,0 +1,146 @@
+"""Generate `pythonx.compose` stubs from the binder's Kotlin-named stubs, by the runtime's own rule.
+
+ python3 tools/gen_stubs.py [--out pythonx/compose/..]
+
+`` is what python-multiplatform's `PythonStubsTask` writes: one
+`androidx/compose/.../__init__.pyi` per Kotlin package, Kotlin names unchanged. For each module in
+`pythonx-map.toml`, this reads the stub of the Kotlin package it maps to and writes the Pythonic one
+next to the module's `__init__.py`:
+
+- a function or constant is renamed with `pythonx.compose._reexport.python_name`, the function the
+ runtime resolves names with, so an editor completes what the interpreter answers;
+- a parameter is renamed with `snake_case`, except the positional-only `receiver` and anonymous
+ `__aN` slots, which a caller never writes by name;
+- every explicit overload (`padding__Dp`, `padding__Dp_Dp`) also contributes an `@overload` of its
+ base name (`padding`), which is the name the runtime's dispatcher answers to;
+- a nested object stub (`Arrangement/__init__.pyi`) is converted the same way, one level down;
+- names the module's own `__init__.py` defines (`runtime.Composable`) are carried over, because a
+ `.pyi` beside a module replaces everything a type checker knows about it.
+
+Nothing here names a Kotlin declaration; it is the same rule `_reexport.py` applies at run time.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+if str(REPO) not in sys.path:
+ sys.path.insert(0, str(REPO))
+
+from pythonx.compose._reexport import manifest, python_name, snake_case # noqa: E402
+
+HEADER = "# GENERATED by tools/gen_stubs.py from python-multiplatform's Kotlin-named stubs. Do not edit.\n"
+STUB_NAME = "__init__.pyi"
+
+
+def _keeps_its_name(name: str) -> bool:
+ return name == "receiver" or name.startswith("__a")
+
+
+def _rename_arguments(arguments: ast.arguments) -> ast.arguments:
+ for group in (arguments.posonlyargs, arguments.args, arguments.kwonlyargs):
+ for argument in group:
+ if not _keeps_its_name(argument.arg):
+ argument.arg = snake_case(argument.arg)
+ return arguments
+
+
+def _overload(function: ast.FunctionDef, name: str) -> ast.FunctionDef:
+ copy = ast.parse(ast.unparse(function)).body[0]
+ copy.name = name
+ copy.decorator_list = [ast.Name(id="overload", ctx=ast.Load())] + copy.decorator_list
+ return copy
+
+
+def convert(source: str) -> str:
+ """One Kotlin-named stub module, rewritten to the Pythonic surface."""
+ tree = ast.parse(source)
+ body: list[ast.stmt] = []
+ overloads: dict[str, list[ast.FunctionDef]] = defaultdict(list)
+ for node in tree.body:
+ if isinstance(node, ast.FunctionDef):
+ node.name = python_name(node.name)
+ _rename_arguments(node.args)
+ base, sep, _ = node.name.partition("__")
+ if sep:
+ overloads[base].append(node)
+ body.append(node)
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
+ node.target.id = python_name(node.target.id)
+ body.append(node)
+ elif isinstance(node, ast.ClassDef):
+ for member in node.body:
+ if isinstance(member, ast.FunctionDef) and not member.name.startswith("__"):
+ member.name = python_name(member.name)
+ _rename_arguments(member.args)
+ body.append(node)
+ else:
+ body.append(node)
+ defined = {node.name for node in body if isinstance(node, ast.FunctionDef)}
+ for base, variants in overloads.items():
+ if base in defined:
+ continue
+ body.extend(_overload(variant, base) for variant in variants)
+ lines = [HEADER]
+ if overloads:
+ lines.append("from typing import overload\n")
+ lines.extend(ast.unparse(node) + "\n" for node in body)
+ return "\n".join(lines)
+
+
+def own_definitions(init_py: Path) -> list[str]:
+ """Stub text for the public functions and classes a package's own `__init__.py` defines."""
+ if not init_py.is_file():
+ return []
+ stubs = []
+ for node in ast.parse(init_py.read_text(encoding="utf-8")).body:
+ if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
+ node.body = [ast.Expr(ast.Constant(...))]
+ node.decorator_list = []
+ stubs.append(ast.unparse(node) + "\n")
+ elif isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
+ stubs.append(f"class {node.name}: ...\n")
+ return stubs
+
+
+def generate(kotlin_root: Path, package_root: Path) -> dict[Path, str]:
+ """Every stub this manifest yields: output path -> text. Writes nothing."""
+ out: dict[Path, str] = {}
+ for module_name, kotlin_package in manifest()["modules"].items():
+ module_dir = package_root.joinpath(*module_name.split(".")[1:])
+ kotlin_dir = kotlin_root.joinpath(*kotlin_package.split("."))
+ own = own_definitions(module_dir / "__init__.py")
+ source = kotlin_dir / STUB_NAME
+ text = convert(source.read_text(encoding="utf-8")) if source.is_file() else HEADER
+ if own:
+ text += "\n" + "\n".join(own)
+ out[module_dir / STUB_NAME] = text
+ if kotlin_dir.is_dir():
+ for nested in sorted(kotlin_dir.iterdir()):
+ nested_stub = nested / STUB_NAME
+ if nested.is_dir() and nested.name[:1].isupper() and nested_stub.is_file():
+ out[module_dir / nested.name / STUB_NAME] = convert(nested_stub.read_text(encoding="utf-8"))
+ return out
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("kotlin_root", type=Path)
+ parser.add_argument("--out", type=Path, default=REPO / "pythonx")
+ args = parser.parse_args(argv)
+ if not args.kotlin_root.is_dir():
+ parser.error(f"{args.kotlin_root} is not a directory of Kotlin-named stubs")
+ for path, text in generate(args.kotlin_root, args.out).items():
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ print(path)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 20f791b8730734925b7ab6845c6cead04ccac9bb Mon Sep 17 00:00:00 2001
From: BREW <81044362+b-re-w@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:17:17 +0900
Subject: [PATCH 2/2] Docs: Give each baseline row its own subtest count
The previous commit wrote the same subtest figure into both rows of SPEC 0.
Without a checkout the suite runs 66 subtests, with one 72; and 46 tests,
not 45, skip without a checkout now that the stub test installs the binder.
documentation corrected: SPEC section 0.
Refs #12
---
docs/SPEC.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 9f22d2b..3fd8c73 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -20,10 +20,10 @@ Run from a worktree with `python3 -m pytest tests -q` (pytest 8, CPython 3.13):
| Environment | Result |
|---|---|
-| No `PythonMultiplatform` checkout found | **50 passed, 46 skipped**, 72 subtests passed (66 without a checkout) |
-| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **92 passed, 4 skipped**, 72 subtests passed (66 without a checkout) |
+| 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.