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"
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.
The help is the first run of consecutive string literals after the field, joined with one space, and it may stand before or after the flag tuple:
from duho import Args
class Serve(Args):
port: int = 8000
"Port to listen on,"
"default 8000" # -> help "Port to listen on, default 8000"
("--port", "-p")
A one-element flag tuple written without its comma (("--port") is just the string
"--port") would become help text, so a lone flag-shaped string raises a
ValueError at build time that shows the fix: ("--port",).
Flags are optional
With no tuple literal, the flag defaults to one long flag,
"--" + kebabcase(field_name) — underscores become dashes, and a camelCase or
ACRONYM name is kebab-cased too (dry_run → --dry-run, testMe →
--test-me, HTTPPort → --http-port). Reach for an explicit tuple only for:
- a positional —
("source",) - a short flag or extra aliases —
("-n", "--name") - a different spelling —
("--env",)for a field namedenvironment
A bare "--" entry inside a tuple expands to that same default long flag, so
you can pair a short flag with it without spelling it out:
("-n", "--") → ("-n", "--name").
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
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, Meta(...)],
where Arg is typing.Annotated:
from duho import Args, Arg, Meta
class Run(Args):
tags: Arg[list, Meta(action="append", metavar="TAG")] = []
"Repeatable tag"
("--tag",)
level: Arg[int, Meta(choices=(1, 2, 3))] = 1
action, nargs, const, metavar, choices, required — they all
pass straight through. Anything duho doesn't model explicitly can go through
Meta(kwargs={...}), which is merged last. dest is not among them: a field's dest
is always its declared name, and Meta(dest=...) is a TypeError.
Typed metadata with Meta
duho.Meta is a dataclass of the known metadata fields. It takes keyword
arguments only: a positional argument and an unknown keyword (Meta(hlep="oops"))
are both a TypeError, and only the fields you set are merged. The TypeError is raised as soon as the
annotation is evaluated: at class-definition time on Python 3.9-3.13 with
eager annotations, or at first parser build on 3.14+ (PEP 649), under string
annotations, or with from __future__ import annotations:
from duho import Args, Arg, Meta
class Run(Args):
level: Arg[int, Meta(help="verbosity", env="LEVEL")] = 0
A field's dest is always its declared name, so Meta has no dest field at
all and Meta(dest=...) is a TypeError. Meta(kwargs=...) is the raw
add_argument escape hatch.
Untyped metadata with a dict
A plain dict (dict(help="...", env="...") or a literal) is the permissive form:
use it for a key Meta does not have, or for the keys a custom argument's own
builder declares. A key that no Meta field and no such builder claims is ignored
and logged (see Misdeclaration warnings):
from duho import Arg, Args
class Run(Args):
level: Arg[int, dict(help="verbosity", env="LEVEL")] = 0
Any other object with attributes is read the same way, its public attributes
being the keys, so an argparse.Namespace(help="verbosity") works where the
dict does. Only a Meta, or a subclass of it, is strict.
Any metadata object exposing a str .documentation attribute (a PEP-727-style
Doc) contributes help text, so Arg[int, Doc("how many")] works too.
An option value that looks like --
By default argparse treats a token after an option that starts with - as another
option, and duho treats a bare -- as the start of the passthrough tail. For an
option whose value may legitimately be one of those, Meta(literal_value=True) makes
the token after the flag always its value:
import duho
from duho import Cmd, Arg, Meta
class Grep(Cmd):
pattern: Arg[str, Meta(literal_value=True)] = ""
("-e", "--pattern")
def __call__(self):
print(self.pattern, self._passthrough_)
if __name__ == "__main__":
raise SystemExit(duho.main(Grep))
grep.py --pattern -x -- tail prints -x ['tail'], and --pattern -- makes the
pattern --. It applies only to an option that takes exactly one value: on a bare
flag, a variable-arity option or a positional it raises ValueError naming the field
when the parser is built. A repeated option joins once per occurrence, and
--pattern=-- works with or without it. The option strings of the whole subcommand
tree are collected once per parser. Two forms are not joined: an abbreviated long flag
(--pat for --pattern) and a short flag inside a cluster (-vp -x); an empty value
after a short flag is left alone as well.
Mutually exclusive groups
Meta(conflicts="<group-name>") puts fields into the same mutually exclusive group:
class Output(Args):
json: Arg[bool, Meta(conflicts="format")] = False
yaml: Arg[bool, Meta(conflicts="format")] = False
Passing both --json and --yaml is now an error.
Add conflicts_required=True on any member to require exactly one:
push: Arg[bool, Meta(conflicts="mode", conflicts_required=True)] = False
pull: Arg[bool, Meta(conflicts="mode")] = False
Omitting both --push and --pull is now an error.
Titled argument groups
Meta(group="<title>") buckets fields under a named --help section:
class App(Args):
outfile: Arg[str, Meta(group="Output options")] = "-"
A field combining group= and conflicts= nests the mutually-exclusive group
inside the titled section.
Helpers
Common Meta(...) 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"
paths: Arg[list, Extend(":")] = [] # --path a:b -> ["a", "b"]
("--path",)
Misdeclaration warnings
Two mistakes that would otherwise silently do nothing are logged as a WARNING on the
duho.args logger when the parser is built, once each. Neither is an error and neither
has a switch.
A dict key that is not a Meta field is reported once per field, naming the
nearest Meta field when there is one:
App.port: metadata key 'hlep' is not a Meta field and is ignored; closest Meta field: 'help'
dest is such a key. Keys that Extend, Count, Append, Const and Choice set,
and attributes a custom ArgumentBuilder subclass declares, are accepted. Meta
itself needs no such check: an unknown keyword is a TypeError.
A sandwich attribute (leading and trailing underscore) in a class body that duho does not read, but whose spelling is a near-miss of exactly one it does, is reported once per class:
App declares '_verison_', which duho does not read; did you mean '_version_'?
Only a near-miss is reported. An attribute of your own that merely extends a name duho
reads (_config_dir_), a dunder, and a name starting _duho_ are left alone.
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 the __call__ dunder — implement it (on a
duho.Cmd subclass) to make a class runnable, and
duho.main/duho.run_command call instance() to run it — so a field called
main or parse will not collide with anything.
Three plain (non-sandwich) names ARE reserved, though: a field named help,
version (when a --version flag resolves), or print_completion (when
_completion_ = True) raises a build-time ValueError naming the collision,
since each would otherwise clash with an argparse-added flag of the same
name. On a duho.Cli subclass, subcommand is a fourth, differently-behaved
gotcha: it is the name Cli itself uses for the @Root.subcommand decorator,
so a CLI field also named subcommand silently replaces the decorator
instead of raising — avoid that field name on a Cli subclass.