diff --git a/docs/SPEC.md b/docs/SPEC.md index a7b81c1..496458d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -23,16 +23,15 @@ Run from a worktree with `uv run --with pytest --with mypy pytest tests -q -rs` |---|---| | No `PythonMultiplatform` checkout found | **140 passed, 112 skipped**, 167 subtests passed | | python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **251 passed, 1 skipped**, 198 subtests passed | -| an older checkout, without `describe_member` (python-multiplatform #54) | the same, with 7 more skipped | +| an older checkout, before python-multiplatform #131 (no binder-side snake_case names) | the same, with the snake_case method tests skipped | | an older one, without `describe(module, name)` (python-multiplatform #36) | with 13 more skipped again | | an older one still, without `add_member_resolver` | with 5 more skipped again | Measured in a worktree, which has `.tmp/kotlin-stubs.zip` but no `UI.ipynb`. Without a checkout, the 110 tests that install the binder's layers through `tests/adapter.py` skip, plus the notebook test. Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1) -skip. Against a binder without `describe_member`, the 7 that check a method's keywords (S4.1) skip; the 2 -that check the fallback (Kotlin keywords, names resolved) run. Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the -binder returned skip as well, because that needs its member resolver (python-multiplatform #17). The skip against a current checkout is `UI.ipynb`'s (§2). A binder older than #38 skips the 5 +skip. Against a binder before python-multiplatform #131, the tests that call a snake_case method or +pass a snake_case keyword on a proxy the binder returned skip, because those names are the binder's. The skip against a current checkout is `UI.ipynb`'s (§2). A binder older than #38 skips the 5 `TheBinderPath` tests (S5.4). A binder without the `@Composable` binding (`push_composer`) skips the 13 text-field tests (S5.5). `tests/test_typing.py` skips where mypy is not installed. A skip is not a pass. @@ -221,18 +220,14 @@ Evidence: `tests/test_runtime_module.py::TheModuleIsTheFileOnDisk`, real Python and the fake host. Disabling the name rule fails 15 tests, the keyword mapping 1, the allowlist priming 12. -Methods on a proxy the binder returned (`m.fill_max_width()`) are attributes of the binder's own -class. The same rule reaches them as the binder's member resolver (`member_name`, registered through -`python_multiplatform.binding.add_member_resolver`, python-multiplatform `ba4c6f49`); the binder -caches the alias in its own registry, so its class keeps Kotlin names in `dir()` (issue #20). -Keyword arguments to such a method are snake_case too (`m.padding(padding_values=...)`): the resolver -answers `(kotlin_name, {python_keyword: kotlinParameter})`, built from every overload's parameter -names read with `python_multiplatform.describe_member(type, member)` (python-multiplatform #54; -receiver slot skipped, cached per type and member because it is metadata, nothing invoked). The -Kotlin spelling still works at run time -- the binder passes a keyword the map does not name -through -- so `m.padding(paddingValues=...)` is accepted; the stubs are Pythonic-only (§1, S1.2), -so a type checker flags it. On a binder without `describe_member` the resolver answers the name -only, and a method's keywords are Kotlin's. +Methods on a proxy the binder returned (`m.fill_max_width()`) and their keyword arguments +(`m.padding(padding_values=...)`) are the binder's own since python-multiplatform #131 (develop +452cc807): it serves snake_case names and keywords by this package's rule, keeps the Kotlin spelling +working, and lists Kotlin names only in `dir()`. This package no longer registers a member resolver +(issue #79). `tests/test_chain.py::TheBindersNamingRuleIsThisPackages` pins that the binder's +`python_multiplatform.python_name` and this package's agree, so the stubs and the runtime spell names +the same. The stubs are Pythonic-only (§1, S1.2), so a type checker flags a Kotlin keyword the +runtime accepts. ## 4. Naming @@ -246,8 +241,8 @@ name. Implemented for module-level names and keywords and for method names on a binder proxy (`tests/test_chain.py::Names`, 8 tests; `TheChain`) and for a method's keywords on a binder proxy -(`tests/test_chain.py::MethodKeywords`, 7 tests, and `MethodKeywordsNeedTheBindersDescription`, 2; needs -python-multiplatform's `describe_member`, #54). Disabling the keyword map fails 4 tests. +(`tests/test_chain.py::MethodKeywords`, 5 tests; served by the binder since python-multiplatform +#131, so they skip on an older one). ### S4.2 The notebook's spellings are examples, not the contract: `implemented` as a rule diff --git a/pythonx/compose/_reexport.py b/pythonx/compose/_reexport.py index a38afb7..3b1f649 100644 --- a/pythonx/compose/_reexport.py +++ b/pythonx/compose/_reexport.py @@ -23,11 +23,9 @@ defaults stay `python_multiplatform.KOTLIN_DEFAULT`. - **Value classes.** The manifest's `raw-primitive-allowed` list is handed to the binder once per installed binding layer, so `padding(16)` reaches a `Dp` parameter. -- **Methods on a proxy.** The same name rule is registered as the binder's member resolver, so - `Modifier.padding(16).fill_max_width()` reaches `fillMaxWidth` on a proxy Kotlin returned, and - `m.padding(padding_values=...)` reaches `paddingValues`: the resolver also answers a keyword map, - read from `python_multiplatform.describe_member`. The Kotlin spelling still works. The binder - caches the alias in its own registry; its proxy classes keep Kotlin names in `dir()`. +- **Methods on a proxy** are the binder's: since python-multiplatform #131 it serves + `Modifier.padding(16).fill_max_width()` and `m.padding(padding_values=...)` itself, by this + package's rule, with the Kotlin spelling still working. Nothing here takes part. - **Grouped constants.** Inside a Kotlin object served as a namespace, a type nested in it that is not one of its members groups the constants declared as exactly that type, so the notebook's @@ -116,77 +114,16 @@ def binding_layer(): _PRIMED = weakref.WeakSet() -_MEMBER_KEYWORDS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary() - - -def _method_keywords(kotlin_type_name: str, kotlin_member_name: str) -> dict: - """`{snake_case(p): p}` for every parameter `p` of every declaration of a proxy's member. - - Read with `python_multiplatform.describe_member`, which lists each overload's parameters without - invoking anything (the receiver slot is not among them). It is metadata, so it is kept per - (type, member), under the `describe_member` of the binding layer that answered: a new layer - starts with an empty table. A keyword whose snake_case spelling is its own is left out, and a - binder without `describe_member`, or a member it does not describe, has no map. - """ - describe_member = getattr(sys.modules.get(ROOT_MODULE), "describe_member", None) - if describe_member is None: - return {} - try: - known = _MEMBER_KEYWORDS.setdefault(describe_member, {}) - except TypeError: # something that cannot be weakly referenced: nothing to key a cache by - known = {} - key = (kotlin_type_name, kotlin_member_name) - if key not in known: - keywords: dict = {} - try: - declarations = describe_member(kotlin_type_name, kotlin_member_name) - except (TypeError, AttributeError): - declarations = () - for declaration in declarations: - for parameter in declaration.get("parameters", ()): - kotlin_parameter = parameter.get("name") - if kotlin_parameter and snake_case(kotlin_parameter) != kotlin_parameter: - keywords.setdefault(snake_case(kotlin_parameter), kotlin_parameter) - known[key] = keywords - return known[key] - - -def member_name(kotlin_type_name: str, requested: str, kotlin_member_names): - """The binder's member-resolver hook: the Kotlin member a Pythonic name means on a proxy, and - the keywords it takes. - - The binder asks for a name its proxy has no Kotlin member of - (`python_multiplatform.binding.add_member_resolver`): the rule is the module rule applied - forward to the proxy's real member names, so `fill_max_width` finds `fillMaxWidth`. It asks - again for a Kotlin member name when a call passes keywords. The answer is then - `(kotlin_name, {python_keyword: kotlinParameter})`, the parameters of every overload of that - member, so `m.padding(padding_values=...)` reaches `paddingValues`. Without a keyword to map the - answer is the name alone, which is all a binder without `describe_member` can be given. - """ - if requested in kotlin_member_names: - target = requested - else: - matches = [name for name in kotlin_member_names if python_name(name) == requested] - target = matches[0] if len(matches) == 1 else None - if target is None: - return None - keywords = _method_keywords(kotlin_type_name, target) - return (target, keywords) if keywords else target - - def _prime(binding) -> None: - """Hand this binding layer the manifest's value-class allowlist and the member rule, once. + """Hand this binding layer the manifest's value-class allowlist, once. - The member rule needs python-multiplatform's `add_member_resolver`; on a binder without it, - methods on a returned proxy stay reachable by their Kotlin names only. + Method names and keywords on a proxy are the binder's own since python-multiplatform #131 + (snake_case by this package's rule); nothing is registered for them here. """ if binding in _PRIMED: return for kotlin_type in manifest().get("value-classes", {}).get("raw-primitive-allowed", ()): binding.allow_raw_primitive(kotlin_type) - add_member_resolver = getattr(binding, "add_member_resolver", None) - if add_member_resolver is not None: - add_member_resolver(member_name) _PRIMED.add(binding) diff --git a/tests/test_chain.py b/tests/test_chain.py index a5902e5..a0a43f5 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -61,12 +61,14 @@ def describe(self, modifier): return ui.describe_modifier(modifier) def needs_member_resolver(self): - """Receiver methods by snake_case name need the binder's member-resolver hook. + """Receiver methods and their keywords by snake_case name are the binder's own. - python-multiplatform #17 adds it; until a checkout has it, these tests skip rather than pass. + python-multiplatform #131 serves them; on an older binder these tests skip rather than pass. """ - if not hasattr(self.binding, "add_member_resolver"): - self.skipTest("the binder has no add_member_resolver yet (python-multiplatform #17)") + import python_multiplatform + + if not hasattr(python_multiplatform, "python_name"): + self.skipTest("the binder does not serve snake_case names yet (python-multiplatform #131)") class TheBinderSourcesAreReadable(AdapterCase): @@ -207,19 +209,11 @@ def test_the_module_function_dispatches_with_snake_case_keywords(self): class MethodKeywords(AdapterCase): - """SPEC S4.1: a method's keywords are snake_case like a module function's. - - The member resolver answers `(kotlin_name, {python_keyword: kotlinParameter})`, built from - `python_multiplatform.describe_member(type, member)` (python-multiplatform #54). - """ + """SPEC S4.1: a method's keywords are snake_case like a module function's (the binder's, #131).""" def setUp(self): super().setUp() - import python_multiplatform - self.needs_member_resolver() - if not hasattr(python_multiplatform, "describe_member"): - self.skipTest("the binder has no describe_member(type, member) yet (python-multiplatform #54)") def values(self): import pythonx.compose.foundation.layout as layout @@ -253,62 +247,6 @@ def test_an_unknown_keyword_still_reaches_the_binders_refusal_with_its_candidate self.assertIn("Candidates", message) self.assertIn("padding__PaddingValues", message) - def test_the_map_is_metadata_and_is_asked_for_once_per_member(self): - import python_multiplatform - - original, asked = python_multiplatform.describe_member, [] - - def counting(*args): - asked.append(args) - return original(*args) - - python_multiplatform.describe_member = counting - self.addCleanup(setattr, python_multiplatform, "describe_member", original) - modifier = self.empty() - modifier.padding(padding_values=self.values()) - modifier.padding(padding_values=self.values()) - self.assertEqual([(fake_host.MODIFIER, "padding")], asked) - - def test_the_map_lists_every_overloads_parameters_and_not_the_receiver(self): - from pythonx.compose._reexport import member_name - - answer = member_name(fake_host.MODIFIER, "padding", ["padding"]) - self.assertEqual("padding", answer[0]) - self.assertEqual("paddingValues", answer[1]["padding_values"]) - self.assertNotIn("receiver", answer[1]) - self.assertNotIn("horizontal", answer[1]) # one word: Kotlin's spelling is the same - - -class MethodKeywordsNeedTheBindersDescription(AdapterCase): - """Without `describe_member` a method's keywords are Kotlin's and its names still resolve.""" - - def setUp(self): - super().setUp() - import python_multiplatform - - self.needs_member_resolver() - original = getattr(python_multiplatform, "describe_member", None) - if original is not None: - del python_multiplatform.describe_member - self.addCleanup(setattr, python_multiplatform, "describe_member", original) - - def test_the_name_resolves_and_kotlin_keywords_work(self): - import pythonx.compose.foundation.layout as layout - - modifier = self.empty().fill_max_width() - self.assertEqual("fillMaxWidth", self.describe(modifier)) - self.empty().padding(paddingValues=layout.padding_values_of(8)) - self.assertEqual("padding__PaddingValues", self.host.calls[-1]) - - def test_a_snake_case_keyword_is_the_binders_refusal(self): - import python_multiplatform - import pythonx.compose.foundation.layout as layout - - if hasattr(python_multiplatform, "python_name"): - self.skipTest("the binder translates names itself since python-multiplatform #131") - with self.assertRaises(TypeError): - self.empty().padding(padding_values=layout.padding_values_of(8)) - class TheBindersNamingRuleIsThisPackages(AdapterCase): """python-multiplatform #131 restored the binder's own snake_case names, with this package's rule.