From e0f14e41b71f8246383b48b61908960b69206fb0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:14:59 +0900 Subject: [PATCH] Feat: Let a library resolve member names on Kotlin proxies python_multiplatform.binding.add_member_resolver(fn) is asked only when a proxy has no Kotlin member of the requested name; fn returns the Kotlin member to serve, or None. The binder still renames nothing: with no resolver the surface is Kotlin names only, and resolved aliases are cached in the resolver registry, never written onto proxy classes, so dir() and the class dict stay Kotlin. This is the hook pythonx-compose needs for chains like Modifier.padding(16).fill_max_width(). --- docs/SPEC.md | 7 + .../fixture/compose/ModifierComparisons.kt | 17 ++ .../compose/MemberResolverComposeTest.kt | 78 ++++++ .../ffi/pythonx/PythonxAdapter.kt | 65 ++++- .../multiplatform/ffi/upcall/KotlinSurface.kt | 22 ++ .../ffi/pythonx/MemberResolverTest.kt | 226 ++++++++++++++++++ 6 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt create mode 100644 ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt create mode 100644 python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 539a84a03..1be46730c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -178,6 +178,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. 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`. +- **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. + 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`, + `ksp-fixtures/compose/.../MemberResolverComposeTest.kt`. ## 6. Binding prebuilt libraries (Gradle plugin) diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt new file mode 100644 index 000000000..f22e3be14 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt @@ -0,0 +1,17 @@ +package fixture.compose + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * What `MemberResolverComposeTest` compares a Python-built chain against. Compose's own structural + * equality, so the chain cannot match unless Compose's `padding` and `fillMaxWidth` really ran. + */ +fun equalsPaddingThenFillMaxWidth(modifier: Modifier, pad: Double): Boolean = + modifier == Modifier.padding(pad.dp).fillMaxWidth() + +/** The negative control: the same members in the other order is a different chain. */ +fun equalsFillMaxWidthThenPadding(modifier: Modifier, pad: Double): Boolean = + modifier == Modifier.fillMaxWidth().padding(pad.dp) diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt new file mode 100644 index 000000000..d02a3b03f --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt @@ -0,0 +1,78 @@ +package fixture.compose + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import python.multiplatform.ffi.upcall.UpcallBootstrap +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Issue #17 on the real thing: a `Modifier` chain built from Compose's own jars, with the second link + * reached under a name only a registered member resolver knows. + * + * Compose's structural equality is the judge, so `fill_max_width` can only match if the resolver + * served Compose's real `fillMaxWidth`. The controls: no resolver means `AttributeError`, and the same + * members in the other order do not match. + */ +class MemberResolverComposeTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aResolvedSnakeNameRunsComposesFillMaxWidthInARealChain() { + Python3.exec( + """ + import python_multiplatform.binding as _b + from androidx.compose.foundation.layout import padding__Dp + from fixture.compose import emptyModifier + from fixture.compose import equalsPaddingThenFillMaxWidth, equalsFillMaxWidthThenPadding + + _start = padding__Dp(emptyModifier(), 16.0) + try: + _start.fill_max_width + raise AssertionError('fill_max_width resolved with no resolver registered') + except AttributeError: + pass + + def _snake_to_camel(type_name, requested, kotlin_names): + head, *rest = requested.split('_') + camel = head + ''.join(p.capitalize() for p in rest) + return camel if camel in kotlin_names else None + + _b.add_member_resolver(_snake_to_camel) + try: + _chain = _start.fill_max_width() + assert equalsPaddingThenFillMaxWidth(_chain._pm_handle, 16.0), 'chain is not padding(16.dp).fillMaxWidth()' + assert not equalsPaddingThenFillMaxWidth(_chain._pm_handle, 17.0), '16dp matched 17dp' + assert not equalsFillMaxWidthThenPadding(_chain._pm_handle, 16.0), 'the chain order is not observed' + assert 'fill_max_width' not in dir(_chain), 'the alias leaked into dir()' + assert 'fill_max_width' not in vars(type(_chain)), 'the alias was written onto the proxy class' + finally: + _b.remove_member_resolver(_snake_to_camel) + try: + _start.fill_max_width + raise AssertionError('the alias survived removing the resolver') + except AttributeError: + pass + """.trimIndent(), + ) + } +} 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 1dc86bcbb..bdb2cd62b 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 @@ -303,6 +303,7 @@ object PythonxAdapter { _invalidate() _BY_PACKAGE.clear() _BY_RECEIVER.clear() + _MEMBER_ALIASES.clear() _PACKAGES_SEEN.clear() _SUPERTYPES.clear() present = set() @@ -895,6 +896,52 @@ object PythonxAdapter { _EMPTY_FACTORIES[kotlin_type_name] = kotlin_factory_name + _MEMBER_RESOLVERS = [] + _MEMBER_ALIASES = {} # (kotlin receiver type, requested name) -> kotlin member name + + + 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. + ''' + 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() + return fn + + + def remove_member_resolver(fn): + '''Unregisters a resolver added by `add_member_resolver`; unknown functions are ignored.''' + if fn in _MEMBER_RESOLVERS: + _MEMBER_RESOLVERS.remove(fn) + _MEMBER_ALIASES.clear() + + + def _resolve_member(cls, name): + '''The Kotlin member name a registered resolver maps [name] to on [cls], or `None`.''' + if not _MEMBER_RESOLVERS: + return None + key = (cls._kotlin_type_name, name) + cached = _MEMBER_ALIASES.get(key) + if cached is not None: + return cached + 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) + if target and target in members and not target.startswith('_'): + _MEMBER_ALIASES[key] = target + return target + return None + + class _BoundMember: '''An extension applied to a receiver: literally the module-level callable with slot 0 filled.''' @@ -946,9 +993,12 @@ object PythonxAdapter { if name.startswith('_'): raise AttributeError(name) if not _attach(cls, name): - raise AttributeError( - 'no Kotlin extension named ' + name + ' on ' + cls._kotlin_type_name - ) + target = _resolve_member(cls, name) + if target is None: + raise AttributeError( + 'no Kotlin extension named ' + name + ' on ' + cls._kotlin_type_name + ) + return getattr(cls, target) return getattr(cls, name) def __repr__(cls): @@ -989,9 +1039,12 @@ object PythonxAdapter { if name.startswith('_'): raise AttributeError(name) if not _attach(type(self), name): - raise AttributeError( - 'no Kotlin extension named ' + name + ' on ' + type(self)._kotlin_type_name - ) + target = _resolve_member(type(self), name) + if target is None: + raise AttributeError( + 'no Kotlin extension named ' + name + ' on ' + type(self)._kotlin_type_name + ) + return getattr(self, target) return getattr(self, name) def __repr__(self): 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 42d5ddb86..085b37595 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 @@ -59,6 +59,28 @@ import python.multiplatform.reflection.ExposedCallable * 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. * + * ### Member resolvers: the one hook for names on a proxy + * + * A proxy the binder returns (`Modifier.padding(16)` is one) serves its Kotlin members under their + * Kotlin names only. A Pythonic package built on the binder can say what else a member may be called: + * + * 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` 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`. + * - 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 + * when a table is registered), never written onto a proxy class: `dir()` of a proxy and the class + * `__dict__` show Kotlin names only. Removing a resolver removes its aliases. + * - Registering the same function twice is a no-op. Resolvers are process-wide, like the binding layer. + * * ### `__signature__` is lazy * * [PythonProxySource] renders every entry eagerly, and building an `inspect.Signature` for each of 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 new file mode 100644 index 000000000..5b17e1273 --- /dev/null +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt @@ -0,0 +1,226 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +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 + +/** + * `python_multiplatform.binding.add_member_resolver` (issue #17): the one public point where a + * Pythonic package built on the binder (pythonx-compose) can say what a Kotlin proxy's member is + * called, without the binder renaming anything itself. + * + * Contract under test: + * + * - With no resolver registered, an unknown name on a proxy is an `AttributeError`, exactly as before. + * - `fn(kotlin_type_name, requested_name, kotlin_member_names) -> kotlin_name | None` is asked only + * when no Kotlin member of the requested name exists; the Kotlin member it names is served. + * - The binder's proxy classes are not written to: `dir()` and the class `__dict__` show Kotlin names only. + */ +class MemberResolverTest { + + @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() + } + + @Test + fun withNoResolverASnakeNameIsAnAttributeError() = withAdapter { + Python3.exec( + """ + from androidx.compose.ui import Modifier + _mr = {} + for _label, _target in (('instance', Modifier.padding(16)), ('class', Modifier)): + try: + _target.fill_max_width + _mr[_label] = 'resolved' + except AttributeError: + _mr[_label] = 'AttributeError' + """.trimIndent(), + ) + assertEquals("AttributeError", eval("_mr['instance']")) + assertEquals("AttributeError", eval("_mr['class']")) + } + + @Test + fun aResolvedNameIsReachableThroughAChainAndRunsTheKotlinMember() = withAdapter { + withResolver { + Python3.exec( + """ + from androidx.compose.ui import Modifier, describeModifier + _mr = { + 'chain': describeModifier(Modifier.padding(16).fill_max_width().size(24)), + 'from_class': describeModifier(Modifier.fill_max_width().z_index(2)), + 'kotlin': describeModifier(Modifier.padding(16).fillMaxWidth()), + } + """.trimIndent(), + ) + assertEquals("padding(16.0) -> fillMaxWidth -> size(24.0)", eval("_mr['chain']")) + assertEquals("fillMaxWidth -> zIndex(2.0)", eval("_mr['from_class']")) + assertEquals("padding(16.0) -> fillMaxWidth", eval("_mr['kotlin']")) + assertEquals( + listOf("padding__Dp", "fillMaxWidth", "size__Dp", "fillMaxWidth", "zIndex", "padding__Dp", "fillMaxWidth"), + ComposeShapedFragment.calls, + ) + } + } + + @Test + fun theResolverIsAskedWithTheKotlinTypeTheRequestedNameAndTheKotlinMemberNames() = withAdapter { + withResolver(recording = true) { + Python3.exec( + """ + from androidx.compose.ui import Modifier + Modifier.padding(1).fill_max_width() + _call = _mr_calls[0] + _mr = { + 'type': _call[0], + 'requested': _call[1], + 'has_kotlin_names': 'fillMaxWidth' in _call[2] and 'padding' in _call[2], + 'no_snake': 'fill_max_width' not in _call[2], + } + """.trimIndent(), + ) + assertEquals("androidx.compose.ui.Modifier", eval("_mr['type']")) + assertEquals("fill_max_width", eval("_mr['requested']")) + assertEquals("True", eval("_mr['has_kotlin_names']")) + assertEquals("True", eval("_mr['no_snake']")) + } + } + + @Test + fun theResolverIsNotAskedForANameKotlinHasAndNoneMeansAttributeError() = withAdapter { + withResolver(recording = true) { + Python3.exec( + """ + from androidx.compose.ui import Modifier + Modifier.padding(1).fillMaxWidth() + _asked_for_kotlin = [c[1] for c in _mr_calls] + try: + Modifier.padding(1).no_such_thing + _outcome = 'resolved' + except AttributeError: + _outcome = 'AttributeError' + _mr = {'asked': _asked_for_kotlin, 'outcome': _outcome} + """.trimIndent(), + ) + assertEquals("[]", eval("repr(_mr['asked'])"), "a Kotlin name must never reach the resolver") + assertEquals("AttributeError", eval("_mr['outcome']")) + } + } + + @Test + fun aResolverNamingAMemberTheTypeDoesNotHaveServesNothing() = withAdapter { + Python3.exec( + """ + import python_multiplatform.binding as _b + def _liar(type_name, requested, kotlin_names): + return 'notAKotlinMember' + _b.add_member_resolver(_liar) + try: + from androidx.compose.ui import Modifier + try: + Modifier.padding(1).anything + _mr = {'outcome': 'resolved'} + except AttributeError: + _mr = {'outcome': 'AttributeError'} + finally: + _b.remove_member_resolver(_liar) + """.trimIndent(), + ) + assertEquals("AttributeError", eval("_mr['outcome']")) + } + + @Test + fun dirAndTheProxyClassStayKotlinOnlyAndAResolverOnlyAffectsItsOwnRegistry() = withAdapter { + Python3.exec( + """ + from androidx.compose.ui import Modifier + _before_instance = dir(Modifier.padding(1)) + _before_class = sorted(vars(type(Modifier.padding(1)))) + """.trimIndent(), + ) + withResolver { + Python3.exec( + """ + _m = Modifier.padding(1).fill_max_width() + _mr = { + 'dir_same': dir(Modifier.padding(1)) == _before_instance or + sorted(set(dir(Modifier.padding(1))) - set(_before_instance)) == ['fillMaxWidth'], + 'no_alias_in_dir': 'fill_max_width' not in dir(_m), + 'no_alias_in_class': 'fill_max_width' not in vars(type(_m)), + 'class_dict_same': sorted(set(vars(type(_m))) - set(_before_class)) == ['fillMaxWidth'], + } + """.trimIndent(), + ) + assertEquals("True", eval("_mr['no_alias_in_dir']")) + assertEquals("True", eval("_mr['no_alias_in_class']")) + assertEquals("True", eval("_mr['class_dict_same']")) + assertEquals("True", eval("_mr['dir_same']")) + } + // Removing the only resolver takes the alias with it: nothing was left on the class. + Python3.exec( + """ + try: + Modifier.padding(1).fill_max_width + _mr = {'after': 'still resolved'} + except AttributeError: + _mr = {'after': 'AttributeError'} + """.trimIndent(), + ) + assertEquals("AttributeError", eval("_mr['after']")) + } + + // ------------------------------------------------------------------------------- helpers + + /** `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( + """ + import python_multiplatform.binding as _b + _mr_calls = [] + def _snake_to_camel(type_name, requested, kotlin_names): + if ${if (recording) "True" else "False"}: + _mr_calls.append((type_name, requested, tuple(kotlin_names))) + head, *rest = requested.split('_') + camel = head + ''.join(p.capitalize() for p in rest) + return camel if camel in kotlin_names else None + _b.add_member_resolver(_snake_to_camel) + """.trimIndent(), + ) + try { + block() + } finally { + Python3.exec("_b.remove_member_resolver(_snake_to_camel)") + } + } + + private inline fun withAdapter(block: () -> Unit) = PythonTestFixture.withInterpreter { + PythonxAdapter.install(COMPOSE_SHAPED_RAW_VALUE_CLASSES) + Python3.exec( + "import python_multiplatform.binding as _pm_binding\n" + + "_pm_binding.register_empty('androidx.compose.ui.Modifier', " + + "'${ComposeShapedFragment.EMPTY_MODIFIER}')", + ) + block() + } + + private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() +}