Metadata-Version: 2.4
Name: argtree
Version: 0.1.0
Summary: Typed, declarative, faithful argparse: a command is a dataclass/NamedTuple tree.
Project-URL: Homepage, https://github.com/JPHutchins/argtree
Project-URL: Repository, https://github.com/JPHutchins/argtree
Project-URL: Issues, https://github.com/JPHutchins/argtree/issues
Author-email: JP Hutchins <jphutchins@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: CLI,argparse
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# argtree

[![ci](https://github.com/JPHutchins/argtree/actions/workflows/ci.yaml/badge.svg)](https://github.com/JPHutchins/argtree/actions/workflows/ci.yaml)
[![python](https://img.shields.io/badge/python-3.10%E2%80%933.15-blue)](https://github.com/JPHutchins/argtree)
[![license: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

Typed, declarative, **faithful** [`argparse`](https://docs.python.org/3/library/argparse.html).

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

```sh
uv add argtree     # or: pip install argtree
```

No runtime dependencies. Ships with `py.typed`.

## Quickstart

```python
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`](https://github.com/JPHutchins/argtree/blob/main/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/`](https://github.com/JPHutchins/argtree/tree/main/tests)).

### [mypy](https://github.com/python/mypy) — a flat parser with argument groups

<table>
<tr><th><a href="https://github.com/JPHutchins/argtree/blob/main/examples/mypy_original.py">original argparse</a></th><th><a href="https://github.com/JPHutchins/argtree/blob/main/examples/mypy_cli.py">argtree</a></th></tr>
<tr valign="top">
<td>

```python
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="*")
```

</td>
<td>

```python
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)
```

</td>
</tr>
</table>

### [pre-commit](https://github.com/pre-commit/pre-commit) — nested subcommands

<table>
<tr><th><a href="https://github.com/JPHutchins/argtree/blob/main/examples/pre_commit_original.py">original argparse</a></th><th><a href="https://github.com/JPHutchins/argtree/blob/main/examples/pre_commit_cli.py">argtree</a></th></tr>
<tr valign="top">
<td>

```python
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")
```

</td>
<td>

```python
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)
```

</td>
</tr>
</table>

## 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](https://github.com/JPHutchins/camas) tree in
[`tasks.py`](tasks.py); CI runs the same tree:

```sh
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](LICENSE).
