Skip to content

CLI API

Python API for building CLI programs from Pydantic models.

from dracon import Arg, Subcommand, subcommand, dracon_program, ConfigFile, HelpSection, make_program, make_callable

Arg

Factory function that builds a CliParam(source='model', ...) record — the unified record type for CLI parameters. (CliParam also backs YAML-declared flags via !require / !set_default mapping bodies, where it's built with source='yaml'.) Arg is the ergonomic way to attach CLI metadata to a Pydantic field via Annotated:

class Config(BaseModel):
    name: Annotated[str, Arg(short="n", help="Your name")]
    output: Annotated[str, Arg(positional=True, is_file=True)]

Parameters

Parameter Type Default Description
help str None Help text. Falls back to field's description.
short str None Short flag (e.g. "n" for -n).
long str None Long flag (e.g. "name" for --name). Auto-generated from field name if not set.
positional bool False Treat as a positional argument instead of --flag.
is_file bool False Treat the value as a config file path: the file is loaded and composed as YAML, then merged into the field. Supports selectors (model.yaml@encoder).
is_flag bool None Force flag behavior (no value). None = auto-detect from bool type.
action Callable None Callback (program, value) -> Any triggered when the flag is parsed.
default_str str None Override the default value display in help text.
auto_dash_alias bool None Replace _ with - in the long flag. None inherits from the program default (True).
raw bool False Skip YAML composition; pass the CLI string value as-is.
subcommand bool False Mark this field as a subcommand discriminator (usually set via Subcommand() instead).

Subcommand

Type factory for discriminated union subcommands.

class CLI(BaseModel):
    command: Subcommand(TrainCmd, EvalCmd, discriminator='action')

Signature

Subcommand(*cmd_types, discriminator='action', **arg_kwargs)

Returns Annotated[Union[cmd_types...], Field(discriminator=...), Arg(subcommand=True, positional=True)].

The **arg_kwargs are forwarded to the inner Arg.


@subcommand

Decorator that injects a Literal discriminator field into a BaseModel.

@subcommand("train")
class TrainCmd(BaseModel):
    epochs: int = 10
    # 'action' field is auto-injected: action: Literal["train"] = "train"

Signature

@subcommand(name: str, discriminator: str = 'action')

@dracon_program

Decorator that turns a Pydantic BaseModel into a full CLI program.

@dracon_program(name="mytool", version="1.0")
class Config(BaseModel):
    input: Annotated[str, Arg(positional=True)]
    verbose: bool = False

Config.cli()  # parse sys.argv

Parameters

Parameter Type Default Description
name str Class name Program name in help output.
version str None Version string (shown with --version).
description str Class docstring Help description text.
context_types list[type] None Types added to interpolation context as {TypeName: type}.
context dict None Additional interpolation context.
deferred_paths list[str] [] Paths forced to DeferredNode.
auto_context bool False Capture types from the decorator's call site namespace.
sections list[HelpSection] None Extra sections appended to help output.
epilog str None Text at the bottom of help output.
config_files list[ConfigFile] [] Auto-discovered config files, loaded as the base layer (below CLI args).

Generated Methods

All methods are classmethods on the decorated class:

.cli(argv=None)

Parse CLI args (defaults to sys.argv[1:]), construct the model, and call .run() if defined. Config files from config_files are auto-discovered and loaded as the base layer.

.invoke(*configs, **context_kwargs)

Load from config file paths and context, construct, then call .run().

Config.invoke("train.yaml", lr=0.001)

.from_config(*configs, **context_kwargs)

Like .invoke() but returns the model instance without calling .run().

.load(path, context=None)

Low-level: load a single config file and validate as the model type.

Built-in Flags

Every @dracon_program includes:

Flag Description
-h, --help Print help panel and exit.
--trace PATH Show composition provenance for a dotted keypath.
--trace-all Show provenance for all values.

CLI Argument Parsing

The mental model is one rule: --name value targets anything declared, anywhere — a Pydantic field on the program model, or a top-level !require / !set_default in any layered config. Both surfaces share the same argparse flag set and the same --help panel.

  • +file.yaml -- load as an additional config layer (merged left to right)
  • --flag value or --flag=value -- set a named option (model field or YAML-declared variable)
  • --nested.path value -- set a nested model field by dotted path
  • ++var=value or ++var value -- set a context variable directly (escape hatch, see below)
  • --define.var=value -- long form of ++
  • Short flags cannot be combined: use -c -r -j, not -crj

++ and --define. stay as the generic context-injection rail. Reach for them when a flag has no declaration, when a model field shadows a YAML variable of the same name, or when you want to feed a raw YAML literal that sidesteps argparse coercion.

Spelling: dashes with --, underscores with ++. The --flag form auto-dash-aliases underscore names — a !set_default training_set_file: (or a model field training_set_file) is reached as --training-set-file, and the underscore spelling --training_set_file is rejected as unknown. ++ and --define. do the opposite: they take the variable's literal name with no aliasing, so it's ++training_set_file=…. An accidental ++training-set-file=… does not error — it just sets an unrelated variable that nothing reads (you'll see it in the unused-variable warning).


CLI flags from config layers

The flag mechanism described above is unified: it covers model fields and top-level !require / !set_default directives in any +-layered config. Adding a new flag for an experiment is a config edit, not a code edit — the same --name value rail handles it.

# plugins/analytics.yaml
!require api_key:
  help: "API key for the analytics service"

!set_default:int batch_size:
  default: 32
  help: "batch size"
  short: -b
mycli +plugins/analytics.yaml --api-key $SECRET -b 64
mycli +plugins/analytics.yaml --help    # shows --api-key and --batch-size

What makes a directive CLI-visible

  • It is at the top level of a layered config (a +file.yaml argument).
  • It is !require or !set_default (typed variants like !set_default:int count too, with int becoming the argparse type=).
  • It is not nested inside !fn, !deferred, or !if branches — those are inner scopes by construction.

Precedence

When the same name is declared in multiple places, the resolution order is:

model field (Pydantic)  >  YAML directive  >  context seed  >  default

Specifically:

  • A model field shadows a YAML directive of the same name. --port routes to the model field; the YAML variable is still reachable via ++port=... or --define.port=....
  • Multiple layers declaring the same name: last layer wins for the argparse metadata (help/short/default).
  • A short alias that collides with an existing model-side Arg is dropped with one warning; the long flag still works.

Why ++ still exists

++name=value and --define.name=value bypass all flag discovery. They stay because they cover three cases the unified flag rail cannot:

  • A model field shadows a YAML variable of the same name; ++ writes the YAML variable explicitly.
  • A name has no declaration anywhere — pure ad-hoc context injection.
  • A discovered flag's argparse coercion gets in the way; ++ accepts a raw YAML literal instead (e.g. ++weights="[0.1, 0.2]").

--help rendering

When more than two YAML-declared flags are discovered, --help splits them into per-source-file subsections under the model-side Options: panel:

Usage: myapp [OPTIONS]

Options:
  -e, --env <env>            environment name
  --workers <workers>        worker count [default: 4]
  -h, --help                 Print this help message

Options from analytics.yaml:
  --api-key <api_key>        API key for the analytics service
  -b, --batch-size <int>     events per upload batch

Options from db.yaml:
  --db-host <db_host>        database host [default: localhost]
  --db-port <int>            database port [default: 5432]

With two or fewer YAML flags total, the help stays flat (one Options: panel) so small layered surfaces don't get visually noisy. Layered files whose source path can't be resolved (rare — only when the static-scan fallback fires) bucket under "Options from layered configs:".

Inspecting at runtime

Set DRACON_SHOW_VARS=1 to print a table of every defined variable at the end of a CLI run. The Source column distinguishes CLI (++/--define) from CLI (--flag) (a discovered flag) and config (!define) (a value that came from the composed YAML itself).

The unused-variable warning fires when a variable was set on the CLI (via any of the above) but no ${...} interpolation, no !set_default, and no !require ever read it.


ConfigFile

Declares a config file for auto-discovery.

@dracon_program(config_files=[
    ConfigFile("~/.mytool/config.yaml"),
    ConfigFile(".mytool.yaml", search_parents=True),
])

Parameters

Parameter Type Default Description
path str required File path. Supports ~ expansion.
search_parents bool False Walk up the directory tree looking for the file. Uses cascade: loader internally. Must be a relative path.
required bool False Raise FileNotFoundError if not found.
selector str None Keypath selector appended as @selector to the include string.

HelpSection

Extra section in CLI help output.

HelpSection(title="Examples", body="  mytool train config.yaml\n  mytool eval --checkpoint best.pt")

make_program

Low-level factory. Creates a Program object without decorating a class.

prog = make_program(Config, name="mytool", version="1.0")
instance, raw_args = prog.parse_args(["--input", "data.csv"])

make_callable

Turn a YAML config file or DeferredNode into a reusable callable.

from dracon import make_callable

fn = make_callable("file:template.yaml", context_types=[MyModel])
result = fn(param1="value", param2=42)

Signature

make_callable(
    path_or_node: str | Path | DeferredNode,
    context: dict = None,
    context_types: list[type] = None,
    auto_context: bool = False,
    **loader_kwargs,
)

When given a file path, the entire file is loaded with deferred_paths=['/'] to produce a DeferredNode. The returned callable accepts **kwargs that are injected as context, then constructs the result.

Parameter Description
path_or_node File path string or existing DeferredNode.
context Base context dict (types, functions, values).
context_types List of types added as {TypeName: type}.
auto_context Capture types from the caller's namespace.
**loader_kwargs Forwarded to DraconLoader (e.g. deferred_paths, interpolation_engine).