A Dagger module for managing Dagger modules that use the Python SDK.
SDK-specific module authoring (scaffolding new modules, language build config,
codegen) lives in modules like this one. The engine drives the SDK
(dagger/dagger#13992): it records a module scope in dagger.toml, sets the
workspace cwd to it, and asks this module to generate the complete scope
through findClientRoot and generateScope. The module writes the manifest and
its own files; the engine owns the workspace bookkeeping.
It uses the engine's native Workspace and ModuleSource APIs. It uses
sdkHelpers.moduleManifest from dagger/sdk-helpers.
| Path | What it is |
|---|---|
python-sdk.dang, mod.dang, templates/ |
authoring: findClientRoot, generateScope, mod (generate, config), templates |
sdk/ |
the dagger-io client library and code generator |
runtime/ |
the container build both entrypoints share, and a module runtime for a manifest that names it |
entrypoint/ |
the shared Dang ModuleEntrypoint, served from this repository to any module that names it |
Code generation happens at dagger generate, which calls generateScope for
every recorded scope. It runs the code generator in sdk/ and vendors the
result into the module. The runtime never generates: it builds a module from its
committed generated files, so there is no codegen step in a cold
dagger call, and a module that has not been generated fails with an
actionable error rather than being silently regenerated.
When a managed pre-1.0 dagger.json scope is generated, the SDK writes
dagger-module.toml and removes dagger.json. An unmanaged legacy module keeps
using the Python SDK that is built into the engine.
A module's manifest names what runs it, and generation follows the manifest. There are three answers, and a new module gets the first:
dagger-module.toml |
What runs the module |
|---|---|
[entrypoint] source = "./sdk/entrypoint" |
the entrypoint generated into it, with its types baked |
[entrypoint] source = <a ref> |
an entrypoint served from elsewhere, the shared one below among them |
[runtime] source = <a ref or path> |
a module runtime, which builds and runs the module itself |
An entrypoint needs an engine that runs Dang entrypoints and serves
serveModule: v1.0.0-beta.14 or later. It runs a module on the engine's own
version, so an entrypoint manifest keeps no engineVersion.
Generating a module that has a manifest already keeps the kind it names:
[runtime] source = "python", the runtime built into the engine, which this SDK wrote before, is replaced by a generated entrypoint, andengineVersionand[[dependencies]]go with it. A module that cannot run on an entrypoint moves to the runtime this SDK publishes instead, on the version the generator reads core in.- A
[runtime]of the user's own is kept as written, with no entrypoint added: the engine follows an entrypoint over a runtime, so adding one would take the runtime out of the picture.runtime/is such a runtime, named by module ref or by a path relative to the module. - A manifest that says which files the module is (
include,exclude) or roots it elsewhere (sourceother than.) stays on a runtime: an entrypoint takes the module's own directory and all of it, and dropping the setting would change which files the module is. The runtime it stays on is the one this SDK publishes,dagger.io/sdk/python/runtime@v1, since the runtime built into the engine reads the layout before this one;--legacy-runtimeasks for it for any module. - An entrypoint of another kind, a pin of the shared one or a fork, is kept as
written.
--remote-entrypointasks for the shared one instead. --legacy-runtimeand--remote-entrypointcannot be set together: each asks for a different way to run the module, and a manifest names one.[entrypoint]and[runtime]together are refused, naming both: only one thing can run the module.disableDefaultFunctionCachingand[codegen]are refused one at a time: the first is a function's owncache=on an entrypoint, and the second is read by no entrypoint. A[clients]table is refused as well: a module's clients are its scope's, indagger.toml.
An unmanaged legacy dagger.json with "sdk": {"source": "python"} still
resolves to the runtime built into the engine (dagger/dagger's
sdk/python), which generates bindings at module load.
Migrating takes two commands per module: the engine converts the module's config, and this SDK generates its scope. Run both from the workspace root, and take the modules in dependency order, each dependency before the modules that call it.
- Install this SDK. The command creates
dagger.tomlif the workspace has none:dagger module install github.com/dagger/python-sdk
- Convert one module's
dagger.jsonand record its scope:dagger module migrate <path> -y
- Generate that scope, before migrating anything that depends on this module:
dagger generate -y
Step 2 writes dagger-module.toml keeping the legacy shape (engineVersion,
[runtime] source = "python", [[dependencies]]), removes dagger.json, and
records [sdks.python.scopes."<path>"] in dagger.toml with the module's
dependencies as its clients. Step 3 is what moves the module to this layout:
the manifest keeps only name and [entrypoint], the dependencies become
generated clients, the SDK files are written into the module, and the version
the pre-1.0 config declared goes, since an entrypoint runs the module on the
engine's own version.
Keep steps 2 and 3 paired, in that order. Converting a module loads its
dependencies as modules, and a dependency that was converted but not yet
generated has no generated files for a runtime to build: that fails with
generated file "sdk/pyproject.toml" is missing, naming the dependency.
dagger workspace migrate is not a shortcut for the whole repository. It
migrates the workspace config and the modules the workspace installs; a module
of your own is an optional candidate, which it lists as
dagger module migrate <path> and then skips, so it can report
"No migration needed" while every module is still pre-1.0. Select them
explicitly, with --module <path> repeated, or take them one at a time as
above.
A client loads its target with serveModule, which asks the engine for a
module by its manifest, so a target that still has only a dagger.json cannot
be served; generation names the target and the command to convert it.
Two shapes keep a module on a runtime rather than an entrypoint: include or
exclude, which an entrypoint cannot honour because it takes the module's own
directory and all of it, and a source other than ., which puts the module's
code elsewhere. Generation moves such a module from the runtime built into the
engine, which reads the layout before this one, to the runtime this SDK
publishes, which builds this one:
# <module>/dagger-module.toml
name = "my-module"
engineVersion = "v1.0.0-0"
include = ["../shared-package"]
[runtime]
source = "dagger.io/sdk/python/runtime@v1"A module reference is a legal [runtime] source, and so is a path relative to
the module, which is how this repository's e2e checks point at runtime/ in the
working tree. A module whose source is not . needs more than the move:
generation writes the scope beside the manifest, and the module builds from
source.
Code written against the layout before calls dag and dagger.Container.
Generation keeps that code working by writing the temporary global client,
sdk/src/dagger_global/, and global-client = true under [tool.dagger],
when the module is one the builtin runtime ran: its vendored bindings say so
(sdk/src/dagger/client/gen.py or src/dagger_gen.py with the generator's
header), or its manifest does (a pre-1.0 dagger.json, or [runtime] source = "python"). A scope file that already sets global-client = false keeps it
off. The global client is temporary: move the code to
from dagger_clients.core import ... and turn the flag off.
entrypoint/ is one ModuleEntrypoint, written in Dang, that backs every
Python module at once, with nothing generated into the module. A module whose
manifest names it keeps it:
# <module>/dagger-module.toml
name = "my-module"
[entrypoint]
kind = "dang"
source = "dagger.io/sdk/python/entrypoint@v1"A Dang entrypoint already in the manifest is kept as written, so a module can
pin a version of the shared entrypoint, point at a fork, or name one of its
own. Generation replaces only the static entrypoint it writes itself, told by
its source, ./sdk/entrypoint. The manifest is read with a TOML parser, so
quoting and key order are the user's.
Inside an entrypoint currentModule is the module it serves, so the
entrypoint builds that module's container from currentModule.source, with
the same build the runtime uses, however the module was loaded. It asks the
module to describe itself (python -m dagger.mod describe) or to run one call
(python -m dagger.mod call). The types it returns are rebuilt from that
description in the engine's own session.
The module's code runs in an exec the entrypoint starts, and loads a client
with serveModule, as a plain program does. The engine resolves a local
client's path in the tree the module was loaded from: its git repository at
the pinned commit, the directory it was built from, or on the host its git
repository, or its own directory outside one. An engine without that resolves the path in the caller's
workspace instead, and can serve the wrong module.
| File | What it is |
|---|---|
main.dang |
the ModuleEntrypoint: types and call |
build.dang |
the container build, generated from runtime/build.dang |
build.dang is generated, not hand-edited: the engine copies only the .dang
files at the top of an entrypoint directory, and currentModule inside an
entrypoint is the module it serves, so a shared entrypoint can read none of its
own non-Dang files. The externals block that reads runtime/images/ is written
out into the copy. dagger check -m .dagger/modules/e2e fails when the copy
drifts; refresh it with
dagger call -m .dagger/modules/e2e shared-entrypoint-build export --path entrypoint/build.dang.
The address above only resolves once entrypoint/ is on this repository's
default branch and a v1 release is tagged: @v1 selects the greatest
entrypoint/v1.* tag, then the greatest plain v1.* tag. The Python process the entrypoint starts belongs to no module
on the engine's side: the core API works in it, and dag.current_module()
fails with "no current module". The static entrypoint has the same limit.
On the shared entrypoint a module's types are discovered by running it: the
engine builds the module's container and starts Python once per session to
register the types, then again for every call. A generated entrypoint computes
them once, at dagger generate, and the engine loads them without running
Python. Every new module gets one:
dagger module init python --name my-moduleIts manifest names that entrypoint, and sdk/entrypoint/ sits next to the
SDK files:
| File | What it is |
|---|---|
types.dang |
the module's types, as a literal list of TypeDef values |
main.dang |
the ModuleEntrypoint: returns the types and runs calls in the module's container |
build.dang |
the container build, copied from runtime/build.dang |
The types come from importing the module in its own container and reading
what its decorators registered, so they are the ones the runtime would
register. They are baked, so a change that alters them — a new function, a
changed argument or return type — is served only after dagger generate;
dagger generate in CI, which must leave no diff, is what catches a module
that forgot it. A change the types do not describe, such as a function body,
runs as edited.
What a generated entrypoint cannot carry is in the list above: a module whose
manifest says which files it is, or names a runtime, keeps that runtime
instead. There is no debug terminal on this path, and a function error
reaches the caller as the exec failure with the process's stderr.
A module keeps the kind of entrypoint its manifest names: generation writes
one into a module that has none, and leaves a Dang entrypoint of another kind
alone, so a module on the shared entrypoint or on a fork stays there.
--remote-entrypoint asks for the shared one instead, for a new module or for
one that has an entrypoint of its own, and then sdk/entrypoint/ goes. A
module that names a runtime of its own keeps it, with or without the flag. See
future/done/static-module-entrypoint.md
for the design.
From your workspace root:
dagger module install github.com/dagger/python-sdkThe engine recognizes the SDK interface and records the module as the python
SDK in dagger.toml. After install, the module is also available in
dagger call as python-sdk.
Calls that return a Changeset will print the diff and prompt you to confirm
before writing anything to your workspace.
dagger module init python --name my-moduleThe engine records the module scope in dagger.toml and calls this SDK's
generateScope, which renders the template, writes dagger-module.toml, and
generates the SDK bindings in one step.
The SDK settings below become typed flags on dagger module init python and
are persisted on the scope:
dagger module init python --name my-module --template empty
dagger module init python --name my-module --legacy-runtime
dagger module init python --name my-module --remote-entrypoint
dagger module init python --name my-module \
--python-version 3.13 \
--use-uv=false \
--base-image python:3.13-slim--legacy-runtime and --remote-entrypoint pick what runs the module, as
"How a module runs" describes; without either, a new module gets an entrypoint
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.
Read the current configuration. Settings that are not explicitly written to
pyproject.toml are reported as null rather than guessed:
dagger call python-sdk mod --path my-module config getSelect a single value:
dagger call python-sdk mod --path my-module config get python-version
dagger call python-sdk mod --path my-module config get use-uv
dagger call python-sdk mod --path my-module config get base-imageChange one or more values at once (prints a diff to confirm before writing). Each flag is optional; omitting one leaves that setting untouched:
dagger call python-sdk mod --path my-module config set \
--python-version 3.13 \
--use-uv=false \
--base-image python:3.13-slimdagger generate regenerates every recorded scope. A recorded module can also
be generated on its own:
dagger call python-sdk mod --path my-module generatemod resolves recorded modules by default. For a module root that is not
recorded, pass the module root as --path and add --find-up=false:
dagger call python-sdk mod --path my-module --find-up=false generateModule dependencies are replaced by generated module clients
(dagger module client add). In a module scope the client set becomes the
module's dependency set: each client is recorded in dagger-module.toml and
its types are part of the generated bindings, and a removed client is dropped
again.
Standalone clients, in a scope without a module, are not generated yet; adding
one to a Python scope is refused and the workspace is left unchanged.
dagger checkengine-e-2-e:dev-sdk-check builds the pinned dagger/dagger#13992 engine. It
runs the SDK interface checks, initializes Python modules with default and
explicit settings, and calls a generated module.