From 4029c624b2b2c49c25bdbd5e3bc2519e786b819e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:13:35 +0900 Subject: [PATCH] Feat: Ship the manifest inside the package, and prove it by building the wheel pythonx-map.toml sat at the repository root, outside every package directory, so the package-data pattern that names it could never pick it up -- and the re-export rule now reads it at run time, so an installed pythonx-compose would have failed on the first name it resolved. It moves to pythonx/compose/pythonx-map.toml, and every reader follows. tests/test_wheel.py builds the wheel from a copy of the sources (nothing lands in the checkout) and lists it: the manifest, the re-export rule, and an __init__.py for every mapped module must be inside. It skips, saying so, where setuptools or wheel is missing. requires-python goes from 3.8 to 3.11: the runtime reads the manifest with tomllib. The notebook-coverage test now skips where UI.ipynb is absent instead of passing with nothing checked, which AGENTS.md section 11 used to warn about. feature added: the manifest ships in the wheel. test added: test_wheel.py (3). Red before the move: 2 failed, 1 passed. test changed: the notebook-coverage test skips without the notebook. documentation corrected: README and its Korean copy (link and example path), the guide's getting-started example, SPEC S1.1 and baseline, PROJECT.md tree and baseline, AGENTS.md sections 11. Suite with ../PythonMultiplatform: 81 passed, 4 skipped -> 84 passed, 4 skipped. Without a checkout: 40 passed, 45 skipped -> 43 passed, 45 skipped (main checkout, notebook present). check_guide.py: 0. Refs #13 --- AGENTS.md | 4 +- PROJECT.md | 9 ++- README.md | 4 +- docs/SPEC.md | 18 ++--- docs/guide/getting-started.html | 4 +- docs/guide/status.html | 2 +- docs/locale/README_ko.md | 4 +- pyproject.toml | 2 +- pythonx/compose/_reexport.py | 2 +- .../compose/pythonx-map.toml | 0 tests/adapter.py | 2 +- tests/test_pythonx_map.py | 4 +- tests/test_wheel.py | 78 +++++++++++++++++++ 13 files changed, 107 insertions(+), 26 deletions(-) rename pythonx-map.toml => pythonx/compose/pythonx-map.toml (100%) create mode 100644 tests/test_wheel.py 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 @@
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) 건너뜁니다. 예상된 상태이며 기록되어 있습니다.
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.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 체인, 오버로드 디스패치, 숫자 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()