Skip to content
Open
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
86 changes: 86 additions & 0 deletions .dagger/modules/e2e/main.dang
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package>/, 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.
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions .dagger/modules/engine-e2e/main.dang
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions templates/flat/pyproject.toml.tmpl
Original file line number Diff line number Diff line change
@@ -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 }
39 changes: 39 additions & 0 deletions templates/flat/{{.ModulePackage}}.py.tmpl
Original file line number Diff line number Diff line change
@@ -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")
)