Typed, declarative, faithful argparse: a command is a dataclass/NamedTuple tree.
Project description
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, 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 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_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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5d7cd8724b12455330cf8994542d85e351d4a027bd08a2b8a5cdcaddd4c67bdd
|
|
| MD5 |
56be2b71b7ea956b3961bbe58d57af71
|
|
| BLAKE2b-256 |
0cc1c2117bad1a43bf6d9f63b654bc2bf1c372b7a645b9a0fd3fcf515bff49b1
|
Provenance
The following attestation bundles were made for argtree-0.1.0.tar.gz:
Publisher:
ci.yaml on JPHutchins/argtree
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
argtree-0.1.0.tar.gz -
Subject digest:
5d7cd8724b12455330cf8994542d85e351d4a027bd08a2b8a5cdcaddd4c67bdd - Sigstore transparency entry: 1685965991
- Sigstore integration time:
-
Permalink:
JPHutchins/argtree@466a4e64138536c98952ea960b87ea3dbf1ca68b -
Branch / Tag:
refs/tags/0.1.0 - Owner: https://github.com/JPHutchins
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yaml@466a4e64138536c98952ea960b87ea3dbf1ca68b -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
de0b7935f8d0e297e16f0a49a0cfa451720a855954d40467847c5d06421c5f14
|
|
| MD5 |
7e21f02ff7b633447b60f287196a0f9c
|
|
| BLAKE2b-256 |
c44ba19f7b9c404f4a8853036e32448052ea4c4f889ad913d9cee9ff00719b76
|
Provenance
The following attestation bundles were made for argtree-0.1.0-py3-none-any.whl:
Publisher:
ci.yaml on JPHutchins/argtree
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
argtree-0.1.0-py3-none-any.whl -
Subject digest:
de0b7935f8d0e297e16f0a49a0cfa451720a855954d40467847c5d06421c5f14 - Sigstore transparency entry: 1685966100
- Sigstore integration time:
-
Permalink:
JPHutchins/argtree@466a4e64138536c98952ea960b87ea3dbf1ca68b -
Branch / Tag:
refs/tags/0.1.0 - Owner: https://github.com/JPHutchins
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yaml@466a4e64138536c98952ea960b87ea3dbf1ca68b -
Trigger Event:
push
-
Statement type: