Skip to main content

Typed, declarative, faithful argparse: a command is a dataclass/NamedTuple tree.

Project description

argtree

ci python license: MIT

Typed, declarative, faithful argparse.

A command is a NamedTuple (or a frozen @dataclass); each field is exactly one argparse argument; a field whose type is a Union of command types is a subcommand slot — the recursive part of the tree. parse() builds a real ArgumentParser under the hood, parses, and reconstructs the flat Namespace back into your typed tree so you can match/case on it.

The whole point is nothing more, nothing less than argparse. There is no routing, dispatch, or handler binding — you get data, you decide. The declared type drives the result shape; the arg(...) marker carries literal add_argument keywords, so there is no second DSL to learn and nothing argparse can do that you can't express.

Install

uv add argtree     # or: pip install argtree

No runtime dependencies. Ships with py.typed.

Quickstart

from __future__ import annotations

from typing import NamedTuple

from argtree import arg, parse


class Git(NamedTuple):
    command: Add | Commit                     # Union of commands == subcommands
    verbose: int = arg("-v", action="count", default=0)


class Add(NamedTuple):
    paths: list[str] = arg(positional=True)
    all: bool = arg("-A", "--all")


class Commit(NamedTuple):
    message: str = arg("-m", "--message")     # required: no default, takes a value
    amend: bool = False                        # bare bool -> store_true


git = parse(Git)            # -> Git, fully typed
match git.command:
    case Add(paths=p, all=a):     ...
    case Commit(message=m):       ...

reveal_type(git) is Git; inside the match, p is list[str] and m is str. The static type and the runtime value always agree because the same class is both the schema and the result — verified by mypy --strict and pyright in CI (see tests/test_static_typing.py).

Writing the tree root-first (root above the leaves it references) relies on from __future__ import annotations, which makes annotations lazy strings that argtree resolves at parse time.

What's a subcommand

A field is the subcommand slot when its type is a Union of command types: Add | Commit. Add None (Add | Commit | None) to make the subcommand optional. Nest freely — a chosen command can itself have a Union field, giving you git remote add .... Exactly one subcommand slot per command (argparse allows one subparsers group per parser).

Optional[X] where X is a scalar (e.g. int | None) is not a subcommand — it's an optional value that defaults to None.

arg(...) and command(...)

arg(*flags, **kwargs) attaches argparse config to a field. *flags are the literal add_argument name-or-flags ("-v", "--verbose", or a bare name for a positional); omit them to derive --field-name. The standard add_argument keywords (action, nargs, const, type, choices, required, help, metavar, dest, version) are explicit, typed parameters — discoverable and statically checked, not hidden behind **kwargs — and **kwargs remains an escape hatch for custom argparse.Action subclasses. Convenience keywords: positional=True, group="name", exclusive=True, group_required=True.

@command(name=..., aliases=[...], help=..., **add_parser_kwargs) overrides a command's subcommand name/aliases/help. Without it the name is the class name kebab-cased (RemoteAdd -> remote-add) and help is the docstring's first line.

Type → argparse inference

Everything here is a default you can override by passing the corresponding keyword to arg(...).

Field type Inferred argparse behavior
bool (default False) action="store_true"
bool (default True) BooleanOptionalAction (--flag / --no-flag)
int / float / Path / ... type=<that type>
str left as-is (argparse default is already str)
Literal["a","b"] choices=[...] (+ type inferred when literals share one)
enum.Enum type=<name→member>, metavar {NAME,NAME}
X | None (scalar) default=None, not required
list[X] nargs="*", default=[], type=X
tuple[X, ...] nargs="*", type=X
tuple[X, X, X] (fixed) nargs=3, type=X
tuple[int, str] (heterogeneous) ConfigError — one type= can't cover mixed values
no default, takes a value required=True

Field ordering. A required subcommand slot has no default, so declare it first in its class (Python forbids a non-default field after a defaulted one). Give the slot a default of None to make it optional and free the ordering.

Lists: nargs vs append. list[str] defaults to nargs="*" (the space-separated --track a b c). For the repeated-flag form -t a -t b, pass arg("-t", action="append") (add type=... if the element isn't str).

Worked examples — real argparse CLIs, side by side

Each example translates a real, widely-used argparse CLI. The original plain-argparse module sits next to the argtree version, and the test suite parses both and asserts they agree (see tests/).

mypy — a flat parser with argument groups

original argparseargtree
p.add_argument(
    "-v", "--verbose",
    action="count", default=0,
    dest="verbose")
p.add_argument(
    "--follow-imports",
    choices=["normal", "silent",
             "skip", "error"],
    default="normal",
    dest="follow_imports")
p.add_argument(
    "-n", "--num-workers",
    type=int, default=0,
    dest="num_workers")
p.add_argument(
    "--exclude", action="append",
    default=[], dest="exclude")
p.add_argument("files", nargs="*")
class Mypy(NamedTuple):
    verbose: int = arg(
        "-v", "--verbose",
        action="count", default=0)
    follow_imports: Literal[
        "normal", "silent",
        "skip", "error"] = arg(
        "--follow-imports",
        default="normal")
    num_workers: int = arg(
        "-n", "--num-workers",
        default=0)
    exclude: list[str] = arg(
        "--exclude", action="append")
    files: list[str] = arg(
        positional=True)

pre-commit — nested subcommands

original argparseargtree
sub = parser.add_subparsers(
    dest="command", required=True)

run = sub.add_parser("run")
run.add_argument("hook", nargs="?")
mutex = run.add_mutually_exclusive_group()
mutex.add_argument(
    "--all-files", "-a",
    action="store_true",
    dest="all_files")
mutex.add_argument(
    "--files", nargs="*",
    default=[], dest="files")
class PreCommit(NamedTuple):
    command: Autoupdate | Clean | Run


@command(name="run")
class Run(NamedTuple):
    hook: str | None = arg(
        positional=True, nargs="?")
    all_files: bool = arg(
        "--all-files", "-a",
        group="mutex", exclusive=True)
    files: list[str] = arg(
        "--files",
        group="mutex", exclusive=True)

Public API

arg, command, parse(spec, argv=None, **parser_kwargs) -> spec, build_parser(spec, **parser_kwargs) -> ArgumentParser, from_namespace(spec, namespace) -> spec, ConfigError.

build_parser + from_namespace are the escape hatch: build the parser, do whatever raw argparse thing you need (parse_known_args, parse_intermixed_args, subparser tweaks), then rebuild the typed tree from the namespace.

How it works

Internally, each leaf argument and each subparser selection is stored under a path-namespaced dest (joined with \x1f). This is invisible — clean metavars keep --help, usage, and errors reading exactly like hand-written argparse — but it lets a parent and a chosen child both have a field named verbose without colliding in argparse's single flat namespace. Command classes must be defined at module level (resolved via typing.get_type_hints). The library type-checks clean under mypy --strict and pyright, and so does your spec.

Development

Tasks are a camas tree in tasks.py; CI runs the same tree:

uv run camas all       # fix, then type-check + 100% line/branch coverage
uv run camas matrix    # the full check across Python 3.10–3.15
uv run camas check     # format-check, lint, type-check, tests (no mutation)

For agents/CI, append --effects='(Summary(),)' for a compact post-run report.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

argtree-0.1.0.tar.gz (61.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

argtree-0.1.0-py3-none-any.whl (16.5 kB view details)

Uploaded Python 3

File details

Details for the file argtree-0.1.0.tar.gz.

File metadata

  • Download URL: argtree-0.1.0.tar.gz
  • Upload date:
  • Size: 61.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for argtree-0.1.0.tar.gz
Algorithm Hash digest
SHA256 5d7cd8724b12455330cf8994542d85e351d4a027bd08a2b8a5cdcaddd4c67bdd
MD5 56be2b71b7ea956b3961bbe58d57af71
BLAKE2b-256 0cc1c2117bad1a43bf6d9f63b654bc2bf1c372b7a645b9a0fd3fcf515bff49b1

See more details on using hashes here.

Provenance

The following attestation bundles were made for argtree-0.1.0.tar.gz:

Publisher: ci.yaml on JPHutchins/argtree

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file argtree-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: argtree-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 16.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for argtree-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 de0b7935f8d0e297e16f0a49a0cfa451720a855954d40467847c5d06421c5f14
MD5 7e21f02ff7b633447b60f287196a0f9c
BLAKE2b-256 c44ba19f7b9c404f4a8853036e32448052ea4c4f889ad913d9cee9ff00719b76

See more details on using hashes here.

Provenance

The following attestation bundles were made for argtree-0.1.0-py3-none-any.whl:

Publisher: ci.yaml on JPHutchins/argtree

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page