CLI Patterns¶
You want a CLI that goes beyond basic flags. Subcommands, auto-discovered config files, file arguments, custom actions, programmatic config factories.
Subcommands with discriminated unions¶
Use this when your tool has distinct modes (train/eval, deploy/rollback, etc.) each with their own options.
Define subcommand models¶
Each subcommand is a Pydantic model with a Literal discriminator field:
from typing import Literal, Annotated
from pydantic import BaseModel
from dracon import Arg, Subcommand, dracon_program
class TrainCmd(BaseModel):
"""Train a model."""
action: Literal['train'] = 'train'
epochs: Annotated[int, Arg(help="Number of epochs")] = 10
lr: Annotated[float, Arg(help="Learning rate")] = 0.001
def run(self, ctx):
print(f"Training for {self.epochs} epochs, lr={self.lr}")
print(f"Verbose: {ctx.verbose}")
class EvalCmd(BaseModel):
"""Evaluate a model on test data."""
action: Literal['eval'] = 'eval'
dataset: Annotated[str, Arg(help="Test dataset path")] = "test.csv"
def run(self, ctx):
print(f"Evaluating on {self.dataset}")
Wire them into the root model¶
@dracon_program(name="ml")
class MLConfig(BaseModel):
verbose: Annotated[bool, Arg(short="v", help="Verbose output")] = False
command: Subcommand(TrainCmd, EvalCmd)
if __name__ == "__main__":
MLConfig.cli()
Subcommand(TrainCmd, EvalCmd) creates an Annotated[Union[TrainCmd, EvalCmd], ...] with the right discriminator and Arg(subcommand=True, positional=True) metadata. The discriminator field defaults to action; pass discriminator="cmd" to change it.
Less boilerplate with @subcommand¶
The @subcommand decorator injects the discriminator field for you:
from dracon import subcommand
@subcommand("train")
class TrainCmd(BaseModel):
"""Train a model."""
epochs: Annotated[int, Arg(help="Number of epochs")] = 10
@subcommand("eval")
class EvalCmd(BaseModel):
"""Evaluate a model on test data."""
dataset: Annotated[str, Arg(help="Test dataset path")] = "test.csv"
No need to write action: Literal['train'] = 'train' yourself.
Running it¶
The subcommand's .run(ctx) method receives the parent config as ctx, so it can access shared options like verbose.
Config file scoping¶
Config files placed before the subcommand name merge into the root config. Files after merge into the subcommand:
Here, global.yaml is root-scoped (can set verbose, etc.) and train-config.yaml is scoped to the train subcommand.
Per-subcommand help¶
The docstring on each subcommand model appears as the command description.
ConfigFile for auto-discovered configs¶
Use this when you want your tool to automatically pick up config files from known locations.
from dracon import dracon_program, ConfigFile
@dracon_program(
name="deploy",
config_files=[
ConfigFile("~/.deploy/config.yaml"),
ConfigFile(".deploy.yaml", search_parents=True),
],
)
class DeployConfig(BaseModel):
target: str = "staging"
replicas: int = 1
~/.deploy/config.yamlis checked once (expanded with~). If it exists, it's loaded..deploy.yamlwithsearch_parents=Trueuses the cascade loader: walks up from CWD, collects all.deploy.yamlfiles, merges them root-first (closest wins).
The precedence order:
So a user can always override auto-discovered values with explicit +file or --flag arguments.
Real-world pattern¶
A tool with home-dir defaults and project-local config:
Running deploy from /repo/services/api/ automatically loads the home-dir config, then overlays the project-local one. No +file arguments needed.
File arguments (is_file=True)¶
Use this when a CLI argument should load a YAML file as config instead of being treated as a string.
@dracon_program(name="predict")
class PredictConfig(BaseModel):
model_config_file: Annotated[
ModelConfig,
Arg(is_file=True, help="Path to model config YAML"),
]
input: str = "data.csv"
The value of --model-config-file is loaded as YAML and validated against ModelConfig. It's not just a file path string.
You can combine this with a selector:
This extracts the encoder subtree from models.yaml and validates it.
Action callbacks¶
Use this when you want a flag to trigger a side effect (like exporting the config and exiting).
def export_json(program, config):
import json
print(json.dumps(config, indent=2, default=str))
raise SystemExit(0)
@dracon_program(name="myapp")
class MyConfig(BaseModel):
port: int = 8080
export: Annotated[
bool,
Arg(action=export_json, help="Export config as JSON and exit"),
] = False
The action callback receives the Program instance and the parsed config dict. It runs after parsing but before model validation.
Raw arguments (raw=True)¶
Use this when a field should receive its value as-is, without YAML parsing or interpolation.
@dracon_program(name="runner")
class RunnerConfig(BaseModel):
command: Annotated[
str,
Arg(positional=True, raw=True, help="Shell command to run"),
]
Without raw=True, the ${HOME} would be treated as a Dracon interpolation. With it, the string is passed through untouched.
Good for JSON strings, shell commands, regex patterns, or anything that might clash with Dracon's ${...} syntax.
make_callable() for config factories¶
Use this when you want to turn a YAML config into a reusable Python callable. Good for creating objects from config templates programmatically.
from dracon import make_callable
create_model = make_callable("model.yaml", context_types=[ModelConfig])
# model.yaml
!set_default layers: 3
!set_default lr: 0.001
!ModelConfig
architecture: transformer
layers: ${layers}
learning_rate: ${lr}
# each call constructs a fresh config
small = create_model(layers=2, lr=0.01)
large = create_model(layers=12, lr=0.0001)
The YAML file is loaded once (as a deferred template). Each call to the returned function injects the kwargs as context and constructs a fresh result.
You can also build a callable from an existing DeferredNode:
from dracon import DraconLoader, make_callable
loader = DraconLoader(deferred_paths=['/'])
node = loader.load("model.yaml")
create_model = make_callable(node)
Options:
context_types=[MyType]makes types available for!MyTypetagscontext={'key': value}provides base context (overridden by call-time kwargs)auto_context=Truecaptures types from the caller's namespace
Python API: .invoke(), .from_config(), .load()¶
The @dracon_program decorator adds several class methods beyond .cli():
.invoke(configs, *context)¶
Load config files, validate, and run. Returns whatever .run() returns:
The positional args are config file paths (automatically prefixed with + if needed). The keyword args are injected as context variables.
.from_config(configs, *context)¶
Same as .invoke() but skips the .run() call. Returns the validated model instance:
Good for tests, or when you need the config object without executing the program.
.load(path, context=None)¶
Low-level: loads a single file through the Dracon loader and validates against the model:
This bypasses the CLI argument parsing entirely. No +file merging, no auto-discovered configs.