.contributing/python-decorator-guide.md
This guide explains how to add new decorators (Python's equivalent of Go pragmas) to the Dagger Python SDK that integrate with the GraphQL API.
The Python SDK uses runtime decorators that store metadata on functions, which is then used during module registration to call the appropriate Dagger API methods. Unlike TypeScript decorators (which are no-ops parsed via AST introspection), Python decorators are actual functions that execute at module load time.
Before adding a new decorator parameter:
dagql/server.go (see main contributor guide)core/schema/module.go (e.g., functionWithCheck)sdk/python/src/dagger/client/gen.pyThe Python decorator system has 4 key components:
_module.py: The Module.function() decorator method that accepts parameters_types.py: The FunctionDefinition dataclass that stores metadata_module.py: The Module._typedefs() method that registers functions with the APIclient/gen.py: The generated API client with methods like with_check()function() DecoratorFile: sdk/python/src/dagger/mod/_module.py (around line 630)
Add your parameter to the function() decorator method signature:
def function(
self,
fn: Callable[..., Any] | None = None,
*,
name: str | None = None,
doc: str | None = None,
check: bool = False, # ADD YOUR PARAMETER HERE
) -> Any:
"""Register a function to include in the module's API.
Args:
fn: The function to register.
name: Override the function's name.
doc: Override the function's docstring.
check: Mark this function as a check. # ADD DOCUMENTATION
"""
Notes:
*)False for booleans, None for optional values)FunctionDefinition DataclassFile: sdk/python/src/dagger/mod/_types.py (around line 19)
Add a field to store your metadata:
@dataclass(frozen=True, slots=True)
class FunctionDefinition:
"""Metadata about a function exposed in the module's API."""
name: str | None = None
doc: str | None = None
cache: CachePolicy | None = None
deprecated: str | None = None
check: bool = False # ADD YOUR FIELD HERE
Notes:
__slots__ for efficiencyFunctionDefinitionFile: sdk/python/src/dagger/mod/_module.py (around line 671)
Update the FunctionDefinition instantiation to include your parameter:
def decorator(fn: Callable[..., Any]) -> Any:
fn_def = FunctionDefinition(
name=name,
doc=doc,
cache=cache,
deprecated=deprecated,
check=check, # ADD YOUR PARAMETER HERE
)
setattr(fn, _DEFINITION_METADATA_NAME, fn_def)
setattr(self, fn.__name__, Function(fn, parent=self))
return fn
Notes:
_DEFINITION_METADATA_NAME constantFile: sdk/python/src/dagger/mod/_module.py (around line 207 in _typedefs())
Add logic to check your field and call the appropriate API method:
# Build the function definition
fn_def: Function = (
api_mod.with_function(py_func.name)
.with_description(py_func.doc or "")
)
# Apply cache policy if set
if defn.cache is not None:
fn_def = fn_def.with_cache_policy(
max_age=defn.cache.max_age,
max_concurrent=defn.cache.max_concurrent,
)
# Apply deprecated marker if set
if defn.deprecated is not None:
fn_def = fn_def.with_deprecated(defn.deprecated)
# ADD YOUR CHECK HERE
if defn.check:
fn_def = fn_def.with_check()
# Continue with arguments...
for arg in py_func.parameters:
# ...
Notes:
_typedefs() method iterates through all registered functionswith_*() method returns a new Function object (fluent API)Create a test module to verify the decorator works:
from dagger import function, object_type
@object_type
class MyModule:
@function(check=True)
def my_check(self) -> str:
"""A check function."""
return "all good"
Run the module and verify the GraphQL schema includes the @check directive:
dagger develop --sdk=python
dagger functions # Should show my-check function
Use case: Simple on/off feature (e.g., @function(check=True))
# Step 1: Decorator parameter
def function(self, fn=None, *, check: bool = False) -> Any:
...
# Step 2: Dataclass field
@dataclass(frozen=True, slots=True)
class FunctionDefinition:
check: bool = False
# Step 3: Store value
fn_def = FunctionDefinition(check=check)
# Step 4: Call API
if defn.check:
fn_def = fn_def.with_check()
Use case: Single configuration value (e.g., @function(default_path="./config"))
# Step 1: Decorator parameter
def function(self, fn=None, *, default_path: str | None = None) -> Any:
...
# Step 2: Dataclass field
@dataclass(frozen=True, slots=True)
class FunctionDefinition:
default_path: str | None = None
# Step 3: Store value
fn_def = FunctionDefinition(default_path=default_path)
# Step 4: Call API
if defn.default_path is not None:
fn_def = fn_def.with_default_path(defn.default_path)
Use case: Multiple values (e.g., @function(ignore=["node_modules", ".git"]))
# Step 1: Decorator parameter
def function(self, fn=None, *, ignore: list[str] | None = None) -> Any:
...
# Step 2: Dataclass field
@dataclass(frozen=True, slots=True)
class FunctionDefinition:
ignore: list[str] | None = None
# Step 3: Store value
fn_def = FunctionDefinition(ignore=ignore or [])
# Step 4: Call API
if defn.ignore:
fn_def = fn_def.with_ignore(defn.ignore)
Use case: Complex configuration object
# Define the options dataclass in _types.py
@dataclass(frozen=True, slots=True)
class CachePolicy:
max_age: int | None = None
max_concurrent: int | None = None
# Step 1: Decorator parameter
def function(self, fn=None, *, cache: CachePolicy | None = None) -> Any:
...
# Step 2: Dataclass field
@dataclass(frozen=True, slots=True)
class FunctionDefinition:
cache: CachePolicy | None = None
# Step 3: Store value
fn_def = FunctionDefinition(cache=cache)
# Step 4: Call API with unpacked values
if defn.cache is not None:
fn_def = fn_def.with_cache_policy(
max_age=defn.cache.max_age,
max_concurrent=defn.cache.max_concurrent,
)
Some decorators apply to function arguments rather than functions. Python doesn't have first-class syntax for this, so the pattern uses type annotations:
Annotated for Argument Metadatafrom typing import Annotated
from dagger import Doc, DefaultPath
@function
def my_function(
self,
# Argument with documentation
name: Annotated[str, Doc("The name to use")],
# Argument with default path
config: Annotated[str, DefaultPath("./config.yaml")],
) -> str:
...
Implementation: These use typing.Annotated to attach metadata to type hints. The introspection code in _arguments.py extracts this metadata during module registration.
Adding a new argument decorator:
_types.py (e.g., class MyMarker)__init__.py_arguments.py to extract the marker from Annotated typeswith_*() method when building arguments in _typedefs()| File | Purpose | What to Change |
|---|---|---|
_module.py | Module class with decorator methods | Add decorator parameter, store in FunctionDefinition, check in _typedefs() |
_types.py | Dataclass definitions | Add field to FunctionDefinition |
_resolver.py | Function wrapper | Usually no changes needed (metadata flows through FunctionDefinition) |
client/gen.py | Generated API client | Read-only (regenerated from GraphQL schema) |
__init__.py | Public exports | Export new marker classes for argument decorators |
_arguments.py | Argument introspection | Extract Annotated metadata for argument decorators |
If you add a new API method to core/schema/module.go, you must regenerate the Python SDK:
dagger develop --sdk=python
# or
make sdk-generate
Without this, with_my_feature() won't exist in client/gen.py.
The function() decorator is generic and returns Any to avoid type checking issues. This is intentional:
def function(self, fn=None, *, ...) -> Any:
# Returns Any because decorated functions keep their signatures
FunctionDefinition is frozen, so you can't modify it after creation:
# ❌ This will raise an error
fn_def.check = True
# ✅ Create a new instance instead
fn_def = FunctionDefinition(check=True)
Make sure defaults match across decorator parameter and dataclass field:
# Decorator parameter default
def function(self, fn=None, *, check: bool = False):
# Dataclass field default
@dataclass(frozen=True)
class FunctionDefinition:
check: bool = False # Should match!
For list parameters, use None as the default and convert to empty list when storing:
# Decorator parameter
def function(self, fn=None, *, ignore: list[str] | None = None):
...
# Store as empty list if None
fn_def = FunctionDefinition(ignore=ignore or [])
# Check for non-empty list
if defn.ignore:
fn_def = fn_def.with_ignore(defn.ignore)
| Aspect | Python | TypeScript | Go |
|---|---|---|---|
| Syntax | @function(check=True) | @func() @check() | // +check |
| Mechanism | Runtime decorator | AST introspection | Comment parsing |
| Storage | FunctionDefinition dataclass | DaggerFunction properties | FunctionArg struct |
| Parsing | At module load | During introspection | During codegen |
| Registration | _typedefs() method | register.ts | module_funcs.go |
| Type Safety | Runtime (type hints) | Compile-time (TypeScript) | Compile-time (Go) |
@function(check=True)Here's a complete example of adding the check decorator parameter:
_types.py: Add field@dataclass(frozen=True, slots=True)
class FunctionDefinition:
name: str | None = None
doc: str | None = None
cache: CachePolicy | None = None
deprecated: str | None = None
check: bool = False # NEW
_module.py: Add parameterdef function(
self,
fn: Callable[..., Any] | None = None,
*,
name: str | None = None,
doc: str | None = None,
check: bool = False, # NEW
) -> Any:
"""Register a function to include in the module's API.
Args:
fn: The function to register.
name: Override the function's name.
doc: Override the function's docstring.
check: Mark this function as a check. # NEW
"""
_module.py: Store valuedef decorator(fn: Callable[..., Any]) -> Any:
fn_def = FunctionDefinition(
name=name,
doc=doc,
cache=cache,
deprecated=deprecated,
check=check, # NEW
)
setattr(fn, _DEFINITION_METADATA_NAME, fn_def)
setattr(self, fn.__name__, Function(fn, parent=self))
return fn
_module.py: Register with API# In _typedefs() method, after building fn_def
if defn.check:
fn_def = fn_def.with_check() # NEW
from dagger import function, object_type
@object_type
class MyModule:
@function(check=True)
def lint(self) -> str:
"""Check code style."""
return "✓ All checks passed"
After implementing your decorator:
sdk/python/tests/ verifying metadata storagewith_*() method is called correctly# Run unit tests
cd sdk/python
pytest tests/
# Test a sample module
cd /tmp
dagger init --sdk=python my-test
# Edit dagger.json module file with @function(check=True)
dagger functions # Should show the check function
dagger call lint # Should execute successfully
Symptom: TypeError: function() got an unexpected keyword argument 'check'
Solution: Make sure you added the parameter to the function() method signature in _module.py.
Symptom: AttributeError: 'Function' object has no attribute 'with_check'
Solution: Regenerate the SDK after adding the API method to core/schema/module.go:
dagger develop --sdk=python
Symptom: GraphQL schema doesn't include @check directive
Solution: Verify the directive exists in dagql/server.go and the API method chains correctly in _typedefs().
Symptom: Decorator parameter is ignored during registration
Solution: Check that you:
FunctionDefinitionFunctionDefinition_typedefs() before calling the API methodAdding a decorator to the Python SDK requires 4 file changes:
_module.py: Add decorator parameter to function() method_types.py: Add field to FunctionDefinition dataclass_module.py: Store parameter in FunctionDefinition instance_module.py: Check field and call API method in _typedefs()The pattern is: Decorator parameter → Dataclass field → API method call
Each decorator parameter flows through this pipeline, ultimately calling a generated API method that sets the corresponding GraphQL directive.