Skip to content

Types and conversion

The annotation decides how duho converts the string argparse hands it.

Annotation Behavior
str, int, float Direct conversion. A conversion failure is a normal argparse error.
bool Default False (or no default) → a simple --flag switch. Default True--flag / --no-flag, so the default can be turned back off.
typing.Literal["a", "b"] Becomes choices. Mixed-type literals (Literal["auto", 1]) try each declared value's own type and keep whichever round-trips.
enum.Enum subclass choices are the member names; the parsed value is the member itself.
list / list[T] Accepts repeated (--x a --x b) and space-separated (--x a b) forms. Bare list elements are str. Defaults to [].
typing.Optional[T] / T \| None Not required; converts with T.
typing.Union[A, B] / A \| B Tries each member in declaration order; the first that accepts the text wins.
pathlib.Path Converted to a Path. Also gets file completion in generated completion scripts.

PEP 604 unions (int | str) require Python 3.10+. On 3.9, use typing.Union.

Booleans

class App(Args):
    verbose: bool = False     # --verbose
    ("--verbose",)

    color: bool = True        # --color / --no-color
    ("--color",)

A True default uses argparse.BooleanOptionalAction — without it, a store_true flag could never express "actually, false".

Enums

Enum members are matched by name, not value:

import enum
from duho import Args

class Color(enum.Enum):
    RED = 1
    GREEN = 2

class App(Args):
    color: Color = Color.RED
    "Pick a color"
    ("--color",)
$ app --color GREEN     # -> Color.GREEN
$ app --color 2         # error: invalid choice

--help shows the member names, and unknown names are rejected.

Enums inside a Union

The same name-matching applies when an enum sits inside a Union or Optional — and a name match wins before falling through to a later member, so declaration order matters:

class App(Args):
    kind: ty.Union[Color, str] = "auto"
    ("--kind",)
$ app --kind RED        # -> Color.RED   (matched the enum by name)
$ app --kind whatever   # -> "whatever"  (fell through to str)

Without the name-first rule a total type like str would swallow every value and the enum would never match.

Note

A Union containing an enum does not set choices — argparse can't express "an enum name or any string". The field stays free-form, with enum names preferred. A bare enum field does set choices.

Lists

class App(Args):
    tags: list[str] = []
    ("--tag",)
$ app --tag a --tag b     # -> ["a", "b"]
$ app --tag a b           # -> ["a", "b"]

Unions

Members are tried in order, so put the most specific type first:

    value: ty.Union[int, str]     # "5" -> 5,  "x" -> "x"
    ("--value",)

Only TypeError/ValueError count as "try the next member" — an unexpected exception from a custom type propagates rather than being silently swallowed.

Custom types

Any callable taking a single string works as a type via NS(type=...):

from duho import Args, Arg, NS

def kv(text: str) -> tuple[str, str]:
    key, _, value = text.partition("=")
    return key, value

class App(Args):
    setting: Arg[tuple, NS(type=kv)] = ("", "")
    ("--set",)

For richer control, implement the Argument protocol and provide an _argbuilder_ classmethod — see the API reference.