Skip to content

Repository files navigation

Dracon

Dracon Logo

License: MIT Documentation

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.

Why Dracon?

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.

What It Looks Like

# config.yaml
log_level: ${getenv('LOG_LEVEL', 'INFO')}
workers: 2
database:
  host: db.${@/environment}.local
  port: 5432
  password: !include env:DB_PASS
from 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 8

How It Works

Dracon 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.

Quick Start

Install (not yet on PyPI):

pip install git+https://github.com/jdisset/dracon.git

Write 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.internal

Inspect the merged result before writing any Python:

dracon show base.yaml prod.yaml

Then wire it to a Pydantic model with @dracon_program and run:

python app.py +base.yaml +prod.yaml --workers 8

What You Get

  • 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_default directives that double as CLI flags: layered configs grow the flag set and --help text 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 factories
  • register_template() to bind Python callables as typed YAML symbols, parameters and docs lifted from inspect.signature
  • !cascade:NAME for predicate-keyed mappings (CSS-shaped rules, *_params cascades, route tables, ...) via pluggable dialects
  • dracon show and provenance tracing for debugging composition
  • Bidirectional vocabulary: dump/dump_to_node round-trip Pydantic models and dracon-native wrappers through the same SymbolTable that drives the load path
  • dump_line/loads_line/document_stream for line-framed wire protocols and log-replay streams
  • Live progress events: typed-tag construction and ${...} resolution emit nestable spans to any Callable[[Event], None] subscriber, zero-cost when unbound

Patterns Worth Knowing

Layered Vocabularies

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.

Hybrid Pipelines

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 Contracts

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.

References by Identity

!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 reordering

The 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.

Documentation

Acknowledgements

About

Modular config system for python based on yaml and pydantic

Resources

Stars

3 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages