YAML Functions¶
You have repeated config patterns. You want to parameterize them without copy-pasting, and compose them into pipelines.
If you are here for the bigger design patterns built on top of these primitives, see Hybrid Pipelines, Higher-Order Config, and The Open Vocabulary.
When to use what¶
Before reaching for !fn, check if a simpler tool fits:
| Pattern | Use case | Returns |
|---|---|---|
YAML anchors (&/*) |
Reuse identical subtrees within one file | Exact copy, no parameters |
!include |
Pull in a file or subtree | Static content, no parameters |
!fn (inline) |
Parameterized template, called from YAML | Mapping or scalar |
!fn file:path |
Same, but template lives in a separate file | Mapping or scalar |
!fn:dotted.path |
Wrap a Python function with stored kwargs | Whatever the function returns |
!pipe |
Chain multiple callables, output threading | Final stage's return value |
If you just need the same block twice without changes, anchors work. If you need parameters, use !fn.
Defining callables: three forms¶
From a file¶
The file at templates/endpoint.yaml becomes the function body. It can use !require and !set_default for parameters, just like an inline body. The path is resolved relative to the file containing the !fn directive.
Avoid $DIR in !fn file: paths
!fn file:$DIR/path does not work because $DIR gets transformed into ${DIR} by the shorthand variable expansion, which breaks the !fn file reference detection. Use plain relative paths instead.
Inline mapping¶
!define make_endpoint: !fn
!require name: "service name"
!set_default port: 8080
url: "https://${name}.example.com:${port}"
health: "https://${name}.example.com:${port}/health"
The body is a mapping with parameter declarations and the template content mixed together. !require and !set_default lines are stripped from the output; they define the template's interface.
Each !fn template is a symbol in the open vocabulary. Its interface() surfaces the declared parameters as structured InterfaceSpec data, which drives tag invocation, error messages, and the --symbols CLI output.
Expression lambda¶
For when the whole function is a single expression. The result is whatever the expression evaluates to.
Scalar return with !fn key¶
Sometimes you want a function that takes parameters but returns a single value, not a mapping. Use !fn as a key inside the body to mark the return value:
!define connection_string: !fn
!require host: "database host"
!set_default port: 5432
!set_default db: "myapp"
!fn : "postgresql://${host}:${port}/${db}"
db_url: !connection_string { host: db.prod.internal }
# result: "postgresql://db.prod.internal:5432/myapp"
Without the !fn : return marker, calling this would produce a mapping. The marker says "return this value instead."
You can also use !fn : without an outer !fn tag on the !define. If Dracon sees a !fn key inside a !define body, it implicitly treats the whole thing as a callable:
Same result, slightly less nesting.
Calling from YAML (tag syntax)¶
Any callable in scope can be used as a YAML tag. The tag name is the variable name with a ! prefix:
!define make_endpoint: !fn
!require name: "service name"
!set_default port: 8080
url: "https://${name}.example.com:${port}"
endpoints:
api: !make_endpoint { name: api, port: 443 }
admin: !make_endpoint { name: admin }
docs: !make_endpoint
name: docs
port: 9090
Both flow syntax ({ key: value }) and block syntax work. The result is the template body with arguments substituted in:
endpoints:
api:
url: https://api.example.com:443
admin:
url: https://admin.example.com:8080
docs:
url: https://docs.example.com:9090
This works for any callable in context, not just !fn templates. Python functions passed via context work too.
Alias complex callable choices before tagging¶
If the callable choice is simple, dynamic tags are fine:
But once the expression gets longer, the nicer pattern is to alias it first and then use a normal tag:
This gets even more useful when the callable comes from a runtime or computed selection:
This is easier to read, easier to reuse, and more YAML-friendly than trying to cram a long call directly into a tag.
Calling from expressions¶
Inside ${...}, call functions with Python syntax:
This is useful for list comprehensions, conditionals, and chaining:
!define names:
- api
- admin
- docs
all_urls: ${[make_endpoint(name=n)['url'] for n in names]}
primary: ${make_endpoint(name='api') if production else make_endpoint(name='dev-api')}
If the result of an expression call is meant to become a tag, prefer aliasing it first:
That keeps the selection logic in expression land and the constructed value in plain YAML.
Parameters: !require and !set_default¶
!require name: "hint"-- mandatory. If the caller doesn't provide it, composition fails with an error that includes the hint text.!set_default port: 8080-- optional. Uses 8080 if the caller doesn't override it.
Both are stripped from the output. They only define the callable's interface.
!define make_service: !fn
!require name: "service identifier"
!require region: "deployment region"
!set_default replicas: 1
!set_default health_path: "/health"
endpoint: "https://${name}.${region}.example.com"
health: "https://${name}.${region}.example.com${health_path}"
replicas: ${replicas}
Isolation¶
Each call gets a fresh scope. Variables set inside one call don't leak into the next:
!define counter: !fn
!require x: "input"
!define doubled: ${x * 2}
result: ${doubled}
a: !counter { x: 3 } # result: 6
b: !counter { x: 5 } # result: 10
# 'doubled' from the first call doesn't affect the second
The template node is deep-copied before each invocation, so there's no shared mutable state between calls.
!fn:path -- partial application of Python functions¶
!fn:path wraps a Python function (identified by its dotted import path) with optional pre-filled keyword arguments. The result is a DraconPartial: a callable that's serializable via both pickle and YAML.
!define sqrt: !fn:math.sqrt
!define my_transform: !fn:myproject.transforms.normalize { strategy: "minmax" }
Call them from expressions:
Or use as a tag when no args are pre-filled:
!define greet: !fn:myproject.utils.greet { greeting: hey }
message: ${greet(name='world')} # "hey world"
Resolution order¶
When Dracon encounters !fn:some.name, it looks up the function in this order:
- The current loader context (variables passed via
contextor!define) - Dotted import from Python's module system
This means you can override an importable function with a context variable of the same name.
Serialization¶
DraconPartial is pickle-safe and round-trips through YAML. When dumped to YAML, it produces !fn:dotted.path { kwargs }. When pickled, it stores the path and kwargs, then re-imports the function on unpickle.
Context-only names (no dots) can't be pickled since there's no import path to reconstruct from.
!fn vs !fn:path¶
!fn (template) |
!fn:path (partial) |
|
|---|---|---|
| Wraps | YAML template | Python function |
| Parameters | !require / !set_default |
Function signature |
| Serializable | YAML only | YAML + pickle |
| Isolation | Full (deepcopy per call) | Standard Python |
| Use case | Config generation | Connecting Python code to YAML |
!pipe -- function composition¶
!pipe chains multiple callables into a pipeline. The output of each stage feeds into the next.
Output threading¶
How the output of one stage reaches the next depends on its type:
- Mapping output: kwarg-unpacked into the next stage. If
cleanreturns{'data': [...], 'stats': {...}}, thentrainreceivesdata=[...]andstats={...}as keyword arguments. - Typed (non-mapping) output: passed as a single positional value to the next stage's lone unfilled
!requireparameter. Ifcleanreturns a list, it fills whatever!requireparametertrainhas that isn't already satisfied.
Pre-filling kwargs per stage¶
You can give per-stage keyword arguments using mapping syntax:
!define process: !pipe
- load_data
- clean: { strategy: aggressive, min_length: 10 }
- train: { epochs: 50 }
The pre-filled kwargs are merged with the piped output. Pre-filled values take priority over values from the previous stage.
Mixing !fn templates and !fn:path¶
Pipe stages can be any callable: !fn templates, !fn:path partials, context variables, or expression references.
!define normalize: !fn
!require data: "input data"
!set_default method: "zscore"
result: ${do_normalize(data, method)}
!define pipeline: !pipe
- normalize
- !fn:myproject.models.fit { max_iter: 100 }
Pipes compose with pipes¶
If a pipe stage is itself a DraconPipe, its stages are flattened into the parent. No nesting overhead:
!define preprocess: !pipe
- load
- clean
!define full: !pipe
- preprocess # flattened: load, clean
- train
full has three stages, not two.
Calling a pipe¶
Pipes are called like any other callable:
The initial kwargs go to every stage (each stage picks what it needs). The first stage also receives no piped value; subsequent stages get both the piped output and the initial kwargs.
Recipes¶
Service config factory¶
Generate config blocks for multiple services from a template:
!define make_service: !fn
!require name: "service name"
!set_default port: 8080
!set_default replicas: 1
url: "https://${name}.example.com:${port}"
health_check: "https://${name}.example.com:${port}/health"
replicas: ${replicas}
services:
api: !make_service { name: api, port: 443, replicas: 3 }
auth: !make_service { name: auth, port: 443, replicas: 2 }
docs: !make_service { name: docs }
Map over a collection¶
With !each:
!define make_check: !fn
!require site: "domain"
url: "https://${site}"
interval: 30
!define sites:
- example.com
- api.example.com
checks:
!each(site) ${sites}:
${site}: !make_check { site: "${site}" }
With an expression:
Nested composition¶
Functions can call other functions:
!define make_endpoint: !fn
!require name: "name"
!set_default port: 8080
url: "https://${name}.example.com:${port}"
!define make_service: !fn
!require name: "service name"
!set_default port: 8080
!set_default replicas: 1
endpoint: ${make_endpoint(name=name, port=port)}
replicas: ${replicas}
monitoring:
url: ${make_endpoint(name=name, port=port)['url']}/metrics
Each nested call is fully isolated. The inner make_endpoint calls don't share state with each other or with the outer make_service.