Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 13 additions & 18 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
75 changes: 6 additions & 69 deletions pythonx/compose/_reexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
76 changes: 7 additions & 69 deletions tests/test_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading