From b8938cbfe96e32c283f99055e90bc51fe0191ab8 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 15 Sep 2026 18:49:19 -0700 Subject: [PATCH] templates: add a flat template `dagger module init python --name my-module --template flat` writes the module's code as a single file at the module root, `.py`, instead of `src//__init__.py`. The example module is the one the default template writes, so the two templates differ only in layout. `--template` still defaults to `default`. The template builds with hatchling rather than uv_build. uv_build packages a directory module only: with `module-root = ""` it looks for `/__init__.py` at the root and refuses a root-level `.py`. A root-level package directory would in turn match neither glob the runtimes use to find a module's Python files, `hasPythonFiles` in runtime/build.dang and `src/**/*.py|*.py` in the engine's builtin runtime, so it would need a change on both sides. A single root-level file matches both globs, and hatchling derives it from `[project] name` with no further configuration, so the flat template needs no runtime change. Signed-off-by: Yves Brissaud --- .dagger/modules/e2e/main.dang | 86 +++++++++++++++++++++++ .dagger/modules/engine-e2e/main.dang | 22 ++++++ README.md | 5 +- templates/flat/pyproject.toml.tmpl | 12 ++++ templates/flat/{{.ModulePackage}}.py.tmpl | 39 ++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 templates/flat/pyproject.toml.tmpl create mode 100644 templates/flat/{{.ModulePackage}}.py.tmpl diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index c7ed65a..78b47ef 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -184,6 +184,64 @@ type E2e { null } + """ + The flat template puts the module's code in a single file at the module root + instead of under src//, with the same example module as the default + template. Its build backend packages a root-level module, and the + pyproject.toml settings apply to it like they do to any other template. + """ + pub generateScopeFlatTemplateCheck(ws: Workspace!): Void @check { + let scope = outputRoot + "/scope-flat" + let scoped = ws.withNewDirectory("/" + scope, directory).withWorkdir(scope) + let changes = pythonSdk(template: "flat") + .generateScope(scoped, isModule: true, name: "scope-flat", clients: []) + .withWorkdir(".") + .changes(scoped.withWorkdir(".")) + + assertAdded(changes, scope + "/pyproject.toml") + assertAdded(changes, scope + "/scope_flat.py") + assertAdded(changes, scope + "/dagger-module.toml") + assertAdded(changes, scope + "/" + generatedMarkerPath) + + # The module's own code is at the root; only the vendored library uses src/. + let ownSrc = changes.addedPaths.filter { path => path.hasPrefix(scope + "/src/") } + assert(ownSrc.length == 0, "the flat template wrote module code under src/: " + ownSrc.join(", ")) + + let flatSource = changes.layer.file(scope + "/scope_flat.py").contents + assertContainsAll(flatSource, [ + "class ScopeFlat:", + "ws: dagger.Workspace", + "def container(self) -> dagger.Container:", + ]) + assertContainsNone(flatSource, ["def __init__", "{{"]) + + let flatPyproject = changes.layer.file(scope + "/pyproject.toml").contents + assertContainsAll(flatPyproject, [ + "name = \"scope-flat\"", + ">=3.14", + "hatchling", + "dagger-io", + ]) + assertNotContains(flatPyproject, "[tool.dagger]", "default settings should not write a [tool.dagger] table") + + let configuredScope = outputRoot + "/scope-flat-configured" + let configuredScoped = ws.withNewDirectory("/" + configuredScope, directory).withWorkdir(configuredScope) + let configuredChanges = pythonSdk(template: "flat", pythonVersion: "3.13", useUv: false, baseImage: "python:3.13-slim") + .generateScope(configuredScoped, isModule: true, name: "scope-flat-configured", clients: []) + .withWorkdir(".") + .changes(ws) + assertAdded(configuredChanges, configuredScope + "/scope_flat_configured.py") + assertContainsAll(configuredChanges.layer.file(configuredScope + "/pyproject.toml").contents, [ + ">=3.13", + "use-uv = false", + "python:3.13-slim", + "hatchling", + "dagger-io", + ]) + + null + } + """ generateScope preserves a current manifest. It migrates a pre-1.0 dagger.json to dagger-module.toml. Both cases generate the client library. @@ -341,6 +399,34 @@ type E2e { null } + """ + The runtime runs a module whose code is a single file at the module root, + the layout the flat template writes. The committed src/ fixture is rewritten + into that layout, with the flat template's own pyproject.toml, so the layout + is the only difference from runtimeCallCheck. + """ + pub runtimeFlatLayoutCallCheck(ws: Workspace!): Void @check { + let flatPyproject = ws + .file("/templates/flat/pyproject.toml.tmpl") + .contents + .replace("{{ .ModuleName }}", "runtime-app") + let flat = ws + .directory("/") + .withoutDirectory(runtimeModulePath + "/src") + .withFile( + runtimeModulePath + "/runtime_app.py", + ws.file("/" + runtimeModulePath + "/src/runtime_app/__init__.py"), + ) + .withNewFile(runtimeModulePath + "/pyproject.toml", flatPyproject) + let run = sdkSdk + .target(flat, ".") + .runInstalled(["call", "-m", runtimeFixturePath, "greeting"]) + run.assertSuccess + assertContains(run.stdout, runtimeGreeting, "the flat-layout module did not run on this repository's runtime") + + null + } + """ The runtime refuses a module whose generated files are missing instead of regenerating them: the no-codegen-at-load contract, which the engine's diff --git a/.dagger/modules/engine-e2e/main.dang b/.dagger/modules/engine-e2e/main.dang index 88a0b82..3815e28 100644 --- a/.dagger/modules/engine-e2e/main.dang +++ b/.dagger/modules/engine-e2e/main.dang @@ -5,6 +5,7 @@ Keep engineCommit and the engine-dev dependency in dagger-module.toml aligned. type EngineE2e { let engineCommit: String! = "6bf59d50654ce9244ebeee1cc090b7dce3fe3083" let modulePath: String! = ".dagger/modules/sdk-smoke" + let flatModulePath: String! = ".dagger/modules/sdk-flat" let assert(condition: Boolean!, message: String!): Void { if (condition == false) { @@ -47,6 +48,27 @@ type EngineE2e { .stdout assert(release.trimSpace != "", "the generated module did not run") + # The flat template keeps the module's code in a single file at its root, + # with no src/ of its own, and runs the same example module. + let flat = initialized + .withExec([ + "dagger", "module", "init", "python", "--auto-apply", + "--name", "sdk-flat", + "--path", flatModulePath, + "--template", "flat", + ]) + .withExec(["test", "-f", flatModulePath + "/sdk_flat.py"]) + .withExec(["test", "!", "-e", flatModulePath + "/src"]) + .withExec(["test", "-f", flatModulePath + "/sdk/src/dagger/client/gen.py"]) + .withExec(["grep", "-q", "class SdkFlat:", flatModulePath + "/sdk_flat.py"]) + let flatRelease = flat + .withExec(["dagger", "-m", flatModulePath, "call", "container", "file", "--path", "/etc/alpine-release", "contents"]) + .stdout + assert( + flatRelease.trimSpace == release.trimSpace, + "the flat module returned " + flatRelease.trimSpace + ", the default template returned " + release.trimSpace, + ) + initialized .withExec([ "dagger", "module", "init", "python", "--auto-apply", diff --git a/README.md b/README.md index da102c5..f6c05a6 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,9 @@ dagger module init python --name my-module \ ``` `--template` picks a starter template: `default` (a small working module) when -you pass nothing, `empty` for a bare object class, or `legacy` for a -container-echo example. The three `pyproject.toml` flags are optional; by +you pass nothing, `flat` for the same module in a single file at the module +root instead of under `src/`, `empty` for a bare object class, or `legacy` for +a container-echo example. The three `pyproject.toml` flags are optional; by default the template's Python version is used, uv is enabled, and no base image override is written. diff --git a/templates/flat/pyproject.toml.tmpl b/templates/flat/pyproject.toml.tmpl new file mode 100644 index 0000000..bcc106f --- /dev/null +++ b/templates/flat/pyproject.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "{{ .ModuleName }}" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["dagger-io"] + +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[tool.uv.sources] +dagger-io = { path = "sdk", editable = true } diff --git a/templates/flat/{{.ModulePackage}}.py.tmpl b/templates/flat/{{.ModulePackage}}.py.tmpl new file mode 100644 index 0000000..172fc71 --- /dev/null +++ b/templates/flat/{{.ModulePackage}}.py.tmpl @@ -0,0 +1,39 @@ +import dagger +from dagger import dag, function, object_type + + +@object_type +class {{ .ModuleType }}: + source: dagger.Directory + base_image_address: str + + @classmethod + def create( + cls, + ws: dagger.Workspace, + base_image_address: str = "alpine:3.24", + ) -> {{ .ModuleType }}: + return cls( + source=ws.directory( + "/", + exclude=[ + "**/.dagger", + "**/.git", + "**/.venv", + "**/__pycache__", + "**/node_modules", + "**/dist", + ], + ), + base_image_address=base_image_address, + ) + + @function + def container(self) -> dagger.Container: + """A container with the workspace source, ready to build.""" + return ( + dag.container() + .from_(self.base_image_address) + .with_directory("/src", self.source) + .with_workdir("/src") + )