@@ -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+ }
0 commit comments