Skip to content

Commit e3d8219

Browse files
authored
Merge pull request #102 from thisisthepy/work/m2b-45
Test: compiled TypedPython calls Kotlin in the embedded runtime (#45)
2 parents 7e7241c + 0db1b42 commit e3d8219

7 files changed

Lines changed: 719 additions & 0 deletions

File tree

‎ksp-fixtures/app/build.gradle.kts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,112 @@ pythonBindings {
5757
role.set("app")
5858
processor.set(projects.pythonMultiplatformKsp)
5959
}
60+
61+
// ---------------------------------------------------------------------------------------------
62+
// TypedPython: compiled code calling Kotlin through the binder (issue #45, `TypedPythonKotlinCallTest`)
63+
// ---------------------------------------------------------------------------------------------
64+
//
65+
// `buildTypedPythonBridge` compiles `src/desktopTest/typedpython/tp_kotlin_bridge.py` with the
66+
// TypedPython pipeline (`python-multiplatform-ksp/src/main/python/typedpython`) into an extension
67+
// for the CPython this fixture *embeds*, not the host's:
68+
//
69+
// - headers: `python-multiplatform`'s `cpythonIncludeDirectories` provider
70+
// (`docs/platforms/python-version-acquisition.md` §7), for this host's macOS target and the
71+
// flavour `-PpythonFreeThreaded` selects. The provider carries the `downloadPython_*` task.
72+
// - extension suffix: `typedpython/build_bridge.py` reads `EXT_SUFFIX` from the `_sysconfigdata_*.py`
73+
// of that same extracted tree and cross-checks `Py_GIL_DISABLED` against its `pyconfig.h`.
74+
//
75+
// The pipeline itself runs on a *host* Python that has Pyrefly 1.3.2 (the gate needs it):
76+
// `-PtypedpythonPython=<interpreter>`, else `python-multiplatform-ksp/.venv/bin/python` when that
77+
// exists. Without one -- or on a host other than macOS, where the JVM does not load libpython with
78+
// global symbols and a `dynamic_lookup` extension could not resolve the C API -- the task is skipped
79+
// and the test reports itself skipped with the reason. When the task does run, any failure fails the
80+
// build: a gate error, a function left interpreted, a C compiler error.
81+
//
82+
// The provider is reached by name and reflection, not `getByType<CPythonIncludeDirectories>()`: this
83+
// project and `:python-multiplatform` each apply the bindings plugin in their own `plugins {}` block,
84+
// so each build script may see its own copy of the plugin's classes, and a typed lookup across the
85+
// two can fail with "extension of type ... does not exist" even though it is registered.
86+
evaluationDependsOn(":python-multiplatform")
87+
88+
val typedPythonHost: File? = (findProperty("typedpythonPython") as String?)?.let { File(it) }
89+
?: rootDir.resolve("python-multiplatform-ksp/.venv/bin/python").takeIf { it.isFile }
90+
91+
val typedPythonMacTarget: String? =
92+
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) null
93+
else when (System.getProperty("os.arch")) {
94+
"aarch64", "arm64" -> "macos-aarch64"
95+
"x86_64", "amd64" -> "macos-x86_64"
96+
else -> null
97+
}
98+
99+
val typedPythonSkipReason: String? = when {
100+
typedPythonMacTarget == null ->
101+
"host ${System.getProperty("os.name")}/${System.getProperty("os.arch")} is not a macOS desktop target"
102+
typedPythonHost == null ->
103+
"no host Python with pyrefly 1.3.2: pass -PtypedpythonPython=<interpreter> or create " +
104+
"python-multiplatform-ksp/.venv (pyproject.toml there pins pyrefly)"
105+
typedPythonHost?.isFile != true -> "-PtypedpythonPython=$typedPythonHost does not exist"
106+
else -> null
107+
}
108+
109+
@Suppress("UNCHECKED_CAST")
110+
val typedPythonIncludeDir: Provider<Directory>? = typedPythonMacTarget?.let { target ->
111+
val includes = project(":python-multiplatform").extensions.getByName("cpythonIncludeDirectories")
112+
val flavourClass = includes.javaClass.classLoader.loadClass("python.multiplatform.gradle.CPythonFlavour")
113+
val freeThreaded = (findProperty("pythonFreeThreaded") as String?)?.toBoolean() ?: false
114+
val flavour = flavourClass.enumConstants.single { (it as Enum<*>).name == if (freeThreaded) "FREE_THREADED" else "GIL" }
115+
includes.javaClass.getMethod("includeDir", String::class.java, flavourClass)
116+
.invoke(includes, target, flavour) as Provider<Directory>
117+
}
118+
119+
val typedPythonBridgeDir = layout.buildDirectory.dir("typedpython/bridge")
120+
121+
val buildTypedPythonBridge by tasks.registering(Exec::class) {
122+
group = "verification"
123+
description = "Compiles tp_kotlin_bridge.py with TypedPython against the embedded CPython's headers (#45)."
124+
125+
val compilerDir = rootDir.resolve("python-multiplatform-ksp/src/main/python")
126+
val sourceDir = file("src/desktopTest/typedpython")
127+
val script = file("typedpython/build_bridge.py")
128+
val outDir = typedPythonBridgeDir
129+
val tmpDir = layout.buildDirectory.dir("typedpython/tmp")
130+
val include = typedPythonIncludeDir
131+
val skip = typedPythonSkipReason
132+
133+
onlyIf("TypedPython bridge: ${skip ?: "enabled"}") { skip == null }
134+
inputs.files(fileTree(compilerDir) { exclude("**/__pycache__/**") }).withPropertyName("compiler")
135+
inputs.files(fileTree(sourceDir) { exclude("**/__pycache__/**") }).withPropertyName("sources")
136+
inputs.file(script).withPropertyName("script")
137+
if (include != null && skip == null) inputs.dir(include).withPropertyName("cpythonInclude")
138+
inputs.property("host", typedPythonHost?.absolutePath ?: "")
139+
outputs.dir(outDir)
140+
141+
executable = typedPythonHost?.absolutePath ?: "python3"
142+
// Pyrefly and the gate write temporary files; keep them inside this build directory (AGENTS.md §2).
143+
environment("TMPDIR", tmpDir.get().asFile.absolutePath)
144+
environment("PYTHONDONTWRITEBYTECODE", "1")
145+
argumentProviders.add(CommandLineArgumentProvider {
146+
listOf(
147+
script.absolutePath,
148+
compilerDir.absolutePath,
149+
File(sourceDir, "tp_kotlin_bridge.py").absolutePath,
150+
include!!.get().asFile.absolutePath,
151+
outDir.get().asFile.absolutePath,
152+
)
153+
})
154+
doFirst { tmpDir.get().asFile.mkdirs() }
155+
}
156+
157+
tasks.named<Test>("desktopTest") {
158+
dependsOn(buildTypedPythonBridge)
159+
if (typedPythonSkipReason == null) {
160+
inputs.dir(typedPythonBridgeDir).withPropertyName("typedPythonBridge")
161+
systemProperty(
162+
"typedpython.bridge.properties",
163+
typedPythonBridgeDir.get().file("bridge.properties").asFile.absolutePath,
164+
)
165+
} else {
166+
systemProperty("typedpython.bridge.skip", typedPythonSkipReason)
167+
}
168+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package fixture.app
2+
3+
import python.multiplatform.ffi.PyObject
4+
import python.multiplatform.reflection.PythonInternal
5+
6+
/*
7+
* The Kotlin surface compiled TypedPython code calls through the binder (issue #45). The Python
8+
* side is `src/desktopTest/typedpython/tp_kotlin_bridge.py`; the test is
9+
* `TypedPythonKotlinCallTest`. Every call records itself in [TpBridgeLedger], so the test can
10+
* compare what Kotlin saw from the interpreted run with what it saw from the compiled one.
11+
*/
12+
13+
/**
14+
* A Kotlin object Python creates through its constructor, receives as a typed `TpSample` proxy,
15+
* calls a method on, and hands back ([tpAccumulate]).
16+
*
17+
* Created by calling the class, not through a factory function: a Kotlin function returning a
18+
* Kotlin object reaches Python as a generic `_PmObject` owner, not as the class's rendered proxy,
19+
* so its methods are not reachable (`PythonProxySource`'s "What is still not rendered" table; the
20+
* first run of this test hit it). The rendered class's own constructor is the path that gives a
21+
* typed proxy today.
22+
*/
23+
class TpSample(val index: Long, val weight: Double) {
24+
init {
25+
TpBridgeLedger.samples += 1
26+
}
27+
28+
fun weigh(factor: Double): Double {
29+
TpBridgeLedger.weighs += 1
30+
return weight * factor + index
31+
}
32+
}
33+
34+
/** Takes the proxy back (a parameter declared as a Kotlin class, so the binder unwraps it to the object). */
35+
fun tpAccumulate(sample: TpSample, amount: Double): Double {
36+
TpBridgeLedger.accumulates += 1
37+
TpBridgeLedger.received += amount
38+
TpBridgeLedger.indexSum += sample.index
39+
return amount / (1.0 + sample.weight)
40+
}
41+
42+
/** Throws once [value] passes [limit]; the binder turns the throw into a Python `RuntimeError(message)`. */
43+
fun tpCheck(value: Double, limit: Double): Double {
44+
TpBridgeLedger.checks += 1
45+
if (value > limit) throw IllegalStateException("tpCheck: $value exceeds $limit")
46+
return value
47+
}
48+
49+
/**
50+
* Receives a Python object as a [PyObject]. The trampoline wraps it with a reference of its own
51+
* (`borrowed = true`), which the wrapper's cleaner gives back once the Kotlin collector finds the
52+
* wrapper unreachable -- so the token's `sys.getrefcount` is only meaningful after a JVM collection
53+
* and the cleaner have run (`TypedPythonKotlinCallTest.settledCounts`).
54+
*
55+
* Deliberately **not** closed here: closing a `PyObject` argument inside an upcall releases its
56+
* reference twice and crashed the test JVM (`Py_XDECREF` on the cleaner thread) -- binder issue #98.
57+
*/
58+
fun tpTouch(token: PyObject?): Long {
59+
TpBridgeLedger.touches += 1
60+
return if (token == null) 0L else 1L
61+
}
62+
63+
/** What Kotlin observed. Not exposed to Python: only the test reads and resets it. */
64+
@PythonInternal
65+
object TpBridgeLedger {
66+
var samples: Long = 0
67+
var weighs: Long = 0
68+
var accumulates: Long = 0
69+
var checks: Long = 0
70+
var touches: Long = 0
71+
72+
/** Sum of every `amount` [tpAccumulate] received -- a Kotlin-side effect that depends on Python's float math. */
73+
var received: Double = 0.0
74+
var indexSum: Long = 0
75+
76+
fun reset() {
77+
samples = 0
78+
weighs = 0
79+
accumulates = 0
80+
checks = 0
81+
touches = 0
82+
received = 0.0
83+
indexSum = 0
84+
}
85+
86+
/** Every field, `received` as its exact bits, in one comparable string. */
87+
fun snapshot(): String =
88+
"samples=$samples weighs=$weighs accumulates=$accumulates checks=$checks touches=$touches " +
89+
"received=$received (bits ${received.toRawBits().toULong().toString(16)}) indexSum=$indexSum"
90+
}

0 commit comments

Comments
 (0)