diff --git a/AGENTS.md b/AGENTS.md index 7e5336d..24e14c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,8 +152,8 @@ worktree so a test can see it. Read it in place. - `docs/INTENT.md` and `docs/SPEC.md` are derived from it. When they disagree with the notebook or with the user, the notebook and the user win. - `tests/test_pythonx_map.py` reads the notebook's imports. Where the notebook is absent (any - worktree, any CI checkout) that test checks nothing and still passes. Run it in the main checkout - before claiming the manifest covers the notebook. + worktree, any CI checkout) that test **skips**, saying so. Run it in the main checkout before + claiming the manifest covers the notebook. ## 12. Renaming happens here, in real Python, never in the binder diff --git a/PROJECT.md b/PROJECT.md index b91b45c..9bb02ce 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -35,13 +35,13 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **` ``` python3 -m pytest tests -q - PythonMultiplatform 체크아웃 없음 40 passed, 45 skipped - PYTHONMULTIPLATFORM_HOME 지정 81 passed, 4 skipped (4 개는 python-multiplatform #17 대기) + PythonMultiplatform 체크아웃 없음 43 passed, 45 skipped + PYTHONMULTIPLATFORM_HOME 지정 84 passed, 4 skipped (4 개는 python-multiplatform #17 대기) ``` 건너뛰는 4 개는 바인더가 돌려준 proxy 위의 camelCase 확장을 snake_case 이름으로 부르는 테스트다. 바인더의 member resolver(python-multiplatform #17)가 들어오면 실행된다. 건너뜀은 통과가 아니다. -체크아웃 없이 통과하는 40 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지 +체크아웃 없이 통과하는 43 개 중 다수는 *부재*(옛 토큰·삭제된 파일이 없음)를 확인하는 것이라 기능 진척으로 세지 않는다. ### 마일스톤 @@ -65,12 +65,13 @@ GitHub 마일스톤과 같은 내용이다. 날짜는 2026-10-03 에 정했고, ``` pythonx/compose/ import 패키지 (현재 대부분 독스트링만 있는 모듈) runtime/ @Composable + pythonx-map.toml pythonx ↔ androidx 매핑 매니페스트 (wheel 에 함께 실림) + _reexport.py 매니페스트 모듈 전체에 적용되는 재노출 규칙 하나 ui/ modifier.py (빈 Modifier 등록 지점), alignment.py layout/ arrangement.py material3/ icon.py, color_scheme.py (호출 불가 기록용), 빈 파일 28 개 lite/ 2024 JPype 프로토타입 (은퇴, 바이너리 97 개 추적 중) native/ 서브모듈 → thisisthepy/swing-graalvm-demo -pythonx-map.toml pythonx ↔ androidx 매핑 매니페스트 tests/ pytest (unittest 스타일) test/ 2023–2024 Kotlin Multiplatform 샘플 (pycomposeui) — 처리 미정 docs/INTENT.md docs/SPEC.md docs/locale/ docs/guide/ diff --git a/README.md b/README.md index 1fc5d90..b6fb9a5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ def Greeting(): - **`@Composable` stays.** Screens are decorated Python functions. - **A real package.** `pythonx/` is ordinary Python source that imports `androidx.compose.*` and reshapes it. The binder never renames anything — renaming is this package's job. -- **One manifest.** [`pythonx-map.toml`](pythonx-map.toml) says which `pythonx.compose.*` module +- **One manifest.** [`pythonx-map.toml`](pythonx/compose/pythonx-map.toml) says which `pythonx.compose.*` module stands for which Kotlin package. The runtime and the `.pyi` generator read the same file, so what your editor completes is what the interpreter resolves. @@ -95,7 +95,7 @@ assert Screen() == "drawn" and Screen.__name__ == "Screen" ```python import tomllib # the manifest: the one place the mapping is written down -with open("pythonx-map.toml", "rb") as f: +with open("pythonx/compose/pythonx-map.toml", "rb") as f: manifest = tomllib.load(f) manifest["modules"]["pythonx.compose.layout"] diff --git a/docs/SPEC.md b/docs/SPEC.md index 7ad43c7..0ff1683 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 | **40 passed, 45 skipped**, 44 subtests passed | -| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **81 passed, 4 skipped**, 44 subtests passed | +| No `PythonMultiplatform` checkout found | **43 passed, 45 skipped**, 44 subtests passed | +| `PYTHONMULTIPLATFORM_HOME` → the `PythonMultiplatform` checkout | **84 passed, 4 skipped**, 44 subtests passed | 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 40 that pass without a checkout, most assert **absence** (a retired token, a deleted file, a docstring that +Of the 43 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. --- @@ -36,14 +36,14 @@ exists). Those are listed in §9 and are not counted as features. ### S1.1 Installed as `pythonx-compose`, importing `pythonx.compose` — `partial` -`pyproject.toml` declares `name = "pythonx-compose"`, version `0.0.1`, setuptools build, and +`pyproject.toml` declares `name = "pythonx-compose"`, version `0.0.1`, setuptools build, +`requires-python >= 3.11` (the runtime reads the manifest with `tomllib`), and `packages.find include = ["pythonx.compose*"]`. -- Configured: `package-data` carries `*.pyi`, `py.typed` and `pythonx-map.toml` for every package - (`tests/test_pythonx_map.py::TestManifestShipsWithThePackage::test_the_wheel_is_configured_to_carry_it`). -- Not yet true: no `.pyi` and no `py.typed` exist in the tree, and `pythonx-map.toml` sits at the - repository root — outside every package directory — so the `package-data` pattern cannot pick it - up. No test builds a wheel and looks inside it. +- Shipped and tested: `tests/test_wheel.py` builds the wheel from a copy of the sources and finds + `pythonx/compose/pythonx-map.toml`, the re-export rule, and an `__init__.py` for every mapped + 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` diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index 0b7e47f..958e5bc 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 40 passed, 45 skipped. The skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:

40 passed, 45 skipped 가 정상입니다. 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:

+

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

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

@@ -61,7 +61,7 @@

3. What you can call today3 assert Screen() == "drawn" and Screen.__name__ == "Screen"
import tomllib
 
-with open("pythonx-map.toml", "rb") as f:
+with open("pythonx/compose/pythonx-map.toml", "rb") as f:
     manifest = tomllib.load(f)
 
 manifest["modules"]["pythonx.compose.layout"]
diff --git a/docs/guide/status.html b/docs/guide/status.html
index dc9005a..0b85cbe 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: 40 passed, 45 skipped without a python-multiplatform checkout; 81 passed, 4 skipped with one.

테스트 기준선: python-multiplatform 체크아웃이 없으면 40 passed, 45 skipped, 있으면 81 passed, 4 skipped.

+

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.

FAQ

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index a1195f4..8a5ecfc 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -44,7 +44,7 @@ def Greeting(): - **`@Composable` 은 그대로.** 화면은 데코레이터가 붙은 파이썬 함수입니다. - **실제 패키지.** `pythonx/` 는 `androidx.compose.*` 를 import 해 재구성하는 평범한 파이썬 소스입니다. 바인더는 아무 이름도 바꾸지 않습니다 — 이름을 바꾸는 것은 이 패키지의 일입니다. -- **하나의 매니페스트.** [`pythonx-map.toml`](../../pythonx-map.toml) 이 어떤 `pythonx.compose.*` +- **하나의 매니페스트.** [`pythonx-map.toml`](../../pythonx/compose/pythonx-map.toml) 이 어떤 `pythonx.compose.*` 모듈이 어떤 Kotlin 패키지에 대응하는지 적습니다. 런타임과 `.pyi` 생성기가 같은 파일을 읽으므로, 편집기가 자동완성하는 이름과 인터프리터가 해석하는 이름이 어긋나지 않습니다. @@ -94,7 +94,7 @@ assert Screen() == "drawn" and Screen.__name__ == "Screen" ```python import tomllib # 매니페스트: 대응 관계가 적힌 유일한 곳 -with open("pythonx-map.toml", "rb") as f: +with open("pythonx/compose/pythonx-map.toml", "rb") as f: manifest = tomllib.load(f) manifest["modules"]["pythonx.compose.layout"] diff --git a/pyproject.toml b/pyproject.toml index 1245b60..09857aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ #dependencies-kotlin = [ # "org.jetbrains.compose:compose-ui:0.4.0", #] -requires-python = ">= 3.8" +requires-python = ">= 3.11" # tomllib, which reads the manifest at run time #requires-kotlin = ">= 1.4.32" [tool.setuptools.packages.find] diff --git a/pythonx/compose/_reexport.py b/pythonx/compose/_reexport.py index 08b01de..6a545f3 100644 --- a/pythonx/compose/_reexport.py +++ b/pythonx/compose/_reexport.py @@ -42,7 +42,7 @@ ROOT_MODULE = "python_multiplatform" BINDING_MODULE = "python_multiplatform.binding" -_MANIFEST = Path(__file__).resolve().parents[2] / "pythonx-map.toml" +_MANIFEST = Path(__file__).resolve().parent / "pythonx-map.toml" _LOWER_UPPER = re.compile(r"([a-z0-9])([A-Z])") _ACRONYM_WORD = re.compile(r"([A-Z]+)([A-Z][a-z])") diff --git a/pythonx-map.toml b/pythonx/compose/pythonx-map.toml similarity index 100% rename from pythonx-map.toml rename to pythonx/compose/pythonx-map.toml diff --git a/tests/adapter.py b/tests/adapter.py index 91d4503..fbc130a 100644 --- a/tests/adapter.py +++ b/tests/adapter.py @@ -139,7 +139,7 @@ def read_manifest() -> dict: """ import tomllib - path = pathlib.Path(__file__).resolve().parents[1] / "pythonx-map.toml" + path = pathlib.Path(__file__).resolve().parents[1] / "pythonx" / "compose" / "pythonx-map.toml" with path.open("rb") as handle: return tomllib.load(handle) diff --git a/tests/test_pythonx_map.py b/tests/test_pythonx_map.py index 9f45326..0f4b5e7 100644 --- a/tests/test_pythonx_map.py +++ b/tests/test_pythonx_map.py @@ -20,7 +20,7 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[1] -MAP = REPO / "pythonx-map.toml" +MAP = REPO / "pythonx" / "compose" / "pythonx-map.toml" NOTEBOOK = REPO / "UI.ipynb" @@ -60,6 +60,8 @@ def test_the_wheel_is_configured_to_carry_it(self) -> None: class TestEveryNotebookImportIsMapped(unittest.TestCase): def test_the_specification_resolves(self) -> None: + if not NOTEBOOK.is_file(): + self.skipTest("UI.ipynb is not here (it lives only in the maintainer's main checkout)") modules = _manifest()["modules"] missing = sorted(name for name in _notebook_imports() if name not in modules) self.assertEqual([], missing, "UI.ipynb imports a module the manifest does not map") diff --git a/tests/test_wheel.py b/tests/test_wheel.py new file mode 100644 index 0000000..9a81371 --- /dev/null +++ b/tests/test_wheel.py @@ -0,0 +1,78 @@ +"""What the wheel actually carries -- built, opened and listed, not inferred from `package-data`. + +AGENTS.md section 15: a pattern in `pyproject.toml` is not proof that a file ships. This builds the +wheel from a copy of the sources (so no `build/` or `*.egg-info` lands in the checkout) and reads its +file list. + +Needs `setuptools` and `wheel` importable in the running interpreter; without them the test skips, +and a skip is not a pass. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +import sys +import tempfile +import tomllib +import unittest +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SCRATCH = REPO / ".tmp" + + +def _build_wheel(out: Path) -> Path: + source = out / "src" + shutil.copytree( + REPO, source, + ignore=shutil.ignore_patterns(".git", ".tmp", ".worktrees", "build", "*.egg-info", + "__pycache__", "UI.ipynb", "test", "docs", "tools"), + ) + wheels = out / "wheels" + subprocess.run( + [sys.executable, "-m", "pip", "wheel", str(source), "--no-deps", "--no-build-isolation", + "-q", "-w", str(wheels)], + check=True, capture_output=True, text=True, + ) + (wheel,) = wheels.glob("*.whl") + return wheel + + +class TheWheel(unittest.TestCase): + + @classmethod + def setUpClass(cls): + for needed in ("setuptools", "wheel"): + if importlib.util.find_spec(needed) is None: + raise unittest.SkipTest(f"{needed} is not installed; the wheel cannot be built here") + SCRATCH.mkdir(exist_ok=True) + cls._dir = tempfile.TemporaryDirectory(dir=SCRATCH) + wheel = _build_wheel(Path(cls._dir.name)) + with zipfile.ZipFile(wheel) as archive: + cls.names = set(archive.namelist()) + + @classmethod + def tearDownClass(cls): + cls._dir.cleanup() + + def test_the_manifest_is_inside_the_package(self): + self.assertIn("pythonx/compose/pythonx-map.toml", self.names) + + def test_every_mapped_module_is_a_file_in_the_wheel(self): + with (REPO / "pythonx" / "compose" / "pythonx-map.toml").open("rb") as handle: + modules = tomllib.load(handle)["modules"] + missing = sorted( + name for name in modules + if name.replace(".", "/") + "/__init__.py" not in self.names + ) + self.assertEqual([], missing, "a mapped module with no file cannot be imported once installed") + + def test_the_re_export_rule_ships(self): + self.assertIn("pythonx/compose/_reexport.py", self.names) + + +if __name__ == "__main__": + unittest.main()