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. Meta(enum_by="value") matches the value text instead.
list / list[T] As an OPTION: one value per flag occurrence, repeated (--x a --x b) to accumulate — space-separated (--x a b) is not the default; pass Meta(nargs="*") to opt back into it. As a POSITIONAL: variadic and space-separated (nargs="*") unconditionally. Bare list elements are str. Defaults to [].
datetime.date / datetime.datetime / datetime.time Parsed via fromisoformat. See Dates and times below.
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.

Negative numbers

Negative numeric values work out of the box — both as option values (--temp -5) and as positionals (a positional int accepts -3). This is argparse's own _negative_number_matcher at work: when your parser declares no option that itself looks like a negative number (e.g. a literal -1 flag), argparse treats -5/-2.5 as values, not unknown options. No special support is needed.

If you genuinely need a -1-style flag (rare and ambiguous), reach for the Meta(kwargs=...) escape hatch to pass raw add_argument keywords.

Booleans

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

    color: bool = True        # --color / --no-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"
$ app --color GREEN     # -> Color.GREEN
$ app --color 2         # error: invalid choice

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

Matching by value

Meta(enum_by="value") (or Meta(enum_by="value")) matches the text against str(member.value) instead of the member name. It applies on the command line, in env and in config (a TOML number 2 matches the value 2); --help, the error text, shell completion, agent help and the MCP schema all list the value text. The parsed field is still the member:

import enum
from duho import Args, Arg, Meta

class Mode(enum.Enum):
    FAST = "f"
    SLOW = "s"

class App(Args):
    mode: Arg[Mode, Meta(enum_by="value")] = Mode.FAST
    "Speed"
$ app --mode s       # -> Mode.SLOW
$ app --mode SLOW    # error: invalid choice: 'SLOW' (choose from f, s)

It applies to a plain Enum, an Optional[Enum] and the elements of a list/set/tuple or the values of a dict; a Literal of Enum members is unchanged. Two members whose value text is the same (aliases excepted) raise ValueError naming the field when the parser is built, and so does any enum_by other than "name" or "value".

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"
$ 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

An OPTION collects a list[T] field with one value per flag occurrence — repeat the flag to accumulate:

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

Space-separated multi-value (--tag a b) is not the default for an option — pass an explicit Meta(nargs="*") to opt back into it:

from duho import Args, Arg, Meta

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

A list[T] positional, by contrast, is always variadic and space-separated (nargs="*") — no override needed:

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

Dates and times

datetime.date, datetime.datetime, and datetime.time fields are parsed with the type's own fromisoformat:

import datetime
from duho import Args

class App(Args):
    day: datetime.date = None
$ app --day 2026-01-01     # -> datetime.date(2026, 1, 1)

A trailing Z (RFC 3339's UTC marker) is accepted for datetime/time values on every supported Python version — including 3.9/3.10, where it's rewritten to +00:00 before delegating to fromisoformat (which only started accepting Z natively on 3.11). A date value never accepts a trailing Z on any version — a date has no time-of-day/UTC component, so fromisoformat rejects it the same way on every version. A basic, no-dash format like 20260101 works for date/datetime on 3.11+ (native fromisoformat accepts it there) but raises on 3.9/3.10 — stick to the dashed/colon-separated ISO forms if you need to support the older versions.

Dicts

A dict[str, V] field collects KEY=VALUE tokens; repeated flags merge into one dict, and the value half is converted with V:

class App(Args):
    define: dict[str, int] = {}
    ("--define", "-D")
$ app -D width=80 -D height=24     # -> {"width": 80, "height": 24}

Only the first = splits, so a value may itself contain = (-D url=a=b → {"url": "a=b"}). A token with no = is a clear argparse error. Keys are always strings — a non-str key type (dict[int, str]) is a build-time error. A bare dict means dict[str, str]. The default is {} when none is declared. Under the env/config layers, an env string k=v becomes a one-pair dict and a TOML table converts each value through V.

Unions

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

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

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 Meta(type=...):

from duho import Args, Arg, Meta

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

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

When your converter raises ValueError or TypeError with a message, that message is the usage error:

from duho import Args, Arg, Meta

def port(text: str) -> int:
    value = int(text)
    if not 1 <= value <= 65535:
        raise ValueError("port must be 1..65535")
    return value

class App(Args):
    port: Arg[int, Meta(type=port)] = 8080
$ app --port 99999
app: error: argument --port: port must be 1..65535

An empty message, a builtin type, duho's own factories and a converter that raises argparse.ArgumentTypeError keep argparse's own text. This covers an explicit type=, not a class used as the annotation (port: SomeClass), and a bad value from the env or config layers never echoes the value or the message.

For richer control, implement the Argument protocol and provide an _argbuilder_ classmethod — see the API reference. The hook also applies to such a type as a member of an Optional/Union or the element of a list, set, tuple or dict; there only the builder's factory (type), choices and metavar are used.