Zero-dependency dataclass-driven CLI builder for Python
Project description
armature
A zero-dependency Python library for building CLIs from plain dataclasses. No function decorators, no magic — just annotate a @dataclass and armature wires it into argparse.
pip install armature
Python 3.12+. Zero runtime dependencies.
Quickstart
from armature import CLI, Arg, dataclass, Annotated
@dataclass
class Greet:
"""Print a greeting."""
name: str
loud: Annotated[bool, Arg(short="-l", help="shout it")] = False
def run(self) -> None:
msg = f"Hello, {self.name}!"
print(msg.upper() if self.loud else msg)
if __name__ == "__main__":
CLI(Greet).run()
$ python greet.py Alice
Hello, Alice!
$ python greet.py Alice --loud
HELLO, ALICE!
$ python greet.py Alice -l
HELLO, ALICE!
Everything you need comes from a single import line:
from armature import CLI, Arg, SubCmd, handler, dataclass, field, Annotated
How fields map to CLI arguments
| Field definition | CLI shape |
|---|---|
name: str |
positional: prog Alice |
count: int = 1 |
option: prog --count 5 |
verbose: bool = False |
flag: prog --verbose |
items: list[str] |
multi-value positional: prog a b c |
tag: str | None = None |
optional option: prog --tag v1 |
Underscores in field names become hyphens on the CLI (dry_run → --dry-run).
Adding metadata
Use Annotated[T, Arg(...)] to attach help text, choices, a short alias, and more:
from armature import CLI, Arg, dataclass, Annotated
@dataclass
class Deploy:
env: Annotated[str, Arg(
help="target environment",
choices=["prod", "staging"],
)]
verbose: Annotated[bool, Arg(short="-v")] = False
Arg fields:
| Field | Type | Description |
|---|---|---|
help |
str |
Help text shown in --help output |
choices |
list |
Restrict input to allowed values |
short |
str |
Short flag alias, e.g. "-v" |
metavar |
str |
Display name in usage string |
required |
bool |
Make a named option required (no positional default) |
converter |
Callable[[str], T] |
Custom type converter / validator |
env |
str |
Environment variable to read as default |
group |
str |
Mutually exclusive group name |
hidden |
bool |
Hide flag from --help output |
remainder |
bool |
Capture all remaining tokens as list[str] |
action |
str |
"count" for -vvv style; "append" for --tag a --tag b |
Subcommands
Flat — CLI([A, B])
Pass a list of command classes. The first token on the CLI becomes the subcommand name (lowercased class name):
@dataclass
class Add:
text: str
def run(self) -> None: print(f"added: {self.text}")
@dataclass
class Done:
id: int
def run(self) -> None: print(f"done: {self.id}")
CLI([Add, Done]).run()
$ prog add "Buy milk"
added: Buy milk
$ prog done 3
done: 3
Nested — Annotated[A | B, SubCmd]
Use SubCmd as an annotation marker for arbitrarily deep hierarchies:
from armature import CLI, SubCmd, dataclass, Annotated
@dataclass
class Ls:
filter: str = ""
def run(self) -> None: print(f"ls {self.filter}")
@dataclass
class Rm:
name: str
def run(self) -> None: print(f"rm {self.name}")
@dataclass
class Image:
cmd: Annotated[Ls | Rm, SubCmd]
def run(self) -> None: self.cmd.run()
@dataclass
class App:
cmd: Annotated[Image, SubCmd]
debug: bool = False
def run(self) -> None:
if self.debug: print("[debug]")
self.cmd.run()
CLI(App).run()
$ prog --debug image ls --filter ubuntu
[debug]
ls ubuntu
$ prog image rm nginx
rm nginx
CLI.parse() returns the full tree. Each level's flags are preserved:
result = CLI(App).parse(["--debug", "image", "rm", "nginx"])
# result = App(debug=True, cmd=Image(cmd=Rm(name="nginx")))
Note:
SubCmdfields have no default value, so Python's dataclass rules require them to appear before any fields that have defaults.@dataclass class Good: cmd: Annotated[A | B, SubCmd] # required first verbose: bool = False # default after
Subcommand name and aliases
Override the CLI token with __armature_name__ and add aliases with __armature_aliases__:
@dataclass
class RemoveImage:
"""Remove a container image."""
__armature_name__ = "rm"
__armature_aliases__ = ["remove", "del"]
name: str
def run(self) -> None: print(f"removed {self.name}")
$ prog rm nginx
$ prog remove nginx # alias
$ prog del nginx # alias
Execution
parse() — return the instance, do what you want
args = CLI(Deploy).parse()
# args is a typed Deploy instance
run_deploy(args.env, dry_run=args.dry_run)
run() — automatic dispatch
Three styles, all compatible:
Method on the dataclass:
@dataclass
class Deploy:
env: str
def run(self) -> None:
print(f"deploying to {self.env}")
CLI(Deploy).run()
@handler decorator (separate data from logic):
from armature import handler
@dataclass
class Deploy:
env: str # pure data, no run() method
@handler(Deploy)
def deploy(cmd: Deploy) -> None:
print(f"deploying to {cmd.env}")
CLI(Deploy).run()
@handler accepts any callable, including callable classes:
@handler(Deploy)
class DeployHandler:
def __call__(self, cmd: Deploy) -> None:
...
Just call parse() and dispatch yourself:
result = CLI([Deploy, Rollback]).parse()
match result:
case Deploy(env=env):
deploy(env)
case Rollback(version=v):
rollback(v)
Async handlers: both run() methods and @handler functions can be async def. Armature calls asyncio.run() automatically:
@dataclass
class Sync:
target: str
async def run(self) -> None:
await do_work(self.target)
CLI(Sync).run()
Dispatch priority when using run(): registered @handler > run() method > RuntimeError.
Advanced Arg features
Environment variable fallback
@dataclass
class Deploy:
token: Annotated[str, Arg(env="DEPLOY_TOKEN", help="API token")]
env: Annotated[str, Arg(env="DEPLOY_ENV")] = "staging"
If DEPLOY_TOKEN is set in the environment, --token becomes optional. Help text automatically shows (env: DEPLOY_TOKEN).
Required named options
@dataclass
class Create:
name: Annotated[str, Arg(required=True, help="resource name")]
region: Annotated[str, Arg(required=True, short="-r")]
Produces --name and --region flags that are required (not positional).
Custom type converters
import pathlib
@dataclass
class Convert:
path: Annotated[pathlib.Path, Arg(converter=pathlib.Path)]
upper: Annotated[str, Arg(converter=str.upper)]
Any Callable[[str], T] works. Raised argparse.ArgumentTypeError messages surface directly in the error output.
Count action (-vvv)
@dataclass
class Cmd:
verbose: Annotated[int, Arg(short="-v", action="count")] = 0
$ prog -v # verbose=1
$ prog -v -v -v # verbose=3
$ prog -vvv # verbose=3
Append action (--tag a --tag b)
@dataclass
class Build:
tag: Annotated[list[str], Arg(short="-t", action="append")] = field(default_factory=list)
$ prog --tag latest --tag v1.2
# tag=["latest", "v1.2"]
Remainder / pass-through args
@dataclass
class Run:
image: str
cmd: Annotated[list[str], Arg(remainder=True)] = field(default_factory=list)
$ prog ubuntu -- bash -c "echo hi"
# cmd=["bash", "-c", "echo hi"]
Hidden flags
@dataclass
class Cmd:
debug_mode: Annotated[bool, Arg(hidden=True)] = False
The flag is accepted and parsed but does not appear in --help output.
Mutually exclusive groups
@dataclass
class Get:
output_json: Annotated[bool, Arg(group="fmt")] = False
output_yaml: Annotated[bool, Arg(group="fmt")] = False
Passing both flags at once produces an argparse error.
CLI constructor options
CLI(
commands, # type | list[type]
version="1.2.3", # adds --version / -V flag
epilog="See docs.", # text appended to --help output
)
| Parameter | Type | Description |
|---|---|---|
commands |
type | list[type] |
Single command class or list of subcommand classes |
version |
str | None |
Version string; adds --version / -V flag |
epilog |
str | None |
Extra text printed after the help message |
Examples
The examples/ directory contains three runnable CLIs:
| Example | Demonstrates |
|---|---|
examples/greet |
Single command, Arg metadata, short alias |
examples/task |
Flat subcommands (add, show, done) |
examples/dock |
Nested subcommands, __armature_name__ overrides |
Run any example from the repo root:
python -m examples.greet Alice --loud
python -m examples.task add "Buy milk"
python -m examples.dock image ls --filter ubuntu
API reference
CLI(commands, *, version=None, epilog=None)
| Form | Description |
|---|---|
CLI(MyCmd) |
Single command |
CLI([A, B, C]) |
Flat subcommand dispatch |
Methods:
parse(argv=None) -> T— parse and return a typed instance (sys.argvwhenargvisNone)run(argv=None) -> None— parse, then dispatch to@handlerorrun()method
Arg(...)
Field metadata descriptor. All fields optional. Use inside Annotated[T, Arg(...)].
SubCmd
Sentinel class. Use as Annotated[A | B, SubCmd] to mark a field as a subcommand dispatch point.
@handler(CommandClass)
Register a callable as the execution handler for a command class. Takes precedence over run() methods. Supports async def handlers.
Class attributes for subcommand control
| Attribute | Type | Description |
|---|---|---|
__armature_name__ |
str |
Override the CLI token (default: lowercased class name) |
__armature_aliases__ |
list[str] |
Additional CLI tokens that dispatch to this class |
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 python_armature-1.0.1.tar.gz.
File metadata
- Download URL: python_armature-1.0.1.tar.gz
- Upload date:
- Size: 10.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b4ec166542ce53b345447898c047a201407d2dc90952af9914e3c09231b9716
|
|
| MD5 |
9b9ac57b3781b60c9b35eb2bf125d2dc
|
|
| BLAKE2b-256 |
4e9cb52611d774a9894ab8ae874dd5a9a04397a1ae27e278878952f07615288d
|
File details
Details for the file python_armature-1.0.1-py3-none-any.whl.
File metadata
- Download URL: python_armature-1.0.1-py3-none-any.whl
- Upload date:
- Size: 10.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be3e296f1dd67ee55c7baed3258dddbb23e394e70743f39d51ae5993f26a3e73
|
|
| MD5 |
6a967d1ceaa6cd13499b6c5067fe0d08
|
|
| BLAKE2b-256 |
c7f11fa0de3b4cb5419cfa9ed9247e3b7546400ae1c2d38d89d5013cd35798fb
|