Skip to content

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.

Typed metadata with Meta

NS(...) is an untyped argparse.Namespace, so a misspelled key (NS(hlep="oops")) is silently dropped. duho.Meta is a dataclass with the same known fields — an unknown keyword is a TypeError at class-definition time, and only the fields you set are merged:

from duho import Args, Arg, Meta

class Run(Args):
    level: Arg[int, Meta(help="verbosity", env="LEVEL")] = 0
    ("--level",)

Meta is the recommended, typo-safe form; NS keeps working. Meta.kwargs is the same raw add_argument escape hatch as NS(kwargs=...).

Any metadata object exposing a str .documentation attribute (a PEP-727-style Doc) contributes help text, so Arg[int, Doc("how many")] works too.

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.

Add conflicts_required=True on any member to require exactly one:

    push: Arg[bool, NS(conflicts="mode", conflicts_required=True)] = False
    ("--push",)

    pull: Arg[bool, NS(conflicts="mode")] = False
    ("--pull",)

Omitting both --push and --pull is now an error.

Titled argument groups

NS(group="<title>") buckets fields under a named --help section:

class App(Args):
    outfile: Arg[str, NS(group="Output options")] = "-"
    ("--outfile",)

A field combining group= and conflicts= nests the mutually-exclusive group inside the titled section.

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.