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
109 changes: 109 additions & 0 deletions ksp-fixtures/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,112 @@ pythonBindings {
role.set("app")
processor.set(projects.pythonMultiplatformKsp)
}

// ---------------------------------------------------------------------------------------------
// TypedPython: compiled code calling Kotlin through the binder (issue #45, `TypedPythonKotlinCallTest`)
// ---------------------------------------------------------------------------------------------
//
// `buildTypedPythonBridge` compiles `src/desktopTest/typedpython/tp_kotlin_bridge.py` with the
// TypedPython pipeline (`python-multiplatform-ksp/src/main/python/typedpython`) into an extension
// for the CPython this fixture *embeds*, not the host's:
//
// - headers: `python-multiplatform`'s `cpythonIncludeDirectories` provider
// (`docs/platforms/python-version-acquisition.md` §7), for this host's macOS target and the
// flavour `-PpythonFreeThreaded` selects. The provider carries the `downloadPython_*` task.
// - extension suffix: `typedpython/build_bridge.py` reads `EXT_SUFFIX` from the `_sysconfigdata_*.py`
// of that same extracted tree and cross-checks `Py_GIL_DISABLED` against its `pyconfig.h`.
//
// The pipeline itself runs on a *host* Python that has Pyrefly 1.3.2 (the gate needs it):
// `-PtypedpythonPython=<interpreter>`, else `python-multiplatform-ksp/.venv/bin/python` when that
// exists. Without one -- or on a host other than macOS, where the JVM does not load libpython with
// global symbols and a `dynamic_lookup` extension could not resolve the C API -- the task is skipped
// and the test reports itself skipped with the reason. When the task does run, any failure fails the
// build: a gate error, a function left interpreted, a C compiler error.
//
// The provider is reached by name and reflection, not `getByType<CPythonIncludeDirectories>()`: this
// project and `:python-multiplatform` each apply the bindings plugin in their own `plugins {}` block,
// so each build script may see its own copy of the plugin's classes, and a typed lookup across the
// two can fail with "extension of type ... does not exist" even though it is registered.
evaluationDependsOn(":python-multiplatform")

val typedPythonHost: File? = (findProperty("typedpythonPython") as String?)?.let { File(it) }
?: rootDir.resolve("python-multiplatform-ksp/.venv/bin/python").takeIf { it.isFile }

val typedPythonMacTarget: String? =
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) null
else when (System.getProperty("os.arch")) {
"aarch64", "arm64" -> "macos-aarch64"
"x86_64", "amd64" -> "macos-x86_64"
else -> null
}

val typedPythonSkipReason: String? = when {
typedPythonMacTarget == null ->
"host ${System.getProperty("os.name")}/${System.getProperty("os.arch")} is not a macOS desktop target"
typedPythonHost == null ->
"no host Python with pyrefly 1.3.2: pass -PtypedpythonPython=<interpreter> or create " +
"python-multiplatform-ksp/.venv (pyproject.toml there pins pyrefly)"
typedPythonHost?.isFile != true -> "-PtypedpythonPython=$typedPythonHost does not exist"
else -> null
}

@Suppress("UNCHECKED_CAST")
val typedPythonIncludeDir: Provider<Directory>? = typedPythonMacTarget?.let { target ->
val includes = project(":python-multiplatform").extensions.getByName("cpythonIncludeDirectories")
val flavourClass = includes.javaClass.classLoader.loadClass("python.multiplatform.gradle.CPythonFlavour")
val freeThreaded = (findProperty("pythonFreeThreaded") as String?)?.toBoolean() ?: false
val flavour = flavourClass.enumConstants.single { (it as Enum<*>).name == if (freeThreaded) "FREE_THREADED" else "GIL" }
includes.javaClass.getMethod("includeDir", String::class.java, flavourClass)
.invoke(includes, target, flavour) as Provider<Directory>
}

val typedPythonBridgeDir = layout.buildDirectory.dir("typedpython/bridge")

val buildTypedPythonBridge by tasks.registering(Exec::class) {
group = "verification"
description = "Compiles tp_kotlin_bridge.py with TypedPython against the embedded CPython's headers (#45)."

val compilerDir = rootDir.resolve("python-multiplatform-ksp/src/main/python")
val sourceDir = file("src/desktopTest/typedpython")
val script = file("typedpython/build_bridge.py")
val outDir = typedPythonBridgeDir
val tmpDir = layout.buildDirectory.dir("typedpython/tmp")
val include = typedPythonIncludeDir
val skip = typedPythonSkipReason

onlyIf("TypedPython bridge: ${skip ?: "enabled"}") { skip == null }
inputs.files(fileTree(compilerDir) { exclude("**/__pycache__/**") }).withPropertyName("compiler")
inputs.files(fileTree(sourceDir) { exclude("**/__pycache__/**") }).withPropertyName("sources")
inputs.file(script).withPropertyName("script")
if (include != null && skip == null) inputs.dir(include).withPropertyName("cpythonInclude")
inputs.property("host", typedPythonHost?.absolutePath ?: "")
outputs.dir(outDir)

executable = typedPythonHost?.absolutePath ?: "python3"
// Pyrefly and the gate write temporary files; keep them inside this build directory (AGENTS.md §2).
environment("TMPDIR", tmpDir.get().asFile.absolutePath)
environment("PYTHONDONTWRITEBYTECODE", "1")
argumentProviders.add(CommandLineArgumentProvider {
listOf(
script.absolutePath,
compilerDir.absolutePath,
File(sourceDir, "tp_kotlin_bridge.py").absolutePath,
include!!.get().asFile.absolutePath,
outDir.get().asFile.absolutePath,
)
})
doFirst { tmpDir.get().asFile.mkdirs() }
}

tasks.named<Test>("desktopTest") {
dependsOn(buildTypedPythonBridge)
if (typedPythonSkipReason == null) {
inputs.dir(typedPythonBridgeDir).withPropertyName("typedPythonBridge")
systemProperty(
"typedpython.bridge.properties",
typedPythonBridgeDir.get().file("bridge.properties").asFile.absolutePath,
)
} else {
systemProperty("typedpython.bridge.skip", typedPythonSkipReason)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package fixture.app

import python.multiplatform.ffi.PyObject
import python.multiplatform.reflection.PythonInternal

/*
* The Kotlin surface compiled TypedPython code calls through the binder (issue #45). The Python
* side is `src/desktopTest/typedpython/tp_kotlin_bridge.py`; the test is
* `TypedPythonKotlinCallTest`. Every call records itself in [TpBridgeLedger], so the test can
* compare what Kotlin saw from the interpreted run with what it saw from the compiled one.
*/

/**
* A Kotlin object Python creates through its constructor, receives as a typed `TpSample` proxy,
* calls a method on, and hands back ([tpAccumulate]).
*
* Created by calling the class, not through a factory function: a Kotlin function returning a
* Kotlin object reaches Python as a generic `_PmObject` owner, not as the class's rendered proxy,
* so its methods are not reachable (`PythonProxySource`'s "What is still not rendered" table; the
* first run of this test hit it). The rendered class's own constructor is the path that gives a
* typed proxy today.
*/
class TpSample(val index: Long, val weight: Double) {
init {
TpBridgeLedger.samples += 1
}

fun weigh(factor: Double): Double {
TpBridgeLedger.weighs += 1
return weight * factor + index
}
}

/** Takes the proxy back (a parameter declared as a Kotlin class, so the binder unwraps it to the object). */
fun tpAccumulate(sample: TpSample, amount: Double): Double {
TpBridgeLedger.accumulates += 1
TpBridgeLedger.received += amount
TpBridgeLedger.indexSum += sample.index
return amount / (1.0 + sample.weight)
}

/** Throws once [value] passes [limit]; the binder turns the throw into a Python `RuntimeError(message)`. */
fun tpCheck(value: Double, limit: Double): Double {
TpBridgeLedger.checks += 1
if (value > limit) throw IllegalStateException("tpCheck: $value exceeds $limit")
return value
}

/**
* Receives a Python object as a [PyObject]. The trampoline wraps it with a reference of its own
* (`borrowed = true`), which the wrapper's cleaner gives back once the Kotlin collector finds the
* wrapper unreachable -- so the token's `sys.getrefcount` is only meaningful after a JVM collection
* and the cleaner have run (`TypedPythonKotlinCallTest.settledCounts`).
*
* Deliberately **not** closed here: closing a `PyObject` argument inside an upcall releases its
* reference twice and crashed the test JVM (`Py_XDECREF` on the cleaner thread) -- binder issue #98.
*/
fun tpTouch(token: PyObject?): Long {
TpBridgeLedger.touches += 1
return if (token == null) 0L else 1L
}

/** What Kotlin observed. Not exposed to Python: only the test reads and resets it. */
@PythonInternal
object TpBridgeLedger {
var samples: Long = 0
var weighs: Long = 0
var accumulates: Long = 0
var checks: Long = 0
var touches: Long = 0

/** Sum of every `amount` [tpAccumulate] received -- a Kotlin-side effect that depends on Python's float math. */
var received: Double = 0.0
var indexSum: Long = 0

fun reset() {
samples = 0
weighs = 0
accumulates = 0
checks = 0
touches = 0
received = 0.0
indexSum = 0
}

/** Every field, `received` as its exact bits, in one comparable string. */
fun snapshot(): String =
"samples=$samples weighs=$weighs accumulates=$accumulates checks=$checks touches=$touches " +
"received=$received (bits ${received.toRawBits().toULong().toString(16)}) indexSum=$indexSum"
}
Loading
Loading