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.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 — 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.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonxplanned계획.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.