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

Filter by extension

Filter by extension

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

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ object PythonxAdapter {
_invalidate()
_BY_PACKAGE.clear()
_BY_RECEIVER.clear()
_MEMBER_ALIASES.clear()
_PACKAGES_SEEN.clear()
_SUPERTYPES.clear()
present = set()
Expand Down Expand Up @@ -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.'''

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading