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
8 changes: 6 additions & 2 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
`@OutputFile` `manifestFile`), fails with `GradleException` naming the stderr diagnostics on exit 1 or 2,
and parses the written v1 manifest into `TypedPythonEmitCManifest` (`nativeModules` mapped to
`ExtensionModuleSource(moduleName, sources, includeDirs, defines)` and `notNativeModules` mapped to
`NotNativeModule(module, kind, items)` with `SkippedItem(name, file, line, message)`). Tests:
`TypedPythonEmitCTaskTest.kt`.
`NotNativeModule(module, kind, items)` with `SkippedItem(name, file, line, message)`). Setting
`pythonBindings.typedPythonSourceDir` registers `emitTypedPythonC` (`TypedPythonEmitCTask`, with
`typedPythonRootDir` defaulting to `typedPythonSourceDir`, `typedPythonExecutable` defaulting to
`"python3"`, optional `typedPythonPath`, `cacheDirectory` at `build/typedpython/c-cache` and
`manifestFile` at `build/typedpython/emit-c-manifest.json`); leaving `typedPythonSourceDir` unset
registers no task. Tests: `TypedPythonEmitCTaskTest.kt`, `TypedPythonKotlinCallTest.kt`.
- **N-11** TypedPython fixed-layout classes (M2b, 2026-11-21; design §4.3.3). A class compiles to fixed
field access only when its layout is fixed **in CPython too**: it declares `__slots__` listing exactly
its annotated instance fields, has no base but `object`, no metaclass, no decorator but `@compiled`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,25 @@ interface PythonBindingsExtension {

/** The `ios { }` block itself: without it the nested property is reachable only as `ios.x`. */
fun ios(action: org.gradle.api.Action<in PythonBindingsIosExtension>) = action.execute(ios)

/**
* SPEC N-10 (`python-multiplatform#25` step 2): directory of developer `.py` files to compile
* to C with `python -m typedpython emit-c`. When set, registers [EMIT_TYPEDPYTHON_C_TASK]
* ([TypedPythonEmitCTask]); unset (the default) registers no task.
*/
val typedPythonSourceDir: org.gradle.api.file.DirectoryProperty

/**
* The directory module names are derived relative to (`--root`). Defaults to
* [typedPythonSourceDir].
*/
val typedPythonRootDir: org.gradle.api.file.DirectoryProperty

/** The Python interpreter executable that runs `-m typedpython emit-c` (defaults to `"python3"`). */
val typedPythonExecutable: Property<String>

/** Optional `PYTHONPATH` entries for locating the `typedpython` package when not installed in [typedPythonExecutable]. */
val typedPythonPath: org.gradle.api.file.ConfigurableFileCollection
}

/**
Expand Down Expand Up @@ -326,6 +345,34 @@ class PythonBindingsPlugin : Plugin<Project> {
// `plugins { ... }` and so do not exist while this plugin is being applied.
configureArtifactBindings(this, extension)
configureWasmProxyExports(this)
configureTypedPythonEmitC(this, extension)
}
}

/**
* SPEC N-10 (`python-multiplatform#25` step 2): registers [EMIT_TYPEDPYTHON_C_TASK]
* ([TypedPythonEmitCTask]) when [PythonBindingsExtension.typedPythonSourceDir] is set.
*
* A no-op when [PythonBindingsExtension.typedPythonSourceDir] is unset, so projects that do not
* compile developer Python modules with TypedPython get no task.
*/
internal fun configureTypedPythonEmitC(project: Project, extension: PythonBindingsExtension) {
if (!extension.typedPythonSourceDir.isPresent) return
val srcDir = extension.typedPythonSourceDir.get().asFile
project.tasks.register(EMIT_TYPEDPYTHON_C_TASK, TypedPythonEmitCTask::class.java) {
group = "python"
description = "Runs python -m typedpython emit-c over pythonBindings.typedPythonSourceDir and writes the compile-slot manifest."
sources.from(
project.fileTree(srcDir) {
include("**/*.py")
exclude("**/__pycache__/**", "**/.*", "**/build/**")
},
)
rootDirectory.set(extension.typedPythonRootDir.orElse(extension.typedPythonSourceDir))
pythonExecutable.set(extension.typedPythonExecutable.orElse("python3"))
pythonPath.from(extension.typedPythonPath)
cacheDirectory.set(project.layout.buildDirectory.dir("typedpython/c-cache"))
manifestFile.set(project.layout.buildDirectory.file("typedpython/emit-c-manifest.json"))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const val TYPEDPYTHON_EMIT_C_FORMAT = "typedpython-emit-c"
/** `docs/design/typedpython.md` section 4.3.2a (`emit-c` manifest version). */
const val TYPEDPYTHON_EMIT_C_VERSION = 1

/** Task name registered by [PythonBindingsPlugin] when [PythonBindingsExtension.typedPythonSourceDir] is set. */
const val EMIT_TYPEDPYTHON_C_TASK = "emitTypedPythonC"

/**
* One extension module's generated C and header/define metadata for pypackpack's compile slot
* (`pypackpack` `ExtensionCompiler.kt`, `BundleRequest.nativeModules`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,90 @@ class TypedPythonEmitCTaskTest {
assertTrue("exit 1" in err.message.orEmpty(), err.message)
assertTrue("broken.py does not pass the gate" in err.message.orEmpty(), err.message)
}

@Test
fun pluginDoesNotRegisterEmitTypedPythonCWhenSourceDirIsUnset() {
val project = ProjectBuilder.builder().build()
val extension = project.extensions.create("pythonBindings", PythonBindingsExtension::class.java)
PythonBindingsPlugin().configureTypedPythonEmitC(project, extension)

assertEquals(null, project.tasks.findByName(EMIT_TYPEDPYTHON_C_TASK))
}

@Test
fun pluginRegistersEmitTypedPythonCFromExtensionAndExecutesIt() {
val project = ProjectBuilder.builder().build()
val pkgRoot = project.file("pkg/src/main").apply { mkdirs() }
val pyFile = File(pkgRoot, "app/fast.py").apply {
parentFile.mkdirs()
writeText("@compiled\ndef f(x: int) -> int:\n return x + 1\n")
}
File(pkgRoot, "app/__init__.pyi").writeText("")
val fakeStubRoot = project.file("fake-tp-plugin").apply { mkdirs() }
val tpPkg = File(fakeStubRoot, "typedpython").apply { mkdirs() }
File(tpPkg, "__init__.py").writeText("")
File(tpPkg, "__main__.py").writeText(
"""
import argparse, json
from pathlib import Path
p = argparse.ArgumentParser()
p.add_argument("cmd")
p.add_argument("paths", nargs="+")
p.add_argument("--root", required=True)
p.add_argument("--cache", required=True)
p.add_argument("--manifest", required=True)
a = p.parse_args()
assert all(not x.endswith(".pyi") for x in a.paths), a.paths
cache = Path(a.cache).resolve()
cache.mkdir(parents=True, exist_ok=True)
c_file = cache / "fast.c"
c_file.write_text("/* generated C */\n")
runtime = (Path(__file__).resolve().parent / "runtime").as_posix()
doc = {
"format": "typedpython-emit-c",
"version": 1,
"compilerVersion": "stub-1",
"projectRoot": Path(a.root).resolve().as_posix(),
"runtimeInclude": runtime,
"nativeModules": [{
"moduleName": "app.fast",
"file": "app/fast.py",
"sources": [c_file.as_posix()],
"includeDirs": [runtime],
"defines": {},
"status": "rebuilt",
"skippedItems": [],
}],
"notNativeModules": [],
}
mf = Path(a.manifest)
mf.parent.mkdir(parents=True, exist_ok=True)
mf.write_text(json.dumps(doc))
""".trimIndent() + "\n",
)

val extension = project.extensions.create("pythonBindings", PythonBindingsExtension::class.java)
extension.typedPythonSourceDir.set(pkgRoot)
extension.typedPythonExecutable.set("python3")
extension.typedPythonPath.from(fakeStubRoot)

PythonBindingsPlugin().configureTypedPythonEmitC(project, extension)

val task = project.tasks.findByName(EMIT_TYPEDPYTHON_C_TASK) as? TypedPythonEmitCTask
?: error("expected $EMIT_TYPEDPYTHON_C_TASK to be registered by PythonBindingsPlugin")
assertEquals(listOf(pyFile.canonicalFile), task.sources.files.map { it.canonicalFile })
assertEquals(pkgRoot.canonicalFile, task.rootDirectory.get().asFile.canonicalFile)
assertEquals(
project.layout.buildDirectory.dir("typedpython/c-cache").get().asFile.canonicalFile,
task.cacheDirectory.get().asFile.canonicalFile,
)
assertEquals(
project.layout.buildDirectory.file("typedpython/emit-c-manifest.json").get().asFile.canonicalFile,
task.manifestFile.get().asFile.canonicalFile,
)

task.emitC()
val parsed = task.readManifest()
assertEquals("app.fast", parsed.toExtensionModuleSources().single().moduleName)
}
}
19 changes: 19 additions & 0 deletions python-multiplatform-ksp/fixtures/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,19 @@ val typedPythonSkipReason: String? = when {
else -> null
}

pythonBindings {
typedPythonSourceDir.set(file("src/desktopTest/typedpython"))
typedPythonExecutable.set(typedPythonHost?.absolutePath ?: "python3")
typedPythonPath.from(rootDir.resolve("python-multiplatform-ksp/src/main/python"))
}

val typedPythonEmitCManifest = layout.buildDirectory.file("typedpython/emit-c-manifest.json")

tasks.matching { it.name == "emitTypedPythonC" }.configureEach {
val skip = typedPythonSkipReason
onlyIf("TypedPython emit-c: ${skip ?: "enabled"}") { skip == null }
}

@Suppress("UNCHECKED_CAST")
val typedPythonIncludeDir: Provider<Directory>? = typedPythonMacTarget?.let { target ->
val includes = project(":python-multiplatform").extensions.getByName("cpythonIncludeDirectories")
Expand Down Expand Up @@ -156,6 +169,7 @@ val buildTypedPythonBridge by tasks.registering(Exec::class) {

tasks.named<Test>("desktopTest") {
dependsOn(buildTypedPythonBridge)
dependsOn("emitTypedPythonC")
// The tests import tp_kotlin_bridge.py from src/; without this the interpreter leaves a
// __pycache__ in the source tree.
environment("PYTHONDONTWRITEBYTECODE", "1")
Expand All @@ -165,10 +179,15 @@ tasks.named<Test>("desktopTest") {
}
if (typedPythonSkipReason == null) {
inputs.dir(typedPythonBridgeDir).withPropertyName("typedPythonBridge")
inputs.file(typedPythonEmitCManifest).withPropertyName("typedPythonEmitCManifest")
systemProperty(
"typedpython.bridge.properties",
typedPythonBridgeDir.get().file("bridge.properties").asFile.absolutePath,
)
systemProperty(
"typedpython.emitC.manifest",
typedPythonEmitCManifest.get().asFile.absolutePath,
)
} else {
systemProperty("typedpython.bridge.skip", typedPythonSkipReason)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,19 @@ class TypedPythonKotlinCallTest {
)
}

@Test
fun emitTypedPythonCTaskWritesManifestForTpKotlinBridge() {
val manifestPath = System.getProperty("typedpython.emitC.manifest")
assertTrue(manifestPath != null, "system property typedpython.emitC.manifest is unset")
val manifestFile = File(manifestPath!!)
assertTrue(manifestFile.isFile, "emitTypedPythonC ran but $manifestFile does not exist")
val text = manifestFile.readText()
assertTrue("\"format\": \"typedpython-emit-c\"" in text, text)
assertTrue("\"moduleName\": \"tp_kotlin_bridge\"" in text, text)
assertTrue("tp_kotlin_bridge.c" in text, text)
assertTrue("\"notNativeModules\": []" in text, text)
}

/**
* The watched reference counts once the JVM has collected the `PyObject` wrappers the binder made
* and their cleaners have handed the references back.
Expand Down