argtree
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 argparse | argtree |
|---|---|
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 argparse | argtree |
|---|---|
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
argtreeto[build-system].requiresso 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.
Metadata
Release files for argtree 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| argtree-0.1.2.tar.gz | 67.4 kB | With provenance | Details for argtree-0.1.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| argtree-0.1.2-py3-none-any.whl | Python 3 | none | any | Details for argtree-0.1.2-py3-none-any.whl |
Total release size: 87.4 kB
Release files / argtree-0.1.2.tar.gz
| Download URL | argtree-0.1.2.tar.gz |
|---|---|
| Size | 67.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a91bbaf093bf26dd03f3a87b6932175b0d6f8fd02e05454c6eaa08baf2f180d1
|
|
BLAKE2b-256 checksum How to use checksums |
c1fd22a96ec45c68f96fc3c574dfc5a183ded6a6fc3e6d3b2a72ce7ae7fe29ed
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 5, 2026.
Transparency logRelease files / argtree-0.1.2-py3-none-any.whl
| Download URL | argtree-0.1.2-py3-none-any.whl |
|---|---|
| Size | 20.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5d231c541b394d6670b1d26ae77e5aa7888c4bb325150db1de06f4dadf5b3805
|
|
BLAKE2b-256 checksum How to use checksums |
42fa486e161cd6f3aefb99f57672735f02b779ad165d20953f401fcb0ceb4d21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 5, 2026.
Transparency logSecurity for argtree 0.1.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for argtree-0.1.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 5, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.