From 90c54cd6c75d9f7f883a160f59a25a1c84c4dac5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:52:11 +0900 Subject: [PATCH] Feat: Child namespaces, describe-by-name, keyword maps, and material-icons in the walk - A module's dir() lists its direct child packages and objects, and reading one imports it (#35). - describe(module, name) describes any bound name, constants included, without running the getter (#36). - A member resolver may answer (kotlin_name, keyword_map); a Kotlin-named member asks for a keyword map only when called with keywords (#34). - material-icons-core 1.7.3 (the last published) is walked; Icons.Default is bound and describable. Icons.Default.Add is not yet reachable: it is an extension property and the walker binds no properties (#38). Color(Long) and Color(Int) are bound and tested. --- docs/SPEC.md | 11 +- gradle/libs.versions.toml | 4 + ksp-fixtures/compose/build.gradle.kts | 4 + .../fixture/compose/ColorFactoryTest.kt | 57 +++++ .../fixture/compose/ComposableRenderTest.kt | 6 +- .../kotlin/fixture/compose/IconRenderTest.kt | 17 +- .../compose/MaterialIconsRenderTest.kt | 119 +++++++++ .../ffi/pythonx/PythonxAdapter.kt | 146 +++++++++-- .../multiplatform/ffi/upcall/KotlinSurface.kt | 68 +++++- .../ffi/upcall/PythonProxySource.kt | 17 +- .../ffi/pythonx/BinderNamespaceTest.kt | 227 ++++++++++++++++++ .../ffi/pythonx/MemberResolverTest.kt | 112 +++++++++ 12 files changed, 757 insertions(+), 31 deletions(-) create mode 100644 ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt create mode 100644 ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt create mode 100644 python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/BinderNamespaceTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 1be46730c..1c287415b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -173,14 +173,19 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **U-8** A function on a Kotlin-named module carries Kotlin's own surface and nothing else: the Kotlin declaration name, keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted parameters, overload sets under the base name, and its signature as public metadata - (`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc). No + (`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc). + `describe(module, name)` describes any bound name, a named constant (`STATIC_GETTER`) included, + without evaluating it (#36). A module's `dir()` lists its direct child packages/objects and reading + one as an attribute imports it (#35) — Kotlin names only. No member or parameter is renamed; the binder creates no `pythonx` module and a real `pythonx` package on disk is what `import pythonx` loads. The answer is the same whichever installer (`PythonProxySource`, `PythonxAdapter`) ran first for a table. `Status: implemented` on desktop — - `PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. + `PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `BinderNamespaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. - **U-9** A Pythonic package can serve extra member names on a Kotlin proxy through one hook, `python_multiplatform.binding.add_member_resolver(fn)`, `fn(kotlin_type_name, requested_name, - kotlin_member_names) -> kotlin_name | None`, asked only when no Kotlin member of that name exists. + kotlin_member_names) -> kotlin_name | (kotlin_name, keyword_map) | None`, asked when no Kotlin member + of that name exists — and, for a Kotlin-named member, only when a call passes keywords and only for + its `{python_kw: kotlinParam}` keyword map (#34). The binder renames nothing itself (no resolver: `AttributeError`), and aliases are cached in the registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc657f5fc..3aaa559ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,9 @@ androidx-lifecycle = "2.10.0" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" compose-plugin = "1.11.1" +# JetBrains stopped publishing material-icons with Compose after 1.7.3 (Maven Central: newest is 1.7.3); +# it is the version Compose Multiplatform 1.11.x points to, resolved against 1.11.1's ui/ui-graphics. +compose-material-icons = "1.7.3" graalvm-buildtools = "0.10.6" junit = "4.13.2" kotlin = "2.4.20-Beta2" @@ -20,6 +23,7 @@ kotlinx-coroutines = "1.8.1" ksp = "2.3.11" [libraries] +compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "compose-material-icons" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts index 4a8581fc9..370173d6e 100644 --- a/ksp-fixtures/compose/build.gradle.kts +++ b/ksp-fixtures/compose/build.gradle.kts @@ -49,6 +49,8 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) + // `Icons.Default.Add` and friends (issue #37): the one place an `ImageVector` is built. + implementation(libs.compose.material.icons.core) implementation(compose.desktop.currentOs) } } @@ -92,6 +94,8 @@ pythonBindings { "androidx.compose.ui.graphics", "androidx.compose.ui.res", "androidx.compose.ui", + // 5. `material.icons` -- `Icons.Default` and the icon properties behind it (issue #37). + "androidx.compose.material.icons", ), ) generateStubs.set(false) diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt new file mode 100644 index 000000000..64926ef55 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt @@ -0,0 +1,57 @@ +package fixture.compose + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Compose's packed-ARGB `Color` factories, `Color(color: Long)` and `Color(color: Int)`, called from + * Python under their bound overload spellings. `toArgb` is the judge: `0xFFFF0000` is opaque red, + * `-65536` as a Kotlin `Int`, whichever factory built it. + */ +class ColorFactoryTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun theLongAndIntColorFactoriesAreBoundAndBuildTheSameColor() { + Python3.exec( + """ + import python_multiplatform as _cf_pm + import androidx.compose.ui.graphics as _cf_g + from androidx.compose.ui.graphics import Color__Long, Color__Int, toArgb + + assert [(_p['name'], _p['type']) for _p in _cf_pm.describe(_cf_g, 'Color__Long')['parameters']] == \ + [('color', 'kotlin.Long')], _cf_pm.describe(_cf_g, 'Color__Long') + assert [(_p['name'], _p['type']) for _p in _cf_pm.describe(_cf_g, 'Color__Int')['parameters']] == \ + [('color', 'kotlin.Int')], _cf_pm.describe(_cf_g, 'Color__Int') + + _cf_long = Color__Long(0xFFFF0000) + _cf_int = Color__Int(-65536) + assert type(_cf_long)._kotlin_type_name == 'androidx.compose.ui.graphics.Color', type(_cf_long) + assert toArgb(_cf_long) == -65536, toArgb(_cf_long) + assert toArgb(_cf_int) == -65536, toArgb(_cf_int) + assert toArgb(Color__Long(0xFF00FF00)) == -16711936, 'green through the Long factory' + # Keyword by the Kotlin parameter name. + assert toArgb(Color__Long(color=0x800000FF)) == -2147483393 + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt index 9266fb005..68af5fa28 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt @@ -826,10 +826,14 @@ class ComposableRenderTest { assert _icon_param_types == _icon_image_types, \ "Icon's overloads no longer match what this test recorded: " + repr(_icon_param_types) + # `materialIcon` (material-icons-core, walked since issue #37) returns an ImageVector, + # but only out of a `(ImageVector.Builder) -> ImageVector.Builder` lambda, which Python + # cannot write (`IconRenderTest.imageVectorIsUnreachable...` checks that refusal). So it is + # not a producer a Python caller can use, and it is the only one. _produced = { d.return_type_name for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls - if d.return_type_name in _icon_image_types + if d.return_type_name in _icon_image_types and d.kotlin_name != 'androidx.compose.material.icons.materialIcon' } assert _produced == { 'androidx.compose.ui.graphics.ImageBitmap', diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt index 6898653d9..ea984fb04 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt @@ -442,8 +442,21 @@ class IconRenderTest { _all = [d for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls] _producers = [d.kotlin_name for d in _all if d.return_type_name == _vector] - assert _producers == [], \ - 'something now returns an ImageVector -- the builder is reachable: ' + repr(_producers) + # material-icons-core (issue #37) brings one: `materialIcon(name, ..., imageVectorBuilder)`. + # It builds through a `(ImageVector.Builder) -> ImageVector.Builder` lambda, and a Python + # callable cannot stand in for a lambda that has to give something back -- refused below, + # so it opens nothing. Anything else returning an ImageVector still fails this. + assert _producers == ['androidx.compose.material.icons.materialIcon'], \ + 'something else now returns an ImageVector -- the builder may be reachable: ' + repr(_producers) + import androidx.compose.material.icons as _mi + _mi_decl = [d for d in _all if d.kotlin_name == 'androidx.compose.material.icons.materialIcon'][0] + _mi_slot = [n for n, t in zip(_mi_decl.param_names, _mi_decl.param_type_names) + if t.startswith('kotlin.Function')][0] + try: + _mi.materialIcon('x', **{_mi_slot: lambda builder: builder}) + raise AssertionError('materialIcon accepted a Python builder lambda') + except TypeError as _mi_e: + assert 'cannot be written in Python yet' in str(_mi_e), str(_mi_e) # The extension *on* the builder is bound; the type that would produce one is not. That # gap is the nested-class skip and nothing else. diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt new file mode 100644 index 000000000..8cdb12f9d --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt @@ -0,0 +1,119 @@ +package fixture.compose + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Issue #37: `material-icons-core` in the walk, so `Icon` has an `ImageVector` to draw -- **partly**: + * `Icons.Default` is bound, the icons themselves are not (see + * [iconsDefaultAddIsUnreachableBecauseTheWalkerBindsNoExtensionProperty]). + * + * The artifact is `org.jetbrains.compose.material:material-icons-core:1.7.3` -- the newest JetBrains + * published (Maven Central), and the one Compose Multiplatform 1.11.x points to; its `ui` + * dependencies resolve up to 1.11.1. + */ +class MaterialIconsRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** The control: the same icon drawn from Kotlin, so a 0 below means "the crossing failed". */ + @Test + fun theAddIconDrawsFromKotlin() { + val ink = inkOfScene { Icon(Icons.Default.Add, contentDescription = null) } + println("compose render: Kotlin Icon(Icons.Default.Add) -> $ink px") + assertTrue(ink > 0, "the control icon drew nothing") + } + + /** `Icons.Default` is bound, and `describe()` says what it is without reading it. */ + @Test + fun iconsDefaultIsBoundAndDescribed() { + Python3.exec( + """ + import python_multiplatform as _mi_pm + import androidx.compose.material.icons as _mi_icons + assert 'Icons' in dir(_mi_icons), dir(_mi_icons) + _mi_d = _mi_pm.describe(_mi_icons.Icons, 'Default') + assert _mi_d['kind'] == 'STATIC_GETTER', _mi_d + assert _mi_d['returns'] == 'androidx.compose.material.icons.Icons.Filled', _mi_d + assert 'Default' in dir(_mi_icons.Icons), dir(_mi_icons.Icons) + """.trimIndent(), + ) + } + + /** + * **Not reached yet -- pinned, with the reason.** Issue #37's completion statement is + * `Icon(Icons.Default.Add, contentDescription=None)` from Python, and adding the artifact is not + * enough for it: `Icons.Default` is bound (above), but `Add` is a top-level **extension property**, + * `val Icons.Filled.Add: ImageVector` (`AddKt.getAdd(Icons$Filled)` in the jar), and the walker + * binds a file facade's *functions* only (`ArtifactScanner.scanClassNode` reads + * `kmPackage.functions`, never `kmPackage.properties`). So no binding has `Icons.Filled` as its + * receiver and the attribute is an `AttributeError`. + * + * The day the walker binds extension properties, this fails, and it should become the render + * proof: `Icon(androidx.compose.material.icons.Icons.Default.Add, contentDescription=None)` + * drawing the same ink as [theAddIconDrawsFromKotlin]. + */ + @Test + fun iconsDefaultAddIsUnreachableBecauseTheWalkerBindsNoExtensionProperty() { + Python3.exec( + """ + import androidx + import python_multiplatform.binding as _mi_b + _mi_filled = 'androidx.compose.material.icons.Icons.Filled' + _mi_default = androidx.compose.material.icons.Icons.Default + assert type(_mi_default)._kotlin_type_name == _mi_filled, type(_mi_default) + try: + _mi_default.Add + raise AssertionError('Icons.Default.Add is reachable now -- turn this pin into the render proof') + except AttributeError: + pass + _mi_on_filled = [d.kotlin_name for d in _mi_b._TABLE.values() if d.receiver_type_name == _mi_filled] + assert _mi_on_filled == [], repr(_mi_on_filled) + assert 'androidx.compose.material.icons.filled' not in _mi_b._BY_PACKAGE, \ + sorted(_mi_b._BY_PACKAGE['androidx.compose.material.icons.filled']) + """.trimIndent(), + ) + } + + private fun inkOfScene(content: @Composable () -> Unit): Int { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f), content = content) + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + var ink = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != 0) ink++ + } + } + return ink + } finally { + scene.close() + } + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt index 702fb47fc..fab8de6f9 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt @@ -112,6 +112,7 @@ object PythonxAdapter { # only per-library knowledge is the package map and the value-class allowlist, both of which are # data and both of which a consumer can extend at run time. + import importlib as _importlib import importlib.machinery as _machinery # Read once per crossing, never per invocation: `_positional_capacity` asks it how many # arguments a callable takes at the moment the callable becomes a Kotlin `FunctionN`, and the @@ -256,6 +257,7 @@ object PythonxAdapter { _BY_PACKAGE = {} # kotlin package -> {kotlin name -> [_Decl]} _BY_RECEIVER = {} # kotlin receiver type -> {kotlin name -> [_Decl]} _PACKAGES_SEEN = set() + _CHILDREN = {} # kotlin package -> the Kotlin names of its direct child packages/objects _SUPERTYPES = {} # kotlin type name -> the types it is a, nearest first @@ -304,7 +306,9 @@ object PythonxAdapter { _BY_PACKAGE.clear() _BY_RECEIVER.clear() _MEMBER_ALIASES.clear() + _MEMBER_KEYWORDS.clear() _PACKAGES_SEEN.clear() + _CHILDREN.clear() _SUPERTYPES.clear() present = set() for row in rows: @@ -328,6 +332,11 @@ object PythonxAdapter { segments = decl.package.split('.') for count in range(1, len(segments) + 1): _PACKAGES_SEEN.add('.'.join(segments[:count])) + if count > 1: + # Issue #35: a package lists its direct children in `dir()` and serves them + # as attributes. Kotlin names, exactly as the table spells them -- an object + # (`Alignment`) is a package here, because its members are bound under it. + _CHILDREN.setdefault('.'.join(segments[:count - 1]), set()).add(segments[count - 1]) for gone in [name for name in _TABLE if name not in present]: del _TABLE[gone] # Here rather than at the end of this file: `_PACKAGES_SEEN` is empty until this @@ -381,6 +390,19 @@ object PythonxAdapter { return list(_TABLE) + def _rows_named(kotlin_package, name): + '''The table rows [name] stands for in [kotlin_package], or `None` -- without reading anything. + + What `python_multiplatform.describe(module, name)` asks first (issue #36): a + `STATIC_GETTER` read runs Kotlin, so a description has to come from the table and never + from the module attribute. An overload set's base name answers every member. + ''' + decls = _BY_PACKAGE.get(kotlin_package, {}).get(name) + if not decls: + return None + return tuple(decl.row for decl in decls) + + # --------------------------------------------------------------------------- names # **Kotlin names, unchanged, everywhere.** A Kotlin declaration is reached under its own name @@ -897,23 +919,30 @@ object PythonxAdapter { _MEMBER_RESOLVERS = [] - _MEMBER_ALIASES = {} # (kotlin receiver type, requested name) -> kotlin member name + _MEMBER_ALIASES = {} # (kotlin receiver type, requested name) -> (kotlin member name, keyword map) + _MEMBER_KEYWORDS = {} # (kotlin receiver type, kotlin member name) -> keyword map ({} for none) def add_member_resolver(fn): - '''Registers `fn(kotlin_type_name, requested_name, kotlin_member_names) -> kotlin_name | None`. - - Asked only when a proxy has **no** Kotlin member of the requested name. The first resolver to - return a name that is one of `kotlin_member_names` decides, and that Kotlin member is served. - The binder renames nothing by itself: with no resolver an unknown name is an `AttributeError`. - The answer is cached in this registry, never written onto the proxy class, so `dir()` of a - proxy shows Kotlin names only. Registering the same `fn` twice is a no-op. + '''Registers `fn(kotlin_type_name, requested_name, kotlin_member_names)`. + + It answers `kotlin_name`, `(kotlin_name, keyword_map)` or `None`. Asked for a name the proxy + has **no** Kotlin member of: the first resolver to return a name that is one of + `kotlin_member_names` decides, and that Kotlin member is served. Asked for a Kotlin member + name only when a call to it passes keyword arguments, and then only for its keyword map + (the answer must name that same member). `keyword_map` is `{python_kw: kotlinParam}`, + applied to that member's calls; keywords it does not name pass through unchanged. + The binder renames nothing by itself: with no resolver an unknown name is an `AttributeError` + and a keyword is a Kotlin parameter name. Answers are cached in this registry, never + written onto the proxy class, so `dir()` of a proxy shows Kotlin names only. Registering + the same `fn` twice is a no-op. ''' if not callable(fn): raise TypeError('a member resolver must be callable') if fn not in _MEMBER_RESOLVERS: _MEMBER_RESOLVERS.append(fn) _MEMBER_ALIASES.clear() + _MEMBER_KEYWORDS.clear() return fn @@ -922,10 +951,25 @@ object PythonxAdapter { if fn in _MEMBER_RESOLVERS: _MEMBER_RESOLVERS.remove(fn) _MEMBER_ALIASES.clear() + _MEMBER_KEYWORDS.clear() + + + def _resolver_answer(answer): + '''`(kotlin_name, keyword_map)` out of a resolver's answer; `(None, None)` for "not mine".''' + if isinstance(answer, tuple): + if len(answer) != 2 or not isinstance(answer[0], str) or not hasattr(answer[1], 'items'): + raise TypeError( + 'a member resolver answers kotlin_name, (kotlin_name, keyword_map) or None; got ' + + repr(answer) + ) + return answer[0], dict(answer[1]) + if isinstance(answer, str): + return answer, None + return None, None def _resolve_member(cls, name): - '''The Kotlin member name a registered resolver maps [name] to on [cls], or `None`.''' + '''`(kotlin_member_name, keyword_map_or_None)` a registered resolver maps [name] to on [cls], or `None`.''' if not _MEMBER_RESOLVERS: return None key = (cls._kotlin_type_name, name) @@ -935,24 +979,67 @@ object PythonxAdapter { members = _BY_RECEIVER.get(cls._kotlin_type_name, {}) names = tuple(members) for resolver in tuple(_MEMBER_RESOLVERS): - target = resolver(cls._kotlin_type_name, name, names) + target, keywords = _resolver_answer(resolver(cls._kotlin_type_name, name, names)) if target and target in members and not target.startswith('_'): - _MEMBER_ALIASES[key] = target - return target + _MEMBER_ALIASES[key] = (target, keywords) + return _MEMBER_ALIASES[key] return None + def _member_keywords(type_name, name): + '''The keyword map a resolver gives the Kotlin member [name] of [type_name]; `{}` for none. + + Asked only when a call passes keywords and a resolver is registered, so a call written with + Kotlin keywords and no resolver costs what it always did. + ''' + key = (type_name, name) + cached = _MEMBER_KEYWORDS.get(key) + if cached is not None: + return cached + names = tuple(_BY_RECEIVER.get(type_name, {})) + found = {} + for resolver in tuple(_MEMBER_RESOLVERS): + target, keywords = _resolver_answer(resolver(type_name, name, names)) + if target == name and keywords is not None: + found = keywords + break + _MEMBER_KEYWORDS[key] = found + return found + + + def _map_keywords(name, keywords, kwargs): + mapped = {} + for key, value in kwargs.items(): + target = keywords.get(key, key) + if target in mapped: + raise TypeError(name + "() got multiple values for argument '" + target + "'") + mapped[target] = value + return mapped + + class _BoundMember: - '''An extension applied to a receiver: literally the module-level callable with slot 0 filled.''' + '''An extension applied to a receiver: literally the module-level callable with slot 0 filled. + + `_keywords` is the keyword map a member resolver gave (issue #34): `None` until something + decides -- then a call with keywords asks the resolvers for this Kotlin name's map. + ''' - __slots__ = ('_fn', '_receiver', '__name__') + __slots__ = ('_fn', '_receiver', '__name__', '_type_name', '_keywords') - def __init__(self, fn, receiver, name): + def __init__(self, fn, receiver, name, type_name=None): self._fn = fn self._receiver = receiver self.__name__ = name + self._type_name = type_name + self._keywords = None def __call__(self, *args, **kwargs): + if kwargs: + keywords = self._keywords + if keywords is None and _MEMBER_RESOLVERS and self._type_name is not None: + keywords = _member_keywords(self._type_name, self.__name__) + if keywords: + kwargs = _map_keywords(self.__name__, keywords, kwargs) return self._fn(self._receiver, *args, **kwargs) @property @@ -980,9 +1067,18 @@ object PythonxAdapter { self._name = name def __get__(self, obj, owner=None): + if owner is None: + owner = type(obj) if obj is None: obj = owner.empty() - return _BoundMember(self._fn, obj, self._name) + return _BoundMember(self._fn, obj, self._name, getattr(owner, '_kotlin_type_name', None)) + + + def _aliased(member, keywords): + '''The member an alias answer served, carrying that answer's keyword map (if it gave one).''' + if keywords is not None and isinstance(member, _BoundMember): + member._keywords = keywords + return member class _ProxyMeta(type): @@ -993,12 +1089,12 @@ object PythonxAdapter { if name.startswith('_'): raise AttributeError(name) if not _attach(cls, name): - target = _resolve_member(cls, name) - if target is None: + resolved = _resolve_member(cls, name) + if resolved is None: raise AttributeError( 'no Kotlin extension named ' + name + ' on ' + cls._kotlin_type_name ) - return getattr(cls, target) + return _aliased(getattr(cls, resolved[0]), resolved[1]) return getattr(cls, name) def __repr__(cls): @@ -1039,12 +1135,12 @@ object PythonxAdapter { if name.startswith('_'): raise AttributeError(name) if not _attach(type(self), name): - target = _resolve_member(type(self), name) - if target is None: + resolved = _resolve_member(type(self), name) + if resolved is None: raise AttributeError( 'no Kotlin extension named ' + name + ' on ' + type(self)._kotlin_type_name ) - return getattr(self, target) + return _aliased(getattr(self, resolved[0]), resolved[1]) return getattr(self, name) def __repr__(self): @@ -1573,6 +1669,11 @@ object PythonxAdapter { if name[:1].isupper() and (qualified in _BY_RECEIVER or qualified in _PROXY_TYPES): # A type, not a declaration: `androidx.compose.ui.Modifier` is the receiver proxy. return _proxy_type(qualified), True + if qualified in _PACKAGES_SEEN: + # Issue #35: a child package or object, imported on first read, so + # `androidx.compose.ui.Alignment.End` needs no `import` of `Alignment` first. The import + # system would bind the same module onto this one after an explicit import anyway. + return _importlib.import_module(qualified), True return None, False @@ -1603,6 +1704,7 @@ object PythonxAdapter { def _module_dir(kotlin_package): def __dir__(): names = set(_BY_PACKAGE.get(kotlin_package, {})) + names.update(_CHILDREN.get(kotlin_package, ())) for type_name in _BY_RECEIVER: package, _, leaf = type_name.rpartition('.') if package == kotlin_package: diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt index 085b37595..23605a453 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt @@ -24,6 +24,7 @@ import python.multiplatform.reflection.ExposedCallable * |---|---| * | `KOTLIN_DEFAULT` | the `inspect.Parameter.default` of a parameter whose Kotlin declaration has a default. Passing it (or leaving the parameter out) means "Kotlin's own default" | * | `describe(fn)` | the declaration(s) behind a binder-made callable: a tuple of dicts, one per overload (see [SOURCE]) | + * | `describe(module, name)` | the declaration(s) a bound name of a Kotlin-named module stands for, read from the table and never by reading the name | * | `kotlin_function(raw, row)` | what [PythonProxySource] wraps each rendered function in: keyword arguments by Kotlin name, omitted defaults, and the metadata above | * | `signature_of(rows)` | the `inspect.Signature` for one declaration (`(*args, **kwargs)` for an overload set) | * @@ -55,6 +56,23 @@ import python.multiplatform.reflection.ExposedCallable * 'has_default': False, 'value_class': False, 'composable_lambda': False}, * ...)} * + * `python_multiplatform.describe(module, name)` describes **any** bound name of a Kotlin-named + * module -- a function, an overload set's base name, an explicit `name__Types` spelling, and a + * named constant (`kind: 'STATIC_GETTER'`, `returns` its declared type, no parameters) -- **without + * evaluating it**: a constant's Kotlin getter is never invoked. One declaration answers one dict; + * an overload set's base name answers the tuple `describe(fn)` gives for it. A name that is not a + * bound declaration (a child package, a typo) raises `AttributeError`; `describe(fn)` with one + * argument is unchanged. So `describe(androidx.compose.ui.Alignment, 'End')['returns']` is + * `'androidx.compose.ui.Alignment.Horizontal'`. + * + * ### Modules list and serve their children + * + * A Kotlin-named module's `dir()` lists its bound declarations, the receiver types under it, and + * its **direct child packages and objects** (an object whose members are bound, `Alignment`, is a + * package here). Each under its Kotlin name. Reading a child as an attribute imports it, so + * `androidx.compose.ui.Alignment.End` works after `import androidx` alone; a name nothing is bound + * under stays an `AttributeError`. + * * `value_class` is true for a parameter whose marshalling tag is a primitive while its declared * type is not a Kotlin primitive (`Dp`, `Color`, `TextUnit`) -- the only machine-checkable form of * "this is a value class". `composable_lambda` is true for a `@Composable` function-typed slot. @@ -67,13 +85,22 @@ import python.multiplatform.reflection.ExposedCallable * python_multiplatform.binding.add_member_resolver(fn) * python_multiplatform.binding.remove_member_resolver(fn) * - * fn(kotlin_type_name, requested_name, kotlin_member_names) -> kotlin_name | None + * fn(kotlin_type_name, requested_name, kotlin_member_names) + * -> kotlin_name | (kotlin_name, keyword_map) | None * * - `fn` is asked only when the proxy has **no** Kotlin member named `requested_name`; a Kotlin name * never reaches it. `kotlin_member_names` is a tuple of the Kotlin member names the type has. * - The first resolver (in registration order) returning a name that is in `kotlin_member_names` wins, * and that Kotlin member is served. Any other answer (`None`, an unknown name, a name starting with * `_`) means "not mine"; with no resolver answering, the result is the usual `AttributeError`. + * - **Keyword maps.** An answer may be `(kotlin_name, keyword_map)`, `keyword_map` being + * `{python_kw: kotlinParam}`: it is applied when that member is called, and keywords it does not + * name pass through as Kotlin parameter names (an unknown one is still the binding layer's + * `TypeError`, which lists the Kotlin parameters). For a member the proxy *does* have under its + * Kotlin name (`padding`), resolvers are asked only when a call to it passes keyword arguments, and + * only for its keyword map: an answer counts when it is `(that same name, keyword_map)`; a plain + * name or `None` means no map. A call with no keywords never asks. A tuple of any other shape is a + * `TypeError`. * - The binder renames nothing itself: with no resolver registered behaviour is exactly the * Kotlin-names-only behaviour above. * - Answers are cached in the resolver registry (cleared when a resolver is added or removed, and @@ -224,19 +251,56 @@ object KotlinSurface { } - def describe(fn): + _NO_NAME = object() + + + def describe(fn, name=_NO_NAME): '''The Kotlin declaration(s) behind [fn]: a tuple of dicts, one per overload. [fn] is anything the binder put on a Kotlin-named module -- a function the proxy layer rendered, a callable or overload set the binding layer adapted, or an extension already applied to its receiver (`Modifier.padding`). See `KotlinSurface` for the dict's keys. + + `describe(module, name)` describes the bound Kotlin name [name] of a Kotlin-named + [module] without reading it -- a `STATIC_GETTER` (`Alignment.End`) included, whose getter + is never run. One declaration answers one dict; an overload set's base name answers the + same tuple `describe(fn)` gives for it. ''' + if name is not _NO_NAME: + return _describe_named(fn, name) rows = getattr(fn, '__kotlin_rows__', None) if rows is None: raise TypeError(repr(fn) + ' is not a Kotlin declaration the binder exposed') return tuple(_describe_row(row) for row in rows) + def _describe_named(module, name): + if not isinstance(module, type(_sys)): + raise TypeError('describe(module, name) takes a Kotlin-named module, not ' + repr(module)) + if not isinstance(name, str): + raise TypeError('describe(module, name): name must be a str') + rows = None + binding = _sys.modules.get(BINDING_MODULE) + if binding is not None: + rows = binding._rows_named(module.__name__, name) + if rows is None: + # The proxy layer's own, read without touching the attribute: a static property's row + # is kept on the module's type (`PythonProxySource._pm_kotlin_rows`), and a rendered + # function is found by `getattr_static`, which runs no descriptor -- so a constant's + # Kotlin getter is never invoked here. + row = (getattr(type(module), '_pm_kotlin_rows', None) or {}).get(name) + if row is not None: + rows = (row,) + else: + rows = getattr(_inspect.getattr_static(module, name, None), '__kotlin_rows__', None) + if not rows: + raise AttributeError( + "module '" + module.__name__ + "' has no bound Kotlin declaration named '" + name + "'" + ) + described = tuple(_describe_row(row) for row in rows) + return described[0] if len(described) == 1 else described + + _GENERIC = _inspect.Signature([ _inspect.Parameter('args', _inspect.Parameter.VAR_POSITIONAL), _inspect.Parameter('kwargs', _inspect.Parameter.VAR_KEYWORD), diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt index d9bae659d..88599adb6 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt @@ -427,6 +427,20 @@ object PythonProxySource { return _t + def _pm_kotlin_rows(_mod, _name, _row): + # The table row of the static property `_pm_static_property` just put on [_mod]'s type, + # kept beside it on that type (`_pm_kotlin_rows`, name -> row) so that + # `python_multiplatform.describe(module, name)` can describe a constant without reading + # it (issue #36). Beside the descriptor rather than on it: the read path stays a plain + # `property`, which is what `GeneratedProxyCostTest` prices. + _t = _pm_module_type(_mod) + _rows = _t.__dict__.get('_pm_kotlin_rows') + if _rows is None: + _rows = {} + _t._pm_kotlin_rows = _rows + _rows[_name] = _row + + def _pm_static_property(_mod, _name, _get, _set): # A `property` -- a *data* descriptor -- rather than a `__getattr__`/`__setattr__` pair # on a shared subclass, which is what this used to be. Both are correct; they are not @@ -1030,7 +1044,8 @@ object PythonProxySource { if (setter != null) appendLine("$setterHandle = _pm_lookup(${setter.name.quoted()})") append( "_pm_static_property(_pm_module(${module.quoted()}), ${leaf.quoted()}, " + - "$getterHandle, $setterHandle)", + "$getterHandle, $setterHandle)\n" + + "_pm_kotlin_rows(_pm_module(${module.quoted()}), ${leaf.quoted()}, ${KotlinSurface.row(getter)})", ) } return source to index diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/BinderNamespaceTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/BinderNamespaceTest.kt new file mode 100644 index 000000000..c67bb1b31 --- /dev/null +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/BinderNamespaceTest.kt @@ -0,0 +1,227 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.bindUpcallOrNull +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a Kotlin-named module offers to something that **discovers** it rather than knowing its + * names in advance -- a completion list, an IDE, and pythonx-compose building its grouping rules. + * + * - Issue #35: a module's `dir()` lists its direct child packages and objects (Kotlin names), and + * reading one as an attribute imports it, so `androidx.compose.foundation.layout.Arrangement.End` + * works after nothing more than `import androidx`. + * - Issue #36: `python_multiplatform.describe(module, name)` describes any bound name, a + * `STATIC_GETTER` included, **without reading it** -- reading a constant runs Kotlin, and a + * description must not. + * + * `ComposeShapedFragment` puts `Arrangement.Start`/`.End` under the package + * `androidx.compose.foundation.layout.Arrangement`, which is exactly the shape the walker gives + * `Alignment.End`: an object is a package whose members are `STATIC_GETTER`s. + */ +class BinderNamespaceTest { + + @BeforeTest + fun install() { + check(PythonTestFixture.available) { + "CPython could not be initialized (${PythonTestFixture.failureReason})" + } + UpcallTable.install(listOf(ComposeShapedFragment)) + ComposeShapedFragment.calls.clear() + assertTrue(bindUpcallOrNull(ComposeShapedFragment.EMPTY_MODIFIER), "the fixture table is not installed") + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + // ------------------------------------------------------------------------------- #35 + + @Test + fun dirOfAModuleListsItsDirectChildPackagesAndObjects() = withAdapterOnly { + Python3.exec( + """ + import androidx + import androidx.compose.foundation.layout as _bn_layout + import androidx.compose.ui as _bn_ui + _bn = { + 'root': 'compose' in dir(androidx), + 'object': 'Arrangement' in dir(_bn_layout), + 'still_declarations': 'padding' in dir(_bn_layout) and 'fillMaxWidth' in dir(_bn_layout), + 'ui_children': sorted(_n for _n in ('draw', 'util') if _n in dir(_bn_ui)), + 'only_direct': 'layout' not in dir(androidx) and 'Arrangement' not in dir(androidx.compose), + 'no_snake': [_n for _n in dir(_bn_layout) if '_' in _n.strip('_') and '__' not in _n], + } + """.trimIndent(), + ) + assertEquals("True", eval("_bn['root']"), "dir(androidx) does not list 'compose'") + assertEquals("True", eval("_bn['object']"), "dir(androidx.compose.foundation.layout) does not list 'Arrangement'") + assertEquals("True", eval("_bn['still_declarations']")) + assertEquals("['draw', 'util']", eval("repr(_bn['ui_children'])")) + assertEquals("True", eval("_bn['only_direct']"), "dir() must list direct children only") + assertEquals("[]", eval("repr(_bn['no_snake'])"), "dir() must show Kotlin names only") + } + + @Test + fun aChildPackageOrObjectIsReachableAsAnAttributeWithoutAPriorImport() = withAdapterOnly { + Python3.exec( + """ + import androidx + _bn_end = androidx.compose.foundation.layout.Arrangement.End + from androidx.compose.foundation.layout import describeHorizontal + _bn = { + 'end': describeHorizontal(_bn_end), + 'module': androidx.compose.foundation.layout.Arrangement.__name__, + } + try: + androidx.compose.noSuchPackage + _bn['unknown'] = 'resolved' + except AttributeError: + _bn['unknown'] = 'AttributeError' + """.trimIndent(), + ) + assertEquals("End", eval("_bn['end']")) + assertEquals("androidx.compose.foundation.layout.Arrangement", eval("_bn['module']")) + assertEquals("AttributeError", eval("_bn['unknown']"), "a name nothing is bound under must stay an AttributeError") + } + + // ------------------------------------------------------------------------------- #36 + + /** The issue's completion statement, in every install order, and with the getter never run. */ + @Test + fun describeOfANamedConstantGivesItsDeclarationWithoutReadingIt() { + for (order in listOf("adapter-only", "proxy-only", "adapter-then-proxy", "proxy-then-adapter")) { + PythonTestFixture.withInterpreter { + when (order) { + "adapter-only" -> { dropAndroidxModules(); installAdapter() } + "proxy-only" -> { dropAndroidxModules(); PythonProxySource.install() } + "adapter-then-proxy" -> { installAdapter(); PythonProxySource.install() } + "proxy-then-adapter" -> { PythonProxySource.install(); installAdapter() } + } + ComposeShapedFragment.calls.clear() + val run = { + Python3.exec( + """ + import importlib as _bn_importlib + import python_multiplatform as _bn_pm + _bn_arr = _bn_importlib.import_module('androidx.compose.foundation.layout.Arrangement') + _bn_d = _bn_pm.describe(_bn_arr, 'End') + _bn = { + 'kind': _bn_d['kind'], + 'returns': _bn_d['returns'], + 'name': _bn_d['name'], + 'parameters': _bn_d['parameters'], + } + """.trimIndent(), + ) + } + if (order == "proxy-only") withoutBindingLayer(run) else run() + assertEquals(emptyList(), ComposeShapedFragment.calls, "$order: describe() invoked the getter") + assertEquals("STATIC_GETTER", eval("_bn['kind']"), order) + assertEquals("androidx.compose.foundation.layout.Arrangement.Horizontal", eval("_bn['returns']"), order) + assertEquals("androidx.compose.foundation.layout.Arrangement.End", eval("_bn['name']"), order) + assertEquals("()", eval("repr(_bn['parameters'])"), order) + } + } + } + + /** Any bound name, not only constants: one declaration is one dict, an overload set a tuple. */ + @Test + fun describeByNameCoversFunctionsAndOverloadSetsAndLeavesTheOneArgumentFormAlone() = withAdapterOnly { + Python3.exec( + """ + import python_multiplatform as _bn_pm + import androidx.compose.foundation.layout as _bn_layout + _bn_one = _bn_pm.describe(_bn_layout, 'padding__Dp') + _bn_set = _bn_pm.describe(_bn_layout, 'padding') + _bn = { + 'one': (_bn_one['name'], _bn_one['kind'], [_p['name'] for _p in _bn_one['parameters']]), + 'set': sorted(_d['name'] for _d in _bn_set), + 'set_type': type(_bn_set).__name__, + 'same_as_fn': _bn_pm.describe(_bn_layout.padding__Dp) == (_bn_one,), + } + for _bn_bad in ('noSuchThing', 'Arrangement'): + try: + _bn_pm.describe(_bn_layout, _bn_bad) + _bn[_bn_bad] = 'described' + except AttributeError as _bn_e: + _bn[_bn_bad] = 'AttributeError' + try: + _bn_pm.describe(42) + _bn['callable_rule'] = 'described' + except TypeError: + _bn['callable_rule'] = 'TypeError' + """.trimIndent(), + ) + assertEquals( + "('androidx.compose.foundation.layout.padding__Dp', 'FUNCTION', ['all'])", + eval("repr(_bn['one'])"), + ) + assertEquals("tuple", eval("_bn['set_type']")) + assertEquals( + "['androidx.compose.foundation.layout.padding__Dp', 'androidx.compose.foundation.layout.padding__Dp_Dp', " + + "'androidx.compose.foundation.layout.padding__Dp_Dp_Dp_Dp', 'androidx.compose.foundation.layout.padding__PaddingValues']", + eval("repr(_bn['set'])"), + ) + assertEquals("True", eval("_bn['same_as_fn']"), "the one-argument form must answer the same rows") + assertEquals("AttributeError", eval("_bn['noSuchThing']")) + assertEquals("AttributeError", eval("_bn['Arrangement']"), "a child package is not a declaration") + assertEquals("TypeError", eval("_bn['callable_rule']"), "the one-argument form still takes binder callables only") + } + + // ------------------------------------------------------------------------------- helpers + + private fun installAdapter() { + PythonxAdapter.install(COMPOSE_SHAPED_RAW_VALUE_CLASSES) + Python3.exec( + "import python_multiplatform.binding as _bn_b\n" + + "_bn_b.register_empty('androidx.compose.ui.Modifier', '${ComposeShapedFragment.EMPTY_MODIFIER}')", + ) + } + + /** + * The binding layer on modules nobody has imported yet: an earlier test in this interpreter may + * have imported (and so attached as attributes) the very children these tests must reach + * without an import. + */ + private inline fun withAdapterOnly(block: () -> Unit) = PythonTestFixture.withInterpreter { + dropAndroidxModules() + installAdapter() + block() + } + + private fun dropAndroidxModules() = Python3.exec( + """ + import sys as _bn_sys + for _bn_n in [_n for _n in _bn_sys.modules if _n == 'androidx' or _n.startswith('androidx.')]: + del _bn_sys.modules[_bn_n] + """.trimIndent(), + ) + + private inline fun withoutBindingLayer(block: () -> Unit) { + Python3.exec( + "import sys as _bn_sys\n" + + "_bn_saved = _bn_sys.modules.pop('python_multiplatform.binding', None)", + ) + try { + block() + } finally { + Python3.exec( + "if _bn_saved is not None:\n" + + " _bn_sys.modules['python_multiplatform.binding'] = _bn_saved", + ) + } + } + + private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() +} diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt index 5b17e1273..0bac2e693 100644 --- a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt @@ -188,8 +188,120 @@ class MemberResolverTest { assertEquals("AttributeError", eval("_mr['after']")) } + // ------------------------------------------------------------------------------- keyword maps (#34) + + /** + * Issue #34's completion statement: `m.padding(padding_values=...)`. `padding` is a Kotlin name, + * so the resolver is asked for it only for its keyword map -- and only because the call passed + * a keyword. + */ + @Test + fun aKeywordMapRenamesTheKeywordsOfAKotlinNamedMember() = withAdapter { + withKeywordResolver { + Python3.exec( + """ + from androidx.compose.ui import Modifier, describeModifier + from androidx.compose.foundation.layout import paddingValuesOf + _mr = { + 'instance': describeModifier(Modifier.padding(16).padding(padding_values=paddingValuesOf(4))), + 'class': describeModifier(Modifier.padding(padding_values=paddingValuesOf(2))), + 'kotlin_kw_still_works': describeModifier(Modifier.padding(paddingValues=paddingValuesOf(1))), + 'asked_for_padding': [c for c in _mr_calls if c[1] == 'padding'] != [], + } + """.trimIndent(), + ) + assertEquals("padding(16.0) -> padding(pv(4.0))", eval("_mr['instance']")) + assertEquals("padding(pv(2.0))", eval("_mr['class']")) + assertEquals("padding(pv(1.0))", eval("_mr['kotlin_kw_still_works']")) + assertEquals("True", eval("_mr['asked_for_padding']")) + } + } + + /** An alias answer can carry a map too: the map applies to that alias's call. */ + @Test + fun aKeywordMapRidesAlongWithAnAlias() = withAdapter { + withKeywordResolver { + Python3.exec( + """ + from androidx.compose.ui import Modifier, describeModifier + _mr = {'chain': describeModifier(Modifier.padding(1).fill_max_height(frac=0.5))} + """.trimIndent(), + ) + assertEquals("padding(1.0) -> fillMaxHeight(f=0.5)", eval("_mr['chain']")) + } + } + + /** A keyword neither the map nor Kotlin knows is refused with the Kotlin parameter names. */ + @Test + fun anUnknownKeywordStillRaisesTypeErrorNamingTheKotlinParameters() = withAdapter { + withKeywordResolver { + Python3.exec( + """ + from androidx.compose.ui import Modifier + _mr = {} + for _label, _call in ( + ('overloads', lambda: Modifier.padding(1).padding(bogus_kw=1)), + ('single', lambda: Modifier.padding(1).fill_max_height(bogus_kw=1)), + ): + try: + _call() + _mr[_label] = 'accepted' + except TypeError as _e: + _mr[_label] = str(_e) + """.trimIndent(), + ) + val overloads = eval("_mr['overloads']") + assertTrue("paddingValues" in overloads && "horizontal" in overloads, overloads) + val single = eval("_mr['single']") + assertTrue("fraction" in single && "bogus_kw" in single, single) + } + } + + /** No resolver: a Python-spelled keyword on a member is refused exactly as before. */ + @Test + fun withNoResolverAKeywordIsNotMapped() = withAdapter { + Python3.exec( + """ + from androidx.compose.ui import Modifier + from androidx.compose.foundation.layout import paddingValuesOf + try: + Modifier.padding(1).padding(padding_values=paddingValuesOf(4)) + _mr = {'outcome': 'accepted'} + except TypeError: + _mr = {'outcome': 'TypeError'} + """.trimIndent(), + ) + assertEquals("TypeError", eval("_mr['outcome']")) + } + // ------------------------------------------------------------------------------- helpers + /** + * Answers `padding` with its Kotlin name plus a keyword map, and `fill_max_height` with an alias + * plus one; records every question. + */ + private inline fun withKeywordResolver(block: () -> Unit) { + Python3.exec( + """ + import python_multiplatform.binding as _b + _mr_calls = [] + def _kw_resolver(type_name, requested, kotlin_names): + _mr_calls.append((type_name, requested, tuple(kotlin_names))) + if requested == 'padding': + return ('padding', {'padding_values': 'paddingValues'}) + if requested == 'fill_max_height': + return ('fillMaxHeight', {'frac': 'fraction'}) + return None + _b.add_member_resolver(_kw_resolver) + """.trimIndent(), + ) + try { + block() + } finally { + Python3.exec("_b.remove_member_resolver(_kw_resolver)") + } + } + /** `fill_max_width` -> `fillMaxWidth`, and nothing else: the consumer's rule, not the binder's. */ private inline fun withResolver(recording: Boolean = false, block: () -> Unit) { Python3.exec(