diff --git a/docs/SPEC.md b/docs/SPEC.md index 76f1380d..90d07e49 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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`, diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt index 823db419..895f42e4 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt @@ -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) = 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 + + /** Optional `PYTHONPATH` entries for locating the `typedpython` package when not installed in [typedPythonExecutable]. */ + val typedPythonPath: org.gradle.api.file.ConfigurableFileCollection } /** @@ -326,6 +345,34 @@ class PythonBindingsPlugin : Plugin { // `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")) } } diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/TypedPythonEmitCTask.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/TypedPythonEmitCTask.kt index 21f23326..7e5d2e28 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/TypedPythonEmitCTask.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/TypedPythonEmitCTask.kt @@ -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`). diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonEmitCTaskTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonEmitCTaskTest.kt index 23893acd..2ff23ddb 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonEmitCTaskTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonEmitCTaskTest.kt @@ -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) + } } diff --git a/python-multiplatform-ksp/fixtures/app/build.gradle.kts b/python-multiplatform-ksp/fixtures/app/build.gradle.kts index 57ec527d..9cd4ac65 100644 --- a/python-multiplatform-ksp/fixtures/app/build.gradle.kts +++ b/python-multiplatform-ksp/fixtures/app/build.gradle.kts @@ -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? = typedPythonMacTarget?.let { target -> val includes = project(":python-multiplatform").extensions.getByName("cpythonIncludeDirectories") @@ -156,6 +169,7 @@ val buildTypedPythonBridge by tasks.registering(Exec::class) { tasks.named("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") @@ -165,10 +179,15 @@ tasks.named("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) } diff --git a/python-multiplatform-ksp/fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt b/python-multiplatform-ksp/fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt index 101d0d7a..4a45e5e6 100644 --- a/python-multiplatform-ksp/fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt +++ b/python-multiplatform-ksp/fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt @@ -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.