Skip to content

Custom CLI Source#

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

For the built-in argparse integration, see ArgparseSource.

The _parse_argv 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.

ClickSource example#

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

import sys
from dataclasses import dataclass

import click
import dature
from dature.sources.base import CliSource


@dataclass(kw_only=True, repr=False)
class ClickSource(CliSource):
    cli: click.Command
    discriminator: str = "command"
    format_name: str = "click"

    def _parse_argv(self) -> dict[str, dature.type_aliases.JSONValue]:
        ctx = self.cli.make_context(
            info_name=self.cli.name or "cli",
            args=sys.argv[1:],
            resilient_parsing=False,
        )
        out: dict[str, dature.type_aliases.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.type_aliases.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

        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.