How-To: Customize CLI Arguments¶
Dracon automatically generates CLI arguments from your Pydantic model, but you can customize them using typing.Annotated and dracon.Arg.
Basic Customization (Arg)¶
Import Arg and use it within Annotated on your model fields.
from pydantic import BaseModel
from typing import Annotated
from dracon import Arg
class CliConfig(BaseModel):
input_file: Annotated[str, Arg(
positional=True, # Make it a positional arg (order matters)
help="Path to the input data file." # Custom help text
)]
output_dir: Annotated[str, Arg(
short='o', # Add a short flag -o
long='output-directory', # Custom long flag --output-directory
help="Directory to save results."
)]
threshold: Annotated[float, Arg(help="Processing threshold.")] = 0.5
verbose: Annotated[bool, Arg(
short='v',
help="Enable verbose output."
# is_flag=True is automatic for bool
)] = False
force_update: Annotated[bool, Arg(
long='force', # only long flag --force
short=None # explicitly disable short flag
)] = False
Common Arg Parameters:
short: A single character for the short flag (e.g.,'o'for-o). Default derived if possible, otherwise none.long: String for the long flag (e.g.,'output-directory'for--output-directory). Default is derived from field name (e.g.,output_dir->output-dir).help: Description shown in the--helpmessage.positional:Trueto make the argument positional instead of an option. Order defined by field order in the model.is_flag:Truefor boolean flags (no value needed, presence meansTrue).Falseto require an explicit value (--verbose true). Default isTrueforbooltypes.default_str: Custom string representation of the default value for the help message (overrides automatic formatting).
Marking Arguments for File Loading (is_file)¶
If an argument represents a path to another configuration file that Dracon should load and merge, set is_file=True. This tells the CLI parser to treat the provided value like a +filename argument internally.
from pydantic import BaseModel
from typing import Annotated
from dracon import Arg
class SecretsConfig(BaseModel):
api_key: str
secret_token: str
class MainConfig(BaseModel):
base_url: str
# This argument expects a path to a YAML file defining SecretsConfig
secrets: Annotated[SecretsConfig, Arg(
is_file=True, # Treat the value as a file path to load
help="Path to secrets YAML file."
)]
Usage:
# Pass the path to the secrets file directly
$ python your_app.py --base-url http://example.com --secrets path/to/my_secrets.yaml
Dracon's CLI parser will see --secrets path/to/my_secrets.yaml, recognize is_file=True, and internally treat it as if +path/to/my_secrets.yaml was given, loading and merging its contents into the secrets field of the MainConfig object (expecting !SecretsConfig tag or structure match).
Delaying Value Processing (resolvable=True)¶
Sometimes, you need to process an argument's value after the initial CLI parsing, perhaps based on other arguments or loaded config. Use resolvable=True combined with dracon.Resolvable type hint.
from pydantic import BaseModel
from typing import Annotated
from dracon import Arg, Resolvable, construct
class PostProcessingConfig(BaseModel):
input_path: Annotated[str, Arg(positional=True)]
# We want to finalize the output path later
output_path: Annotated[Resolvable[str], Arg(
resolvable=True, # Mark for deferred resolution
help="Output path pattern (e.g., '{input}_out.txt')."
)]
# --- In your main script ---
# config, _ = program.parse_args(...) # Parse args as usual
# 'config.output_path' is a Resolvable object here
# Perform logic based on other args/config
final_output = construct(
config.output_path,
context={'input': config.input_path} # Provide context needed for resolution
)
# Now 'final_output' is the resolved string
print(f"Final output path: {final_output}")
See the Deferred Execution Guide for more on Resolvable.
Post-Parse Actions (action)¶
Execute a function after the full config is generated. The action receives the Program instance and the validated config object. If it returns a non-None value, that value replaces the config.
import json, sys
def export_json(program, config):
"""Export the final config as JSON and exit."""
print(json.dumps(config.model_dump(), indent=2))
sys.exit(0)
class AppConfig(BaseModel):
export: Annotated[bool, Arg(
short='x',
action=export_json,
help="Export final config as JSON and exit."
)] = False
log_level: str = "INFO"
workers: int = 4
Actions are collected during parsing and executed after config generation, so they have access to the fully merged and validated config.
Collection Arguments (Lists and Dictionaries)¶
Dracon supports user-friendly syntaxes for list and dictionary arguments, making it easy to pass complex data structures via the command line.
List Arguments¶
For fields typed as List[T], Tuple[T, ...], Set[T], or other list-like containers, Dracon accepts multiple input formats:
from pydantic import BaseModel
from typing import Annotated, List, Tuple, Set
from dracon import Arg
class CollectionConfig(BaseModel):
tags: Annotated[List[str], Arg(help="List of tags to apply.")] = ["default"]
coordinates: Annotated[Tuple[int, ...], Arg(help="Coordinate values.")] = ()
categories: Annotated[Set[str], Arg(help="Unique categories.")] = set()
Usage options:
# Space-separated values (intuitive)
$ python app.py --tags web api backend --coordinates 10 20 30
# Traditional YAML/JSON syntax (also supported)
$ python app.py --tags "['web', 'api', 'backend']" --coordinates "(10, 20, 30)"
# For positional list arguments
$ python app.py web api backend # if tags is marked positional=True
Dictionary Arguments¶
For fields typed as Dict[K, V] or other dict-like containers, Dracon provides multiple convenient syntaxes:
from pydantic import BaseModel
from typing import Annotated, Dict, Any
from dracon import Arg
class ConfigWithDict(BaseModel):
settings: Annotated[Dict[str, Any], Arg(help="Configuration settings.")] = {}
metadata: Annotated[Dict[str, str], Arg(help="Additional metadata.")] = {}
Usage options:
# Key=value pairs (shell-friendly)
$ python app.py --settings debug=true port=8080 host=localhost
# Nested keys with dot notation
$ python app.py --settings app.name=myapp app.version=1.0 cache.enabled=true
# Mixed approaches
$ python app.py --settings timeout=30 database.host=db.example.com
# Traditional JSON syntax (also supported)
$ python app.py --settings '{"debug": true, "port": 8080}'
# For positional dict arguments
$ python app.py debug=true port=8080 # if settings is marked positional=True
Important Notes:
- When using positional arguments, only one collection argument (list or dict) is allowed per command
- Values are automatically quote-stripped if wrapped in single or double quotes
- Nested dictionary keys use dot notation:
parent.child.key=value - Both syntaxes can be mixed with file loading:
--settings +config.yaml debug=true
Subcommands¶
Use subcommands to split your CLI into distinct actions, each with their own arguments — like git commit, git push, etc.
Define Subcommand Models¶
Each subcommand is a BaseModel with an action discriminator field and (optionally) a .run(ctx) method:
from dracon import Arg, Subcommand, dracon_program
from pydantic import BaseModel
from typing import Annotated, Literal
class TrainCmd(BaseModel):
"""Train a model on the dataset."""
action: Literal['train'] = 'train'
epochs: Annotated[int, Arg(help="Number of training epochs")] = 10
lr: float = 0.001
def run(self, ctx):
# ctx is the root CLI instance — access shared options here
print(f"Training (verbose={ctx.verbose}) for {self.epochs} epochs")
class EvalCmd(BaseModel):
"""Evaluate model performance."""
action: Literal['eval'] = 'eval'
dataset: Annotated[str, Arg(help="Path to test dataset")]
def run(self, ctx):
print(f"Evaluating on {self.dataset}")
Wire Into Root Model¶
Use Subcommand() on the root model to declare the union:
@dracon_program(name="ml-tool", version="1.0")
class CLI(BaseModel):
verbose: Annotated[bool, Arg(short='v', help="Verbose output")] = False
command: Subcommand(TrainCmd, EvalCmd)
That's it. Run with:
Skip the Discriminator Boilerplate¶
The @subcommand decorator injects the action: Literal[...] = ... field for you:
from dracon import subcommand
@subcommand('train')
class TrainCmd(BaseModel):
"""Train a model."""
epochs: int = 10
# action: Literal['train'] = 'train' is added automatically
Subcommand-Scoped Config Files¶
Config files appearing after the subcommand name are scoped to it — the file only needs the subcommand's own fields, no wrapper:
Files before the subcommand merge at the root level:
Nested Subcommands¶
Subcommand models can contain their own Subcommand fields for multi-level CLIs:
class AddCmd(BaseModel):
action: Literal['add'] = 'add'
name: Annotated[str, Arg(help="Remote name")]
class RemoveCmd(BaseModel):
action: Literal['remove'] = 'remove'
name: Annotated[str, Arg(help="Remote name")]
class RemoteCmd(BaseModel):
"""Manage remotes."""
action: Literal['remote'] = 'remote'
sub: Subcommand(AddCmd, RemoveCmd)
See the Subcommand reference for full API details.
By combining these Arg parameters, you can create sophisticated and user-friendly command-line interfaces directly from your Pydantic configuration models.
Auto-Discovered Config Files (ConfigFile)¶
Programs can declare config files that are automatically discovered and loaded as a base layer — like .gitconfig, Cargo.toml, or .eslintrc. Users get sensible defaults without passing +file.yaml on every invocation.
from dracon import dracon_program, ConfigFile
@dracon_program(
name='my-tool',
config_files=[
ConfigFile('~/.my-tool/config.yaml'), # home-dir defaults
ConfigFile('.my-tool.yaml', search_parents=True), # project-local override
],
)
class Config(BaseModel):
host: str = "localhost"
port: int = 8080
ConfigFile Options¶
| Field | Type | Default | Description |
|---|---|---|---|
path |
str |
(required) | File path (~ is expanded) |
search_parents |
bool |
False |
Walk up from CWD, cascade-merge all matching files |
required |
bool |
False |
Error if not found |
selector |
str \| None |
None |
Extract a subtree via @keypath |
How Layering Works¶
Auto-discovered configs are prepended as +file args before any user-provided args. Standard dracon merge rules apply.
Precedence (lowest → highest):
- Model field defaults
- Auto-discovered configs (in declaration order)
- Explicit CLI
+file.yaml - CLI
--flag/--nested.pathoverrides
Parent Directory Search (Cascade)¶
With search_parents=True, dracon walks up from the current working directory toward root, collects all matching files, and merges them with the closest file winning. This is the same behavior as .gitconfig or .editorconfig -- defaults from higher-level directories show through, while closer files override specific values.
~/.my-tool.yaml ← user-wide defaults (lowest priority)
~/projects/.my-tool.yaml ← project defaults (medium priority)
~/projects/myapp/.my-tool.yaml ← app-specific overrides (highest priority)
All three files are discovered and merged when CWD is anywhere under myapp/.
This uses the cascade: include loader under the hood.
Example: Multi-Layer Tool Config¶
# ~/.my-tool.yaml — user defaults
host: my-server.local
port: 443
theme: dark
# ~/projects/dev-env/.my-tool.yaml — project override
host: localhost
port: 8080
cd ~/projects/dev-env/subdir
my-tool status
# Result: host=localhost, port=8080, theme=dark (inherited from user defaults)
my-tool --port 9999 status # CLI flag wins over everything
my-tool +/tmp/special.yaml status # explicit +file layers between auto and flags
Note
search_parents=True requires a relative path. Absolute paths raise ValueError since parent-walking is meaningless for them.