diff --git a/docs/SPEC.md b/docs/SPEC.md index 496458d..5cc6bfb 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -209,16 +209,16 @@ Required by INTENT ยง2.2: `import pythonx.compose.material3` (and every other ma Every module in `pythonx-map.toml` is a file on disk whose `__init__.py` hands its name to one rule, `pythonx/compose/_reexport.py` (issue #8). A name read from it resolves, on first use, against the Kotlin package the manifest maps it to: upper-case names keep their Kotlin spelling, every other -name is reached by its snake_case spelling only, keywords are matched to the declaration's own -parameter names, `inspect.signature` reports snake_case names, and the manifest's value-class -allowlist is handed to the binder. Names the package defines itself (`runtime.Composable`) win. +name is reached by its snake_case spelling, functions are the binder's own callables (whose +snake_case keywords and `inspect.signature` are the binder's since python-multiplatform #131), and +the manifest's value-class allowlist is handed to the binder. Names the package defines itself (`runtime.Composable`) win. Evidence: `tests/test_runtime_module.py::TheModuleIsTheFileOnDisk`, `tests/test_ui_init_module.py::TheUiInitModuleIsTheFileOnDisk`, and `tests/test_chain.py` (`Names`, `Laziness`, `OverloadDispatch::test_the_module_function_dispatches_with_snake_case_keywords`, `ValueClasses::test_the_manifest_allowlist_is_what_lets_a_number_through`), against the binder's -real Python and the fake host. Disabling the name rule fails 15 tests, the keyword mapping 1, the -allowlist priming 12. +real Python and the fake host. Disabling the name rule fails 15 tests and the allowlist priming 12 +(measured before #79 moved keyword translation into the binder). 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 diff --git a/pythonx/compose/_reexport.py b/pythonx/compose/_reexport.py index 3b1f649..165f1d4 100644 --- a/pythonx/compose/_reexport.py +++ b/pythonx/compose/_reexport.py @@ -1,26 +1,24 @@ """The one rule that makes a `pythonx.compose.*` module a Pythonic view of a Kotlin package. -The binder (`python-multiplatform`) serves every bound Kotlin package under its **Kotlin** name -- -`androidx.compose.foundation.layout` -- with Kotlin's own surface: declaration names, keyword -arguments by Kotlin parameter name, Kotlin defaults, overload dispatch. It renames nothing, and it -publishes what a Pythonic layer needs to rename by rule: `inspect.signature(fn)` with the Kotlin -parameter names, and `python_multiplatform.describe(fn)` with one dict per overload. +The binder (`python-multiplatform`) serves every bound Kotlin package under its **Kotlin** name +(`androidx.compose.foundation.layout`), and since its #131 it also serves the Pythonic spellings +itself: snake_case names and keyword arguments, by this module's rule (`python_name`), with the +Kotlin spelling still working and `inspect.signature` reporting the snake_case keywords. It never +renames a namespace: `pythonx` is this package. -This module is that layer. A mapped package's `__init__.py` is one line:: +This module groups those Kotlin packages into `pythonx.compose.*`. A mapped package's `__init__.py` +is one line:: __getattr__, __dir__ = reexport(__name__) and every name in it then resolves against the Kotlin package `pythonx-map.toml` maps it to: -- **Names.** A name that starts upper-case -- a type, an object, a `@Composable` -- keeps its Kotlin - spelling (`Modifier`, `Text`). Any other name is reached by its snake_case spelling only +- **Names.** A name that starts upper-case (a type, an object, a `@Composable`) keeps its Kotlin + spelling (`Modifier`, `Text`). Any other name is listed and reached by its snake_case spelling (`fillMaxWidth` -> `fill_max_width`, `toURLString` -> `to_url_string`), and an explicit overload - keeps its suffix (`padding__Dp`). The inverse is never computed: the module's real Kotlin names - are converted forward and looked up, so a name the reverse rule could not invert still resolves. -- **Keywords.** `on_click=` reaches the parameter Kotlin calls `onClick`, matched against the - declaration's own parameter names (every overload's), never derived. -- **Signatures.** A re-exported function answers `inspect.signature` with the snake_case names; - defaults stay `python_multiplatform.KOTLIN_DEFAULT`. + keeps its suffix (`padding__Dp`). The rule is applied forward to the module's real Kotlin names, + so a name the reverse rule could not invert still resolves. +- **Functions** are the binder's own callables; their keywords and signatures are the binder's. - **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** are the binder's: since python-multiplatform #131 it serves @@ -48,7 +46,6 @@ import importlib import importlib.util -import inspect import re import sys import types @@ -170,72 +167,14 @@ def _declared_type(kotlin, kotlin_name: str): return described.get("kind"), described.get("returns") -def _describe(fn): - root = sys.modules.get(ROOT_MODULE) - if root is None or not hasattr(root, "describe"): - return () - try: - return root.describe(fn) - except (TypeError, AttributeError): - return () - - -class PythonicFunction: - """A binder callable seen through the naming rule: snake_case keywords and signature. - - Calls go straight to the binder's callable with the keywords translated; overload selection, - defaults and value classes are the binder's. A keyword that matches no parameter is passed on - unchanged, so the binder's own refusal -- which lists the candidates -- is what the caller sees. - """ - - __slots__ = ("_kotlin", "_keywords", "__name__", "__qualname__") - - def __init__(self, kotlin_fn, name): - self._kotlin = kotlin_fn - self.__name__ = name - self.__qualname__ = name - keywords = {} - for row in _describe(kotlin_fn): - for parameter in row.get("parameters", ()): - kotlin_parameter = parameter["name"] - keywords.setdefault(snake_case(kotlin_parameter), kotlin_parameter) - self._keywords = keywords - - @property - def __kotlin__(self): - """The binder's callable this wraps.""" - return self._kotlin - - @property - def __kotlin_rows__(self): - return self._kotlin.__kotlin_rows__ - - @property - def __signature__(self): - kotlin_signature = inspect.signature(self._kotlin) - return kotlin_signature.replace(parameters=[ - parameter if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD) - else parameter.replace(name=snake_case(parameter.name)) - for parameter in kotlin_signature.parameters.values() - ]) - - def __call__(self, *args, **kwargs): - if kwargs: - kwargs = {self._keywords.get(key, key): value for key, value in kwargs.items()} - return self._kotlin(*args, **kwargs) - - def __repr__(self): - return f" {self._kotlin!r}>" - - def _pythonic(value, name): if isinstance(value, types.ModuleType): # A Kotlin object served as a sub-package. Since python-multiplatform #35 the binder lists it # and exposes it as an attribute of its parent, so the name table reaches it directly. return KotlinObject(value) - if isinstance(value, type) or not callable(value): - return value - return PythonicFunction(value, name) + # A function is the binder's own callable: since python-multiplatform #131 it takes snake_case + # keywords and reports them in `inspect.signature` itself. + return value def _resolve(kotlin, name: str):