Skip to content

ArgparseSource#

ArgparseSource loads command-line arguments into a dataclass, just like JsonSource/EnvSource load files or env vars. It can be combined with other sources via load() so a typical app reads:

JSON file (defaults) → env vars (per-deployment) → CLI args (operator overrides)

To plug in a different CLI library (click, typer, your own parser), see Custom CLI Source.

Two different CLIs in dature

The dature console script (dature inspect/dature validate) is a tool for dature. ArgparseSource is a Source you compose into your own application. They are unrelated.

Quickstart#

ArgparseSource reads sys.argv[1:] via the parser's own parse_args() — there is no separate argv= parameter. Want to drive it from custom argv in tests? Use monkeypatch.setattr(sys, "argv", [...]).

import argparse
from dataclasses import dataclass

import dature


@dataclass
class Config:
    name: str = "demo"
    port: int = 8080
    debug: bool = False


parser = argparse.ArgumentParser()
parser.add_argument("--name")
parser.add_argument("--port", type=int)
parser.add_argument("--debug", action="store_true")


def main() -> None:
    config = dature.load(dature.ArgparseSource(parser=parser), schema=Config)
    print(config)


if __name__ == "__main__":
    main()
python quickstart.py --name demo --port 8080
Config(name='demo', port=8080, debug=False)

Defaults semantics — bool vs everything else#

This is the most subtle rule. It makes merging with other sources predictable:

  • Bool-style actions (store_true, store_false, BooleanOptionalAction) are always included in the result. If the user did not pass the flag, argparse's default value is used.
  • All other arguments are included only if the user explicitly passed them. Argparse defaults for non-bool actions are dropped.

Why: when CLI is one of several sources (env, file, …), unset CLI args must not silently override values from those sources. A user running ./app --port 9000 does not want --env (with default="dev") to clobber env: production set in the loaded JSON file. Bool flags are different — they are tri-state in name only (--debug / --no-debug / unset), and unset genuinely means "use the declared default".

import argparse

import dature


parser = argparse.ArgumentParser()

# --env not passed -> key absent
parser.add_argument("--env", default="dev")

# --port not passed -> key absent
parser.add_argument("--port", type=int)

# --debug not passed -> key present, value False
parser.add_argument("--debug", action="store_true")


def main() -> None:
    src = dature.ArgparseSource(parser=parser)
    print(src.load_raw().data)


if __name__ == "__main__":
    main()
python defaults.py
{'debug': False}

--debug is a bool action → always present. --env and --port are non-bool and weren't passed → absent from the dict. The dataclass receiving this data falls back to its own field defaults for the missing keys.

Nesting#

CliSource (and therefore ArgparseSource) defaults to nested_sep="--". A flag like --db--host nests as db.host in the dataclass: ArgparseSource reads the long-form option string directly (instead of argparse's dest, which collapses every - to _), so the original separator survives.

To use a different separator (., __, …), pass nested_sep= to the source. For non-default separators that argparse can't preserve in dest, set dest= explicitly so the parser stores the separator verbatim.

import argparse
from dataclasses import dataclass, field

import dature


@dataclass
class Db:
    host: str = "localhost"
    port: int = 5432


@dataclass
class Config:
    db: Db = field(default_factory=Db)


parser = argparse.ArgumentParser()
parser.add_argument("--db--host")
parser.add_argument("--db--port", type=int)


def main() -> None:
    config = dature.load(dature.ArgparseSource(parser=parser), schema=Config)
    print(config)


if __name__ == "__main__":
    main()
python nesting.py --db--host localhost --db--port 5432
Config(db=Db(host='localhost', port=5432))

Subparsers (and arbitrary nesting)#

ArgparseSource walks any add_subparsers(...) tree, including nested ones. The subparsers action's dest becomes a discriminator, and the chosen subparser's args go into a sub-dict named after the chosen subparser.

The dataclass: one optional field per subparser plus the discriminator. Args of subparsers that were not chosen are simply absent — adaptix uses None from the dataclass default.

import argparse
from dataclasses import dataclass

import dature


@dataclass
class CreateArgs:
    name: str = "default"


@dataclass
class DeleteArgs:
    item_id: int = 0


@dataclass
class Config:
    command: str | None = None
    verbose: bool = False
    create: CreateArgs | None = None
    delete: DeleteArgs | None = None


parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true")
subs = parser.add_subparsers(dest="command")

create = subs.add_parser("create")
create.add_argument("--name")

delete = subs.add_parser("delete")
delete.add_argument("--item-id", type=int)


def main() -> None:
    config = dature.load(dature.ArgparseSource(parser=parser), schema=Config)
    print(config)


if __name__ == "__main__":
    main()
python subparsers.py --verbose create --name alice
Config(command='create', verbose=True, create=CreateArgs(name='alice'), delete=None)

Bootstrap pattern — peek argv before load()#

Sometimes a CLI flag selects which other config file to read. Since argparse.ArgumentParser is stateless across parse_args() calls, you can parse argv manually first to read the value (e.g. --env), then pass the same parser to ArgparseSource — the source will parse it again internally during load(), which is cheap.

import argparse
from dataclasses import dataclass

import dature


@dataclass
class AppConfig:
    env: str = "dev"
    host: str = "localhost"
    port: int = 8080


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--env", default="dev")
    parser.add_argument("--port", type=int)

    ns = parser.parse_args()  # 1. parsing argv manually
    env = ns.env

    config = dature.load(
        dature.JsonSource(file=SOURCES_DIR / f"config.{env}.json"),
        dature.ArgparseSource(parser=parser),  # 2. parsing argv again
        schema=AppConfig,
    )
    print(config)


if __name__ == "__main__":
    main()
{
  "host": "localhost",
  "port": 8080
}
{
  "host": "api.example.com",
  "port": 443
}
python bootstrap.py --env production --port 9000

The order in load() controls precedence: with the default last_wins, sources passed later override earlier ones. Put CLI last so operator overrides win over file defaults.

Combining with other sources#

Standard load() rules apply. Only values the CLI explicitly received reach the merge step (per the defaults rule), so you can safely mix CLI with env vars and config files.

import argparse
from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False


parser = argparse.ArgumentParser()
parser.add_argument("--host")
parser.add_argument("--port", type=int)


def main() -> None:
    config = dature.load(
        dature.JsonSource(file=SOURCES_DIR / "config.json"),
        dature.EnvSource(prefix="MYAPP_"),
        dature.ArgparseSource(parser=parser),
        schema=Config,
    )
    print(config)


if __name__ == "__main__":
    main()
{
  "host": "from-file",
  "port": 1111,
  "debug": false
}
MYAPP_HOST=from-env python combining.py --port 9000
Config(host='from-env', port=9000, debug=False)

Known limitations#

  • Discriminated unions for subcommands (e.g. args: CreateArgs | DeleteArgs) are not supported. Use one optional field per subparser plus a discriminator field, as shown above.
  • A subparser whose name equals the subparsers action's dest (e.g. add_subparsers(dest="create") plus add_parser("create")) produces a key collision. Argparse allows it; we don't catch it. Avoid the pattern.
  • Argparse rewrites - to _ in dest. To get nested keys, use __ in the flag name or set dest= explicitly.