Skip to main content

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: SubCmd fields 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.argv when argv is None)
  • run(argv=None) -> None — parse, then dispatch to @handler or run() 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_armature-1.0.0.tar.gz (10.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_armature-1.0.0-py3-none-any.whl (10.4 kB view details)

Uploaded Python 3

File details

Details for the file python_armature-1.0.0.tar.gz.

File metadata

  • Download URL: python_armature-1.0.0.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

Hashes for python_armature-1.0.0.tar.gz
Algorithm Hash digest
SHA256 664ef4ddba27224f4772e4dd4b2c48e0125f49051f94f70603fa95b915fc347d
MD5 573d09e733999909395a89a67d415200
BLAKE2b-256 1864bd3c87318009e215ba2b87c5a2e1e9736e73fdb21a712c7cc18a530eac16

See more details on using hashes here.

File details

Details for the file python_armature-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for python_armature-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 22b3169169c7aac8e097852afdf20c64059e3b574e5060f41f337cec76a387c1
MD5 ba116aac9fea266cfe5208ba8b2f06be
BLAKE2b-256 e72ba82c94adba792289f5b7cc87be67e0bbf2c0a73a311b0209127206ef9894

See more details on using hashes here.

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