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() +}