Skip to content

Commit 4bfeeeb

Browse files
authored
Merge pull request #223 from thisisthepy/feat/sam-params
Fill functional-interface parameters (Runnable, fun interface) with a Python callable (#216)
2 parents a6e1730 + 6b851a5 commit 4bfeeeb

8 files changed

Lines changed: 414 additions & 5 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,17 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
405405
`Status: partial`, `python-multiplatform-gradle-plugin/fixtures/artifact/.../WalkedArtifactCallbackTest.kt`; a value-returning slot
406406
(`() -> Float`) does not yet accept a Python callable (`M3ProofRenderTest.kt`
407407
`aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable`).
408+
A parameter typed as a **functional interface** (a Java interface with one abstract method, such as
409+
`java.lang.Runnable`, or a Kotlin `fun interface`) whose method returns `Unit`/`void` and which is
410+
not generic takes a Python callable as well as a Kotlin handle (#216). Its slot type name is the
411+
interface, `|`, and the equivalent `kotlin.FunctionN` (`java.lang.Runnable|kotlin.Function0()->kotlin.Unit`):
412+
the binding layer makes a callable into that function type and checks a handle against the
413+
interface, and generated source converts a wrapper with a SAM lambda (`java.lang.Runnable { f() }`)
414+
and casts anything else, with no reflection. A JDK interface is read from the JDK the build runs
415+
on, any other from the consumer's jars. The stub shows the slot as a `Callable`. A functional
416+
interface whose method returns a value, or a generic one, is left as it was: a handle parameter
417+
(Compose's `Alignment` is one). `Status: implemented` on desktop, `GP/artifact/SamParameterBindingTest.kt`,
418+
`python-multiplatform-gradle-plugin/fixtures/artifact/.../WalkedArtifactSamParameterTest.kt`.
408419
- **B-6** `@Composable` functions are callable from Python inside a composition; a Python click reaches
409420
a Kotlin callback and the redraw shows it. `Status: implemented` on desktop,
410421
`python-multiplatform-compose/fixtures/compose/src/desktopTest/` (`ComposableRenderTest.kt`, `M3ProofRenderTest.kt`,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Walked by `:python-multiplatform-gradle-plugin-fixtures:artifact` (its `fixture.valueclass` include covers this
2+
// package): functional-interface parameters issue #216 fills with a Python callable.
3+
package fixture.valueclass.sam
4+
5+
/** A Kotlin `fun interface` that is handed two values. */
6+
fun interface Listener {
7+
fun onEvent(name: String, count: Int)
8+
}
9+
10+
/** The `CoroutineDispatcher.dispatch(context, block: Runnable)` shape, which runs the block at once. */
11+
fun runRunnable(block: Runnable) = block.run()
12+
13+
/** Hands the listener a name and its length. */
14+
fun notifyListener(listener: Listener, name: String) = listener.onEvent(name, name.length)
15+
16+
private var heard = ""
17+
18+
/** A listener Kotlin made, so the same slot is shown taking a Kotlin handle as well as a callable. */
19+
fun loggingListener(): Listener = Listener { name, count -> heard = "$name:$count" }
20+
21+
/** What [loggingListener]'s listener heard last. */
22+
fun lastHeard(): String = heard
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package fixture.artifact
2+
3+
import python.multiplatform.ffi.Python3
4+
import python.multiplatform.ffi.pythonx.PythonCallables
5+
import python.multiplatform.ffi.pythonx.PythonxAdapter
6+
import python.multiplatform.ffi.upcall.PythonProxySource
7+
import python.multiplatform.ffi.upcall.UpcallBootstrap
8+
import python.multiplatform.generated.FunctionTable
9+
import python.multiplatform.generated.artifacts.ArtifactTable
10+
import python.multiplatform.reflection.UpcallTable
11+
import kotlin.test.AfterTest
12+
import kotlin.test.BeforeTest
13+
import kotlin.test.Test
14+
15+
/**
16+
* Issue #216, end to end: a Python callable fills a `java.lang.Runnable` and a Kotlin `fun interface`
17+
* parameter of a walked declaration (`fixture.valueclass.sam`), and Kotlin runs it.
18+
*
19+
* Red before #216: `run_runnable` and `notify_listener` do not exist, because the walker declined both.
20+
*/
21+
class WalkedArtifactSamParameterTest {
22+
23+
@BeforeTest
24+
fun installBothProducers() {
25+
Python3.initialize(silent = true)
26+
UpcallTable.clear()
27+
UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
28+
check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
29+
PythonProxySource.install()
30+
PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
31+
}
32+
33+
@AfterTest
34+
fun cleanup() {
35+
UpcallTable.clear()
36+
}
37+
38+
@Test
39+
fun aPythonCallableRunsAsARunnable() {
40+
PythonCallables.withScope(PythonCallables.newScope()) {
41+
Python3.exec(
42+
"""
43+
from fixture.valueclass.sam import run_runnable
44+
_sam_ran = []
45+
run_runnable(lambda: _sam_ran.append('ran'))
46+
assert _sam_ran == ['ran'], 'the Runnable did not run the Python callable: ' + repr(_sam_ran)
47+
""".trimIndent(),
48+
)
49+
}
50+
}
51+
52+
@Test
53+
fun aPythonCallableRunsAsAFunInterfaceWithItsArguments() {
54+
PythonCallables.withScope(PythonCallables.newScope()) {
55+
Python3.exec(
56+
"""
57+
from fixture.valueclass.sam import notify_listener
58+
_sam_events = []
59+
notify_listener(lambda name, count: _sam_events.append((name, count)), 'hello')
60+
assert _sam_events == [('hello', 5)], repr(_sam_events)
61+
""".trimIndent(),
62+
)
63+
}
64+
}
65+
66+
@Test
67+
fun aKotlinImplementationStillFillsTheSameSlot() {
68+
Python3.exec(
69+
"""
70+
from fixture.valueclass.sam import notify_listener, logging_listener, last_heard
71+
notify_listener(logging_listener(), 'abc')
72+
assert last_heard() == 'abc:3', 'a Kotlin Listener handle did not reach the slot: ' + repr(last_heard())
73+
""".trimIndent(),
74+
)
75+
}
76+
}

‎python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ class WalkedArtifactTableTest {
7171
"fixture.valueclass.member.Tally.total=",
7272
"fixture.valueclass.member.Tally.twin",
7373
"fixture.valueclass.member.newSwitch",
74+
// `fixture.valueclass.sam` (issue #216): functional-interface parameters, filled with a Python
75+
// callable. `WalkedArtifactSamParameterTest` calls them.
76+
"fixture.valueclass.sam.Listener.onEvent",
77+
"fixture.valueclass.sam.lastHeard",
78+
"fixture.valueclass.sam.loggingListener",
79+
"fixture.valueclass.sam.notifyListener",
80+
"fixture.valueclass.sam.runRunnable",
7481
"fixture.valueclass.sumMeters",
7582
"junit.runner.BaseTestRunner.getFilteredTrace",
7683
"junit.runner.BaseTestRunner.getPreference__String",

‎python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt‎

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlin.metadata.KmType
66
import kotlin.metadata.KmVariance
77
import kotlin.metadata.MemberKind
88
import kotlin.metadata.Visibility
9+
import kotlin.metadata.isFunInterface
910
import kotlin.metadata.isNullable
1011
import kotlin.metadata.isSuspend
1112
import kotlin.metadata.isValue
@@ -587,7 +588,9 @@ internal object ArtifactScanner {
587588
// A zero-parameter member of a group -- there can be at most one, so this only ever has to
588589
// be distinct from the others, not descriptive.
589590
if (parts.isEmpty()) return "0"
590-
return parts.joinToString("_") { name ->
591+
return parts.joinToString("_") { slotName ->
592+
// A functional-interface slot (issue #216) is named by its interface, not by the callable form.
593+
val name = slotName.substringBefore(SAM_SEPARATOR)
591594
val chosen = if (qualified) name else name.substringAfterLast('.')
592595
chosen.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
593596
}
@@ -1759,6 +1762,9 @@ internal object ArtifactScanner {
17591762
val typeName: String? = null,
17601763
val boundary: BoundaryType? = null,
17611764
val declineReason: String? = null,
1765+
/** The type a stub shows for the slot when it is not the declared one: a functional
1766+
* interface's `kotlin.FunctionN` (issue #216). */
1767+
val modelType: KotlinTypeModel? = null,
17621768
)
17631769

17641770
/**
@@ -1851,6 +1857,142 @@ internal object ArtifactScanner {
18511857
return FunctionSlot(typeName = name, boundary = BoundaryType("OBJECT", "(%s as $rendered)", "(%s)"))
18521858
}
18531859

1860+
/**
1861+
* A parameter typed as a **functional interface** (issue #216): a Java interface with one abstract
1862+
* method (`java.lang.Runnable`) or a Kotlin `fun interface`, whose method returns `Unit` and which is
1863+
* not generic. `null` for anything else, which leaves the parameter exactly as it was -- an
1864+
* interface on the classpath still crosses as a handle (`Alignment` is a `fun interface` whose
1865+
* method returns an `IntOffset`, and its slots must keep taking `Alignment.Center`).
1866+
*
1867+
* The slot takes **both** a Python callable and a Kotlin handle. Its type name is the interface's,
1868+
* then [SAM_SEPARATOR], then the `kotlin.FunctionN` it is equivalent to
1869+
* (`java.lang.Runnable|kotlin.Function0()->kotlin.Unit`): the binding layer fills it from a callable
1870+
* through the wrapper path a function type has (B-5, `pythonx._make_function`) and checks a handle
1871+
* against the interface. The generated read converts a wrapper with a SAM lambda and casts anything
1872+
* else -- `((%s).let { v -> if (v is kotlin.Function0<*>) java.lang.Runnable { ... } else v as
1873+
* java.lang.Runnable })` -- which `kotlinc` compiles; nothing is looked up at run time (AGENTS.md §12).
1874+
*
1875+
* The interface is read off the consumer's classpath, or, for a `java.`/`javax.` one, off the JDK
1876+
* this build runs on ([jdkClassNode]): the walk's classpath is the consumer's jars and never held
1877+
* `java.lang.Runnable`, which is why `runRunnable(block: Runnable)` was declined.
1878+
*/
1879+
private fun functionalInterfaceSlotOrNull(type: KmType, classpath: ArtifactClasspath): FunctionSlot? {
1880+
val classifier = type.classifier as? KmClassifier.Class ?: return null
1881+
val internal = classifier.name
1882+
if (internal.startsWith(KOTLIN_FUNCTION_INTERNAL_PREFIX)) return null
1883+
if (type.arguments.isNotEmpty()) return null
1884+
val binary = internal.replace('.', '$')
1885+
val node = classpath.classNode(binary) ?: jdkClassNode(binary) ?: return null
1886+
if (!node.access.hasFlag(Opcodes.ACC_INTERFACE) || !node.access.hasFlag(Opcodes.ACC_PUBLIC)) return null
1887+
if (node.signature?.startsWith("<") == true) return null
1888+
val kmClass = when (val metadata = kotlinClassMetadataOf(node)) {
1889+
null -> null
1890+
is KotlinClassMetadata.Class -> metadata.kmClass.takeIf { it.isFunInterface } ?: return null
1891+
else -> return null
1892+
}
1893+
val method = abstractMethodsOf(node, classpath)?.singleOrNull() ?: return null
1894+
val (descriptors, returns) = splitMethodDescriptor(method.desc)
1895+
if (returns != "V") return null
1896+
// A Kotlin `fun interface` says each parameter's nullability; a Java one does not, so an object
1897+
// parameter is taken as nullable there -- Java may pass `null`, and a non-null type would throw.
1898+
val parameterTypes: List<Pair<String, String>> = if (kmClass != null) {
1899+
val declared = kmClass.functions.singleOrNull { it.name == method.name } ?: return null
1900+
if (declared.valueParameters.size != descriptors.size) return null
1901+
declared.valueParameters.map { parameter ->
1902+
val name = kotlinClassifierNameOf(parameter.type) ?: return null
1903+
name to (renderKotlinSourceType(parameter.type, classpath, depth = 0) ?: return null)
1904+
}
1905+
} else {
1906+
descriptors.map { descriptor -> javaParameterTypeOf(descriptor, classpath) ?: return null }
1907+
}
1908+
val qualified = internal.replace('/', '.')
1909+
val arity = parameterTypes.size
1910+
val functionType = "kotlin.Function$arity<" + (parameterTypes.map { it.second } + UNIT_TYPE_NAME).joinToString(", ") + ">"
1911+
val star = "kotlin.Function$arity<" + List(arity + 1) { "*" }.joinToString(", ") + ">"
1912+
val names = (0 until arity).map { "p$it" }
1913+
val arrow = if (arity == 0) "" else names.joinToString(", ", postfix = " -> ")
1914+
val convert = "v -> if (v is $star) $qualified { $arrow(v as $functionType)(${names.joinToString(", ")}) } else v as $qualified"
1915+
val read = if (type.isNullable) "((%s)?.let { $convert })" else "((%s).let { $convert })"
1916+
return FunctionSlot(
1917+
typeName = qualified + SAM_SEPARATOR + KOTLIN_FUNCTION_PREFIX + arity +
1918+
"(" + parameterTypes.joinToString(",") { it.first } + ")" + FUNCTION_RETURNS + UNIT_TYPE_NAME,
1919+
boundary = BoundaryType("OBJECT", read, "(%s)"),
1920+
modelType = KotlinTypeModel(
1921+
"kotlin.Function$arity",
1922+
isNullable = type.isNullable,
1923+
arguments = parameterTypes.map { KotlinTypeModel(it.first) } + KotlinTypeModel(UNIT_TYPE_NAME),
1924+
),
1925+
)
1926+
}
1927+
1928+
/** Separates a functional-interface slot's interface from the function type a callable fills it as
1929+
* (issue #216). Must match `pythonx`'s `_SAM_SEPARATOR`; not a character a classifier can contain. */
1930+
internal const val SAM_SEPARATOR: String = "|"
1931+
1932+
/** `Object`'s public methods, which an interface may redeclare without becoming non-functional. */
1933+
private val OBJECT_METHOD_SIGNATURES = setOf(
1934+
"equals" to "(Ljava/lang/Object;)Z",
1935+
"hashCode" to "()I",
1936+
"toString" to "()Ljava/lang/String;",
1937+
)
1938+
1939+
/**
1940+
* The abstract methods an interface leaves to an implementation, its superinterfaces' included and
1941+
* minus any a subinterface gives a default body; `null` when a superinterface cannot be read.
1942+
*/
1943+
private fun abstractMethodsOf(node: ClassNode, classpath: ArtifactClasspath): List<org.objectweb.asm.tree.MethodNode>? {
1944+
val abstract = LinkedHashMap<Pair<String, String>, org.objectweb.asm.tree.MethodNode>()
1945+
val concrete = HashSet<Pair<String, String>>()
1946+
fun visit(current: ClassNode, depth: Int): Boolean {
1947+
if (depth > 8) return false
1948+
current.methods.filter { !it.access.hasFlag(Opcodes.ACC_STATIC) && !it.access.hasFlag(Opcodes.ACC_SYNTHETIC) }.forEach { method ->
1949+
val key = method.name to method.desc
1950+
if (key in OBJECT_METHOD_SIGNATURES || method.name.startsWith("<")) return@forEach
1951+
if (method.access.hasFlag(Opcodes.ACC_ABSTRACT)) abstract.putIfAbsent(key, method) else concrete += key
1952+
}
1953+
return current.interfaces.all { parent ->
1954+
val parentNode = classpath.classNode(parent) ?: jdkClassNode(parent) ?: return false
1955+
visit(parentNode, depth + 1)
1956+
}
1957+
}
1958+
if (!visit(node, 0)) return null
1959+
return abstract.filterKeys { it !in concrete }.values.toList()
1960+
}
1961+
1962+
/**
1963+
* A Java method parameter as `(slot type name, Kotlin source type)`: primitives and `String` by
1964+
* their Kotlin names, any other class by its name when it is nameable, an object type nullable.
1965+
*/
1966+
private fun javaParameterTypeOf(descriptor: String, classpath: ArtifactClasspath): Pair<String, String>? {
1967+
val primitive = when (descriptor) {
1968+
"I" -> "kotlin.Int"; "J" -> "kotlin.Long"; "Z" -> "kotlin.Boolean"; "D" -> "kotlin.Double"
1969+
"F" -> "kotlin.Float"; "S" -> "kotlin.Short"; "B" -> "kotlin.Byte"; "C" -> "kotlin.Char"
1970+
else -> null
1971+
}
1972+
if (primitive != null) return primitive to primitive
1973+
if (!descriptor.startsWith("L") || !descriptor.endsWith(";")) return null
1974+
val binary = descriptor.substring(1, descriptor.length - 1)
1975+
val name = when (binary) {
1976+
"java/lang/String" -> "kotlin.String"
1977+
"java/lang/Object" -> "kotlin.Any"
1978+
else -> if (classpath.isNameablePublicClass(binary)) binary.replace('/', '.').replace('$', '.') else return null
1979+
}
1980+
return name to "$name?"
1981+
}
1982+
1983+
/**
1984+
* A `java.`/`javax.` class file, read from the JDK this build runs on -- the walk's classpath is the
1985+
* consumer's jars, which never contain the platform. Build time only, and only to recognise a
1986+
* functional interface ([functionalInterfaceSlotOrNull]); it does not make a JDK type nameable as a
1987+
* handle anywhere else.
1988+
*/
1989+
private fun jdkClassNode(binaryName: String): ClassNode? {
1990+
if (!binaryName.startsWith("java/") && !binaryName.startsWith("javax/")) return null
1991+
val bytes = ClassLoader.getPlatformClassLoader().getResourceAsStream("$binaryName.class")?.use { it.readBytes() }
1992+
?: return null
1993+
return readClassNodeOrNull(bytes)
1994+
}
1995+
18541996
/** The Kotlin-source spelling of a function type: `kotlin.Function1<kotlin.Float, kotlin.Unit>`.
18551997
* Written as the `FunctionN` classifier rather than as `(Float) -> Unit` because the two are the
18561998
* same type and the first needs no bracket rules; assignability to an extension function type
@@ -2126,7 +2268,7 @@ internal object ArtifactScanner {
21262268
// Read before `resolveKotlinType` is asked anything, because for a function-typed parameter
21272269
// it is the one that would answer -- and would answer "no". See [functionSlotOrNull].
21282270
val functionSlots = function.allParameterTypes.mapIndexed { index, type ->
2129-
functionSlotOrNull(type, paramDescriptors[index], classpath)
2271+
functionSlotOrNull(type, paramDescriptors[index], classpath) ?: functionalInterfaceSlotOrNull(type, classpath)
21302272
}
21312273
functionSlots.firstNotNullOfOrNull { it?.declineReason }?.let { return declined(it) }
21322274

@@ -2207,13 +2349,16 @@ internal object ArtifactScanner {
22072349
// A member's defaults are not omittable yet (issue #215): no plan, so Python passes every
22082350
// argument and the stub says so.
22092351
val omittable = if (member != null) emptySet() else omittableParameterIndices(function, receiverIndex)
2352+
val modelParameters = model.parameters.mapIndexed { index, parameter ->
2353+
functionSlots[index + receiverIndex]?.modelType?.let { parameter.copy(type = it) } ?: parameter
2354+
}
22102355
return Candidate(
22112356
callable = callable,
22122357
declaration = model.copy(
22132358
bindingName = qualifiedName,
22142359
returnBoundaryTag = returnType.tag,
22152360
receiverBoundaryTag = if (function.isExtension) resolvedParams.first().tag else model.receiverBoundaryTag,
2216-
parameters = model.parameters.mapIndexed { index, parameter ->
2361+
parameters = modelParameters.mapIndexed { index, parameter ->
22172362
parameter.copy(boundaryTag = resolvedParams[index + receiverIndex].tag)
22182363
},
22192364
),

0 commit comments

Comments
 (0)