Declaring arguments
A duho CLI is a class. Each annotated field becomes an argument.
from duho import Args
class Deploy(Args):
"""Deploy the application."""
environment: str
"Target environment (prod, staging, dev)"
("--env", "-e")
dry_run: bool = False
"Preview changes without applying them"
("--dry-run",)
Three things make up a field:
| Part | Purpose |
|---|---|
The annotation (environment: str) |
The type. Drives conversion and validation — see Types. |
| The docstring below it | The argument's help text. Optional. |
| The tuple literal below that | The flags. Optional. |
The class docstring becomes the parser's description.
Flags are optional
With no tuple literal, the flag is derived from the field name, with underscores becoming dashes:
class Build(Args):
"""Build the project."""
workers: int = 4 # -> --workers
"How many parallel workers"
dry_run: bool = False # -> --dry-run
This also works when a field has a docstring but no tuple — the docstring is recognized as help text, not as flags.
Required vs optional
A field without a default is required; a field with one is not.
class Deploy(Args):
environment: str # required
("--env",)
version: str = "latest" # optional
("--version",)
Optional[T] fields are never required (they default to None if you don't give
them a default).
Any value supplied by a configuration layer — an environment variable or config file — also un-requires the field.
Positional arguments
A tuple whose single entry has no leading dash declares a positional:
class Copy(Args):
"""Copy SOURCE to DESTINATION."""
source: Path
"File to copy"
("source",)
destination: Path
"Where to put it"
("destination",)
Positionals are matched in declaration order. A positional with a default
becomes optional (duho gives it nargs="?"):
output: str = "-"
"Where to write (default: stdout)"
("output",) # optional positional
The full argparse surface
Anything parser.add_argument() accepts is reachable through Arg[T, NS(...)],
where Arg is typing.Annotated and NS is argparse.Namespace:
from duho import Args, Arg, NS
class Run(Args):
tags: Arg[list, NS(action="append", metavar="TAG")] = []
"Repeatable tag"
("--tag",)
level: Arg[int, NS(choices=(1, 2, 3))] = 1
("--level",)
action, nargs, const, metavar, dest, choices, required — they all
pass straight through. Anything duho doesn't model explicitly can go through
NS(kwargs={...}), which is merged last.
Mutually exclusive groups
NS(conflicts="<group-name>") puts fields into the same mutually exclusive group:
class Output(Args):
json: Arg[bool, NS(conflicts="format")] = False
("--json",)
yaml: Arg[bool, NS(conflicts="format")] = False
("--yaml",)
Passing both --json and --yaml is now an error.
Helpers
Common NS(...) combinations have shorthands:
from duho import Args, Arg, Count, Append, Const, Choice, Extend
class App(Args):
verbose: Arg[int, Count()] = 0 # -vvv -> 3
("-v",)
tags: Arg[list, Append()] = [] # --tag a --tag b -> ["a", "b"]
("--tag",)
mode: Arg[str, Const("fast")] = "slow" # --fast -> "fast"
("--fast",)
color: Arg[str, Choice("auto", "always", "never")] = "auto"
("--color",)
paths: Arg[list, Extend(":")] = [] # --path a:b -> ["a", "b"]
("--path",)
Private fields
A field whose name starts with _ is not a CLI argument — duho skips it.
Use this for internal state you want on the instance but not on the command line.
Framework members are sandwich-named (_parser_, _version_, _subcommands_,
_config_…) and the dispatch hook is __call__ (an Args instance is directly
callable — instance() runs the command), so the ordinary name space is
entirely yours: a field called main, parse, or help will not collide with
anything.