Skip to content

CLI Source#

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)

The base class is CliSource — abstract, designed so future implementations (click, typer, your own parser) plug in by overriding a single method. See Implementing a custom CLI parser.

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", [...]).

"""Quickstart: load CLI args into a dataclass."""

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

"""Defaults semantics — non-bool args are dropped unless explicitly passed."""

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.

"""Nesting via double underscore in dest names."""

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.

"""Subparsers — discriminator + per-subcommand args via Optional fields."""

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 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 it yourself first, read the value, then hand the same parser to ArgparseSource — the source will parse it again internally, which is cheap.

"""Bootstrap pattern — peek argv before load() to choose other sources.

argparse parsers are stateless across parse_args() calls, so the user can
parse argv themselves to read a flag (here: --env), then hand the same parser
to ArgparseSource, which parses argv again inside load().
"""

import argparse
from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


@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()
    env = ns.env

    config = dature.load(
        dature.JsonSource(file=SOURCES_DIR / f"config.{env}.json"),
        dature.ArgparseSource(parser=parser),
        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.

"""Combining ArgparseSource with file and env sources via load()."""

import argparse
from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


@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"),  # baseline
        dature.EnvSource(prefix="MYAPP_"),  # per-deployment overrides
        dature.ArgparseSource(parser=parser),  # operator overrides (wins last)
        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)

Implementing a custom CLI parser#

CliSource is an abstract class. To plug in a different CLI library (click, typer, anything else), subclass it and implement one method: _parse_argv() -> dict[str, JSONValue].

The contract:

  • Top-level args → key = field name.
  • Groups / subcommands → emit a discriminator key + prefix the group's args with the chosen group name, joined with self.nested_sep.
  • Bool-style flags — always in the result.
  • Non-bool args — only if the user explicitly passed them.
  • The parser/library reads sys.argv itself; do not add an argv= parameter.

Below is a complete ClickSource you can copy into your project. It supports click groups of arbitrary depth.

"""Custom CliSource backed by click — copy into your project.

click is not a dature dependency; this script exits silently if click isn't
installed in the current environment.
"""

import sys
from dataclasses import dataclass
from typing import ClassVar

import click
import dature


@dataclass(kw_only=True, repr=False)
class ClickSource(dature.CliSource):
    """CLI source backed by a click Group/Command. Supports nested groups."""

    cli: click.Command
    discriminator: str = "command"
    format_name: ClassVar[str] = "click"

    def _parse_argv(self) -> dict[str, dature.types.JSONValue]:
        ctx = self.cli.make_context(
            info_name=self.cli.name or "cli",
            args=sys.argv[1:],
            resilient_parsing=False,
        )
        out: dict[str, dature.types.JSONValue] = {}
        self._walk(ctx, self.cli, prefix=[], out=out)
        return out

    def _walk(
        self,
        ctx: click.Context,
        cmd: click.Command,
        *,
        prefix: list[str],
        out: dict[str, dature.types.JSONValue],
    ) -> None:
        sep = self.nested_sep
        for param in cmd.params:
            param_name = param.name or ""
            value = ctx.params.get(param_name)
            source = ctx.get_parameter_source(param_name)
            key = sep.join([*prefix, param_name])
            if (
                isinstance(param, click.Option) and param.is_flag
            ) or source == click.core.ParameterSource.COMMANDLINE:
                out[key] = value

        if not isinstance(cmd, click.Group):
            return

        # Click 8.x stores the chosen subcommand name in ctx.protected_args[0]
        # (deprecated in 9.0 — ctx.args will contain everything in 9.x).
        rest = [*getattr(ctx, "protected_args", ()), *ctx.args]
        if not rest:
            return
        sub_name, sub_cmd, sub_args = cmd.resolve_command(ctx, rest)
        if sub_cmd is None or sub_name is None:
            return
        out[sep.join([*prefix, self.discriminator])] = sub_name
        sub_ctx = sub_cmd.make_context(
            info_name=sub_name,
            args=sub_args,
            parent=ctx,
            resilient_parsing=False,
        )
        self._walk(sub_ctx, sub_cmd, prefix=[*prefix, sub_name], out=out)


@click.group(invoke_without_command=True)
@click.option("--verbose", is_flag=True)
def cli(verbose: bool) -> None:  # noqa: FBT001
    pass


@cli.command()
@click.option("--name", required=False)
def create(name: str | None) -> None:
    pass


@cli.command()
@click.option("--item-id", type=int, required=False)
def delete(item_id: int | None) -> None:
    pass


@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


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


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

A TyperSource is a thin wrapper — typer commands are click commands under the hood, so subclassing ClickSource and pointing at the underlying click group works directly.

Not part of dature's API surface

ClickSource above is a teaching example. It's not shipped, not tested by dature's CI, and not bound by dature's backward-compatibility guarantees. Treat it as a starting point for your own implementation.

Roadmap#

A future PR will add declarative cross-source interpolation so that the bootstrap pattern can be expressed without parsing argv twice:

load(
    JsonSource(file="config.${@cli.env:-dev}.yaml"),
    ArgparseSource(parser=parser, tag="cli"),
    schema=Config,
)

The ${@<tag>.<key>} form is namespaced (the @ prefix avoids any clash with the existing ${VAR} env-var expansion).

Known limitations#

  • Discriminated unions for subcommands (e.g. args: CreateArgs | DeleteArgs) are not supported in this iteration. 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.