Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "dagger-clients-core"

[tool.dagger]
generated = "core"
8 changes: 8 additions & 0 deletions .dagger/modules/e2e/fixtures/script/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# /// script
# requires-python = ">=3.14"
# dependencies = ["dagger-io", "dagger-clients-core"]
#
# [tool.uv.sources]
# dagger-io = { path = "sdk" }
# dagger-clients-core = { path = "clients/core" }
# ///
5 changes: 5 additions & 0 deletions .dagger/modules/e2e/fixtures/script/app/sdk/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "dagger-io"

[tool.dagger]
generated = "runtime"
1 change: 1 addition & 0 deletions .dagger/modules/e2e/fixtures/script/app/tools/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
print("only code, no script block")
2 changes: 2 additions & 0 deletions .dagger/modules/e2e/fixtures/script/app/unclosed/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# /// script
print("an opening line, and no block after it")
3 changes: 3 additions & 0 deletions .dagger/modules/e2e/fixtures/script/empty/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# /// script
# ///
print("an empty script block")
2 changes: 2 additions & 0 deletions .dagger/modules/e2e/fixtures/script/empty/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[project]
name = "empty-ancestor"
172 changes: 172 additions & 0 deletions .dagger/modules/e2e/main.dang
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type E2e {
let staticTypesModulePath: String! = fixtureRoot + "/static-types"
let runtimeGreeting: String! = "served by the python-sdk runtime"
let mixedDiscoveryModulePath: String! = fixtureRoot + "/mixed-discovery/ancestor/work/app"
let scriptModulePath: String! = fixtureRoot + "/script/app"
let emptyScriptPath: String! = fixtureRoot + "/script/empty/app"
let mixedDiscoveryNestedPath: String! = mixedDiscoveryModulePath + "/nested/deeper"

let legacyBindingsPath: String! = "sdk/src/dagger/client/gen.py"
Expand Down Expand Up @@ -2784,4 +2786,174 @@ type E2e {
null
}

"""
generateScope initializes a script module from the script template: a
main.py whose block names sdk/ and core as path sources, its lock, the
generated members and the entrypoint generated into it, and no
pyproject.toml or uv.lock. The template refuses pip, and a script scope
refuses the global client and a scope without a module.
"""
pub scriptInitCheck(ws: Workspace!): Void @check {
let scope = outputRoot + "/script-init"
let scoped = ws.withNewDirectory("/" + scope, directory).withWorkdir(scope)
let initialized = pythonSdk(template: "script").generateScope(scoped, isModule: true, name: "script-init", clients: [])
let generated = initialized.withWorkdir(".")
let changes = generated.changes(scoped.withWorkdir("."))
[
"main.py", "main.py.lock", "dagger-module.toml", sdkMemberFile, coreMemberFile, coreInitPath,
"sdk/entrypoint/main.dang", "sdk/entrypoint/types.dang",
].each { file => assertAdded(changes, scope + "/" + file) }
let root = generated.directory("/" + scope)
["pyproject.toml", "uv.lock", "src"].each { file =>
assert(root.exists(file) == false, "a script module got " + file)
}
assertContainsAll(root.file("dagger-module.toml").contents, ["name = \"script-init\"", "kind = \"dang\"", "source = \"./sdk/entrypoint\""])
let script = root.file("main.py").contents
assertContainsAll(script, [
"# /// script\n",
"# dependencies = [\"dagger-io\", \"dagger-clients-core\"]\n",
"# dagger-io = { path = \"sdk\" }\n",
"# dagger-clients-core = { path = \"clients/core\" }\n",
"class ScriptInit:",
"from dagger_clients.core import",
])
assertContainsNone(script, ["[tool.uv.workspace]", "workspace = true", "{{"])
assertContainsAll(root.file("main.py.lock").contents, [
"name = \"dagger-io\"",
"source = { directory = \"sdk\" }",
"name = \"dagger-clients-core\"",
"source = { directory = \"clients/core\" }",
])
assertContains(root.file("sdk/entrypoint/types.dang").contents, "typeDef.withObject(\"ScriptInit\"", "the entrypoint does not describe the script's main object")

let pipScope = outputRoot + "/script-pip"
let pip = pythonSdk(template: "script", useUv: false)
.generateScope(ws.withNewDirectory("/" + pipScope, directory).withWorkdir(pipScope), isModule: true, name: "script-pip", clients: [])
.directory(".")
.entries
.join(", ") rescue {
err: Error => err.message
}
assertContains(pip, "builds with uv only", "the script template was initialized for pip: " + pip)

let global = initialized.withNewFile("/" + scope + "/main.py", script.replace("# ///\n", "#\n# [tool.dagger]\n# global-client = true\n# ///\n"))
let globalRefused = pythonSdk
.generateScope(global, isModule: true, name: "script-init", clients: [])
.directory(".")
.entries
.join(", ") rescue {
err: Error => err.message
}
assertContains(globalRefused, "gets no global client", "a script got the global client: " + globalRefused)
let plainRefused = pythonSdk
.generateScope(initialized, isModule: false, name: "", clients: [])
.directory(".")
.entries
.join(", ") rescue {
err: Error => err.message
}
assertContains(plainRefused, "holds a script without a module", "a script scope was generated without its module: " + plainRefused)
null
}

"""
A script module runs: one call through the entrypoint generated into it
and one through this checkout's shared entrypoint, driven through the
released CLI from `dagger module init --template script`. An edit of the
script's code keeps its dependency install, an edit of its block installs
again, and a dependency added to the block without locking it again is
refused rather than installed from the old lock.
"""
pub scriptCallCheck(ws: Workspace!): Void @check {
let path = ".dagger/modules/script-call"
let initialized = cli(playground(ws), ["module", "init", "python", "--auto-apply", "--name", "script-call", "--path", path, "--template", "script"])
.withExec(["test", "-f", path + "/main.py.lock"])
.withExec(["test", "!", "-e", path + "/pyproject.toml"])
let call = ["-m", path, "call", "container", "file", "--path", "/etc/alpine-release", "contents"]
let static = cli(initialized, call).stdout
assert(static.trimSpace != "", "the script module did not run on its generated entrypoint")
let shared = cli(onCheckoutEntrypoint(initialized, ws, path, "script-call"), call).stdout
assert(shared.trimSpace != "", "the script module did not run on the shared entrypoint")

let scope = outputRoot + "/script-call"
let generated = pythonSdk(template: "script")
.generateScope(ws.withNewDirectory("/" + scope, directory).withWorkdir(scope), isModule: true, name: "script-call", clients: [])
.withWorkdir(".")
let installed = scriptInstalledAt(generated, scope)
let scriptPath = "/" + scope + "/main.py"
let script = generated.file(scriptPath).contents
let edited = generated.withNewFile(scriptPath, script + "\n# edited\n")
assert(scriptInstalledAt(edited, scope) == installed, "an edit of the script's code installed its dependencies again")
let block = generated.withNewFile(scriptPath, script.replace("# ///\n", "# # edited\n# ///\n"))
assert(scriptInstalledAt(block, scope) != installed, "an edit of the script's block kept the install before it")
let unlocked = generated.withNewFile(scriptPath, script.replace("\"dagger-clients-core\"]", "\"dagger-clients-core\", \"httpx>=0.20\"]"))
let stale = scriptInstalledAt(unlocked, scope) rescue "raised"
assert(stale == "raised", "a dependency missing from main.py.lock was not refused")
null
}

"""
When the build installed the SDK files: a dependency install that is
cached gives back the same files, and one that runs again writes new ones.
"""
let scriptInstalledAt(generated: Workspace!, path: String!): String! {
pythonSdkRuntime
.moduleRuntime(modSource: runtimeSource(generated, path), introspectionJson: null)
.withExec(["python", "-c", "import os, dagger; print(os.stat(dagger.__file__).st_mtime_ns)"])
.stdout
.trimSpace
}

"""
A client of a script module is a path source of its block: generation
writes `{ path = "clients/<dir>" }` and the dependency into the block and
locks them in main.py.lock, and takes both out with the client, which
gives the block back. A script saved with CRLF line ends is still one, and
the line generation adds ends the same way. findClientRoot answers with
the script scope from inside it, from its generated members, and past a
main.py that is only code or opens a block it never closes. A block with
no content line is a block, so its script scope answers rather than the
project above it.
"""
pub scriptClientCheck(ws: Workspace!): Void @check {
let scope = outputRoot + "/script-client"
let scriptPath = "/" + scope + "/main.py"
let initialized = pythonSdk(template: "script")
.generateScope(ws.withNewDirectory("/" + scope, directory).withWorkdir(scope), isModule: true, name: "script-client", clients: [])
let script = initialized.file(scriptPath).contents
let withClient = pythonSdk.generateScope(initialized, isModule: true, name: "script-client", clients: [ws.moduleSource("/" + clientDepPath)])
let root = withClient.directory("/" + scope)
let edited = root.file("main.py").contents
assertContainsAll(edited, [
"# dependencies = [\"dagger-io\", \"dagger-clients-core\", \"dagger-clients-client-dep\"]\n",
"# dagger-clients-client-dep = { path = \"clients/client-dep\" }\n",
])
assertContainsNone(edited, ["[tool.uv.workspace]", "workspace = true"])
assert(root.exists(clientDepMember + "/pyproject.toml"), "the client of a script was not generated")
assertContains(root.file("main.py.lock").contents, "source = { directory = \"clients/client-dep\" }", "the client of a script was not locked")

let removed = pythonSdk.generateScope(withClient, isModule: true, name: "script-client", clients: [])
assert(removed.file(scriptPath).contents == script, "removing the client did not give the block back: " + removed.file(scriptPath).contents)
assert(removed.directory("/" + scope).exists(clientDepMember) == false, "the removed client of a script was left")

let crlf = pythonSdk.generateScope(
initialized.withNewFile(scriptPath, script.replace("\n", "\r\n")),
isModule: true,
name: "script-client",
clients: [ws.moduleSource("/" + clientDepPath)],
)
assert(crlf.directory("/" + scope).exists("pyproject.toml") == false, "a script with CRLF line ends was generated as a project")
assertContains(crlf.file(scriptPath).contents, "# dagger-clients-client-dep = { path = \"clients/client-dep\" }\r\n", "the client of a CRLF script was not written with its line end")

# findUp does not see files added to the workspace, so the scope is a
# fixture on disk.
assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath)) == scriptModulePath, "findClientRoot should answer with a script scope")
assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/sdk")) == scriptModulePath, "findClientRoot should lift a script's sdk/ to the script")
assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/clients/core")) == scriptModulePath, "findClientRoot should lift a script's core to the script")
assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/tools")) == scriptModulePath, "findClientRoot should look past a main.py without a script block")
assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/unclosed")) == scriptModulePath, "findClientRoot should look past a main.py whose script block is never closed")
assert(pythonSdk.findClientRoot(ws.withWorkdir(emptyScriptPath)) == emptyScriptPath, "findClientRoot should answer with a script scope whose block is empty, not the project above it")
null
}

}
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,59 @@ dagger module init python --name my-module \
generated into it.

`--template` picks a starter template: `default` (a small working module) when
you pass nothing, or `empty` for a bare object class. 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.
you pass nothing, `empty` for a bare object class, or `script` for a module in
a single file, as "Script modules" describes. 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. The `script` template takes them in its
script block.

## Script modules

A module can be one [PEP 723](https://peps.python.org/pep-0723/) script
instead of a project. The `script` template creates one:

```sh
dagger module init python --name my-module --template script
```

The module directory holds these files:

| Path | What it is |
| --- | --- |
| `main.py` | the module's code, with a `# /// script` metadata block at the top |
| `main.py.lock` | the script's lock, written by `uv lock --script main.py` |
| `sdk/`, `clients/core/`, `clients/<name>/` | the generated SDK files and clients, as in a project module |
| `dagger-module.toml` | the manifest |

The script block holds what `pyproject.toml` holds in a project module:
`requires-python`, `dependencies`, and a `[tool]` table. A script cannot be a
uv workspace root, so the block names `sdk/` and each client as a path source,
such as `dagger-io = { path = "sdk" }`. `dagger generate` writes these sources
and the client dependencies into the block, and locks the script again. Every
other line of `main.py` keeps its bytes.

The SDK tells the layout from the files. A directory with no `pyproject.toml`
and a `main.py` that holds a `# /// script` block is a script module. No
command converts a project module into a script module, or back.

The build installs the dependencies from `main.py.lock`, with the sources and
artifact hashes it records. The build refuses a lock that no longer
matches the script block: run `dagger generate` to lock the script again. The
dependency layer reads the script block, the lock and the local packages the
block names, so an edit of the code below the block reuses it. The SDK loads
`main.py` as the top-level module `main`.

A script module has these limits:

- It builds with uv. The build refuses `use-uv = false` in the script block,
and the `script` template refuses `--use-uv=false`.
- It gets no global client. Its code imports core from `dagger_clients.core`.
- Generation does not migrate a script module from a pre-1.0 `dagger.json`.
- A script scope always holds a module. A scope with clients and no module
needs a `pyproject.toml`.
- Its path sources sit inside the module directory. The entrypoint does not
load a path source outside it.
- `dagger call python-sdk mod config` reads and writes `pyproject.toml` only.

## Configure an existing module

Expand Down
Loading