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, 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. (dest is reserved: argtree derives an internal, path-namespaced dest and reconstructs by field name, so passing dest raises ConfigError.)

@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"); the element type (and any Literal choices) is still inferred from list[X].

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_class=ArgumentParser, **parser_kwargs) -> spec, build_parser(spec, *, parser_class=ArgumentParser, **parser_kwargs) -> ArgumentParser, add_arguments(parser, spec) -> None, 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.

Pass parser_class= to build_parser/parse to build into an ArgumentParser subclass (the result is typed as that subclass; its __init__ must accept argparse's standard keywords, since the spec's docstring is forwarded as description). add_arguments(parser, spec) adds a spec's arguments to a parser you already own — a custom or parent parser — without touching its description, so the parents=[] workaround is no longer needed.

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.

Use inside a mypyc- or Cython-compiled package

argtree resolves a spec's field types at runtime with typing.get_type_hints(). mypyc and Cython compile modules to native extensions that erase or mangle those runtime annotations, so get_type_hints() on a command class defined in a compiled module comes back empty or wrong — argtree then builds the wrong parser, or fails.

The fix (verified end to end in camas) is to keep the module that defines your command spec interpreted, while compiling everything else:

  • exclude just that one spec module from mypycify(...);
  • add argtree to [build-system].requires so mypy can still analyze the spec module during the build (it is imported by the compiled code).

A compiled dispatcher can then still call parse(...) / from_namespace(Cli, ns) as long as Cli itself lives in the un-compiled module, because get_type_hints(Cli) then sees the real annotations. This holds even for dynamically-added flags.

# pyproject.toml
[build-system]
requires = ["setuptools", "mypy", "argtree"]
# setup.py
from mypyc.build import mypycify

setup(ext_modules=mypycify(["mypackage/", "!mypackage/cli_spec.py"]))

Takeaway: keep specs in an interpreted module; compile everything else.

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.2.tar.gz (67.4 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.2-py3-none-any.whl (20.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: argtree-0.1.2.tar.gz
  • Upload date:
  • Size: 67.4 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.2.tar.gz
Algorithm Hash digest
SHA256 a91bbaf093bf26dd03f3a87b6932175b0d6f8fd02e05454c6eaa08baf2f180d1
MD5 698b60afc23fe12d24c9f80434026e71
BLAKE2b-256 c1fd22a96ec45c68f96fc3c574dfc5a183ded6a6fc3e6d3b2a72ce7ae7fe29ed

See more details on using hashes here.

Provenance

The following attestation bundles were made for argtree-0.1.2.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.2-py3-none-any.whl.

File metadata

  • Download URL: argtree-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 20.1 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.2-py3-none-any.whl
Algorithm Hash digest
SHA256 5d231c541b394d6670b1d26ae77e5aa7888c4bb325150db1de06f4dadf5b3805
MD5 6b993ac50980ed8dfd7546a020683d7f
BLAKE2b-256 42fa486e161cd6f3aefb99f57672735f02b779ad165d20953f401fcb0ceb4d21

See more details on using hashes here.

Provenance

The following attestation bundles were made for argtree-0.1.2-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