Dracon turns YAML configs into composable, type-safe Python objects. Define your schema as a Pydantic model, write config in YAML with includes and expressions, and get a validated CLI from the same model.
Most config systems I've had the pleasure to deal with were either:
- Too simple: "just a dict,
argparse, and pain" - Too magical: opaque frameworks that make it weirdly hard to tell what config you are actually running
- Too rigid: powerful, but with a pretty strong idea of the Proper Way, and somehow you end up fighting the config system instead of getting work done
I built Dracon to hit the "powerful but transparent" middle ground. Especially in ML and research codebases, config tends to come from everywhere at once: package defaults, local files, environment variables, layered overrides, CLI flags, runtime values, and random YAML fragments living in places they probably shouldn't. Dracon gives you a small set of tools to catch all of that and turn it into something explicit, typed, declarative, highly composable and easy to work with.
# config.yaml
log_level: ${getenv('LOG_LEVEL', 'INFO')}
workers: 2
database:
host: db.${@/environment}.local
port: 5432
password: !include env:DB_PASSfrom typing import Annotated
from pydantic import BaseModel
from dracon import Arg, dracon_program
class DatabaseConfig(BaseModel):
host: str
port: int
password: str
@dracon_program(name="myapp")
class App(BaseModel):
environment: Annotated[str, Arg(short="e")]
log_level: str = "INFO"
workers: int = 1
database: DatabaseConfig
if __name__ == "__main__":
App.cli()python app.py +config.yaml -e prod --workers 8Dracon processes configuration in three phases:
- Compose: parse YAML, resolve includes, apply merges, run instructions like
!define,!if, and!each - Construct: turn the resulting node tree into Python objects and validate typed models with Pydantic
- Resolve: evaluate lazy
${...}expressions when needed, and construct deferred subtrees later if they depend on runtime context
That separation is a big part of the point. You can inspect the composed config before construction with dracon show, instead of treating config loading as one opaque step.
Install (not yet on PyPI):
pip install git+https://github.com/jdisset/dracon.gitWrite two config files:
# base.yaml
environment: dev
workers: 1
database:
host: localhost
port: 5432
<<: !include file:$DIR/db.yaml# prod.yaml
environment: prod
workers: 4
database:
host: db.prod.internalInspect the merged result before writing any Python:
dracon show base.yaml prod.yamlThen wire it to a Pydantic model with @dracon_program and run:
python app.py +base.yaml +prod.yaml --workers 8- Layered configs with
!include, merge keys, selectors, and optional overlays - Standard CLIs generated from Pydantic models, with nested overrides and config-file layering
!require/!set_defaultdirectives that double as CLI flags: layered configs grow the flag set and--helptext without touching the model- YAML callables with
!fn,!fn:path, and!pipe - Runtime deferral with
!deferred(subtree-axis) and!live(variable-axis late binding) make_callable()for turning YAML into reusable Python factoriesregister_template()to bind Python callables as typed YAML symbols, parameters and docs lifted frominspect.signature!cascade:NAMEfor predicate-keyed mappings (CSS-shaped rules,*_paramscascades, route tables, ...) via pluggable dialectsdracon showand provenance tracing for debugging composition- Bidirectional vocabulary:
dump/dump_to_noderound-trip Pydantic models and dracon-native wrappers through the sameSymbolTablethat drives the load path dump_line/loads_line/document_streamfor line-framed wire protocols and log-replay streams- Live progress events: typed-tag construction and
${...}resolution emit nestable spans to anyCallable[[Event], None]subscriber, zero-cost when unbound
Vocabulary files can build on other vocabulary files, so users only see the higher-level tags:
# infra.yaml
!define Service: !fn
!require name: "service name"
!set_default port: 8080
!fn :
url: "https://${name}.internal:${port}"
# ml.yaml
<<(<): !include pkg:mylib:infra.yaml
!define Experiment: !fn
!require name: "experiment"
!fn :
api: !Service { name: "${name}-api", port: 443 }
# config.yaml
<<(<): !include pkg:mylib:ml.yaml
run: !Experiment { name: genomics-v2 }So a config vocabulary can layer cleanly instead of flattening back into Python every time it grows.
Pipelines can stay in YAML even when the stages are ordinary Python functions:
!define vit_pipeline: !pipe
- load_data
- validate: { minimum: 2 }
- train_vit
report: ${vit_pipeline(source='s3://raw')}That gives you config-defined workflow shape without needing to move the actual stage logic out of Python.
Runtime-only config does not need to turn into hand-written glue:
reporting: !deferred
!require run_id: "runtime run identifier"
!assert ${len(run_id) > 0}: "run_id must not be empty"
output_dir: "/runs/${run_id}"
summary:
path: "/runs/${run_id}/summary.json"reporting = config["reporting"].construct(
context={"run_id": run_id},
)So the config itself declares what it needs at runtime and what should happen once those values exist.
!ref / !refs point at other nodes by what they are, not where they sit. A locator carries axes (nearest-enclosing, sibling, ancestor) and predicates, resolved lazily over the constructed tree:
services:
api: { enabled: true, port: 8080 }
worker: { enabled: false, port: 8081 }
cron: { enabled: true, port: 8082 }
monitoring:
scrape: !refs /services.*[enabled].port # [8080, 8082] — truthy filter
pipeline:
- id: load
out: raw
- id: clean
in: !ref ^.*[id=load].out # by identity — survives reorderingThe same locator engine backs predicate-keyed !cascade dialects, so one grammar covers references, styling, and attachment.
The docs go deeper on these in the Patterns section.