Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Command Creator

Tests Status PyPI version Documentation Status License Python Version

Command Creator is a Python package that simplifies the creation of command-line interfaces (CLIs) from pydantic models. You define a command by subclassing BaseCmdModel, declaring each argument as a model field, and implementing run(). Field type annotations drive argument parsing, validation and coercion, so you get a fully-featured CLI without writing argparse boilerplate.

Table of Contents

Installation

pip install command_creator

Command Creator requires Python 3.14+ and pydantic 2.13+. Shell completion is available through an optional extra (see Shell Completion):

pip install command_creator[shtab]

Simple Usage

A command is a subclass of command_creator.BaseCmdModel (itself a pydantic.BaseModel). Declare each argument as a field using arg() for a positional argument or option() for an option (--name), implement run(), and call run_and_exit() as the entry point.

    from command_creator import BaseCmdModel, arg, option


    class SimpleCommand(BaseCmdModel):
        """This doc-string is used as the command description in the help message."""

        # arg() -> a positional argument. With no default it is required.
        positional: str = arg(description="a required positional argument")
        # A positional with a default becomes optional on the command line.
        extra_positional: str = arg(default="Not-Given", description="an optional positional")
        # option() -> an option (--flag). A bool field is always a flag.
        flag: bool = option(default=False, description="sets self.flag to True when given")
        # `--output-file OUTPUT_FILE`; None until provided.
        output_file: str | None = option(default=None, description="where to write output")
        # `--args ARGS [ARGS ...]`; a list field accepts multiple values.
        args: list[str] | None = option(default=None, description="extra arguments")

        # run() holds the command's logic. Override it in every command.
        def run(self) -> None:
            print("Doing something")


    # run_and_exit() parses sys.argv, runs the command, and exits.
    if __name__ == "__main__":
        SimpleCommand.run_and_exit()

arg() and option() are thin wrappers over pydantic.Field: any Field keyword (default, default_factory, description, and validation constraints such as ge or max_length) is forwarded unchanged and fully type-checked. If you build a Field yourself, arg_meta() produces the equivalent CLI metadata for its json_schema_extra.

To parse without running, use SimpleCommand.parse(argv) (returns a populated instance) or SimpleCommand.get_parser() (returns the underlying argparse.ArgumentParser).

CLI Argument Features

Each model field represents a command-line argument. To add CLI behaviour, declare the field with command_creator.arg (positional) or command_creator.option (option) rather than a bare pydantic.Field. This section outlines the keywords those helpers accept.

Positional Arguments and Options

In unix-style CLIs there are two main ways data can be passed to a command: as a positional argument or as an option. Positional arguments are interpreted based solely on their position. Options use -/-- characters and a name, so --debug tells the command to run in debug mode regardless of where it is provided.

The distinction is explicit in Command Creator:

  • arg() declares a positional argument.
  • option() declares an option (--name).

Two field kinds are always options regardless of which helper is used, because there is no command-line concept for them as positionals:

  • bool fields, which become flags (--flag / store_true, or store_false when the default is True). A boolean must have a default.
  • count=True options (see count), which are mutually exclusive with a positional.

A positional argument with a default (or optional=True) may be omitted on the command line; a required option (an option() with no default) must be provided.

description

arg() / option() forward description= to pydantic.Field; it is used as the argument's help text in --help.

abrv

The abrv keyword (options only) takes a single character used as the short -[abrv] form alongside the long --name option, e.g. option(abrv="v") exposes -v. A numeric abbreviation is rejected because it would disable negative-number parsing.

choices

Choices are derived from the field's type annotation - there is no choices keyword. Annotate the field as an enum.Enum subclass or a typing.Literal[...] and its members become the argument's valid values automatically:

from enum import StrEnum
from typing import Literal
from command_creator import BaseCmdModel, option


class Casing(StrEnum):
    plain = "plain"
    caps = "caps"


class Cmd(BaseCmdModel):
    casing: Casing = option(default=Casing.plain)          # --casing {plain,caps}
    level: Literal["low", "high"] = option(default="low")  # --level {low,high}

    def run(self) -> None: ...

metavar

The metavar keyword takes a string used as the value placeholder shown in --help.

optional

The optional keyword takes a boolean:

  • On a positional argument it makes the value omittable (argparse nargs="?"); when omitted the field takes its default.
  • On an option it allows --opt to be given with no following value, in which case the field is set to None. The field must be declared as T | None for this to be valid.

default and default_factory

default and default_factory are forwarded to pydantic.Field. A field with no default is required; giving it a default makes it optional. default_factory is a callable that builds a fresh default at run time (use it for mutable defaults such as lists). See the pydantic documentation for details.

count

count=True (options only) makes a repeat-counter: the option may be provided multiple times and the field is set to the number of occurrences. For example a --verbose/-v option provided three times sets the field to 3. It requires an int field and is mutually exclusive with a positional argument.

verbose: int = option(default=0, count=True, abrv="v", description="increase verbosity")

Lists and tuples

A field annotated as list[T] (or set[T] / frozenset[T], or a variadic tuple[T, ...]) accepts multiple values on the command line. A required list requires at least one value (nargs="+"); a list with a default accepts zero or more (nargs="*"). A fixed-length tuple[A, B, ...] requires exactly that many values.

completer

The completer argument attaches a shell-completion hint to an argument's value, consumed by shtab when it generates a completion script (see Shell Completion). It accepts:

  • an shtab preset - shtab.FILE or shtab.DIRECTORY;
  • the string shorthands "file", "dir" / "directory" (resolved to those presets);
  • a {shell: snippet} mapping for a custom completer per shell.
import shtab
from command_creator import BaseCmdModel, arg, option


class Convert(BaseCmdModel):
    """Convert a file."""

    src: str = arg(description="input file", completer="file")           # shorthand
    out_dir: str = option(default=".", completer=shtab.DIRECTORY)         # shtab preset
    fmt: str = option(                                                    # custom snippet
        default="png", completer={"bash": "compgen -W 'png jpg webp'", "zsh": "(png jpg webp)"}
    )

    def run(self) -> None: ...

completer requires the optional shtab dependency; without it the hint is stored but never applied (it only matters at script-generation time, which itself needs shtab).

Argument Groups

Arguments can be organised into titled groups in the --help output. There are two ways to do this.

1. The group keyword on arg() / option()

Pass group="Title" and the argument is listed under that heading. Same-level arguments sharing a title are displayed together. Grouping is display-only: it does not change parsing, dests or the field name.

from command_creator import BaseCmdModel, option


class Serve(BaseCmdModel):
    """Run the server."""

    host: str = option(default="localhost", group="Network")
    port: int = option(default=8080, group="Network")
    debug: bool = option(default=False)  # ungrouped

    def run(self) -> None: ...

2. A nested command as a field

A field whose type is itself a BaseCmdModel subclass is flattened: the child model's fields become command-line arguments on the parent, listed together under one group. The child does not become a sub-command and its run() is never called - the parsed child instance is simply stored on the field, giving you structured access (self.connection.host).

Because the group's arguments share the parent's flat namespace, every flattened field name must be unique across the command (a clash raises InvalidCommandError).

from command_creator import BaseCmdModel, group, option


class Connection(BaseCmdModel):
    """Connection settings."""

    host: str = option(default="localhost", description="server host")
    port: int = option(default=5432, description="server port")


class Serve(BaseCmdModel):
    """Run the server."""

    # A BaseCmdModel-typed field is auto-detected as a group; use group() to
    # override the title or forward pydantic Field arguments.
    connection: Connection = group(title="Connection Settings")
    debug: bool = option(default=False)

    def run(self) -> None:
        print(f"serving on {self.connection.host}:{self.connection.port}")

The group title defaults to the child's cmd_name (if set), then the child class name; group(title=...) overrides it. Groups nest to any depth.

Sub-commands

A command becomes a parent of others by listing child command classes in the sub_commands key of its model_config (a CmdConfig). Each child is itself a BaseCmdModel, so sub-commands nest to any depth. Give a command a custom name or alternate names with cmd_name / cmd_aliases.

When a sub-command is selected, run_and_exit() runs run() for every command along the invoked path, from root to leaf (whole-path dispatch). A parent that declares sub_commands cannot also have positional arguments, since a positional would consume the sub-command token - expose those as options instead.

from command_creator import BaseCmdModel, CmdConfig, arg, option


class Add(BaseCmdModel):
    """Add a remote."""

    # cmd_name overrides the default (lower-cased class name).
    model_config = CmdConfig(cmd_name="add", cmd_aliases=("a",))

    url: str = arg(description="remote URL")
    name: str = option(default="origin", description="local name for the remote")

    def run(self) -> None:
        print(f"Added remote {self.name!r} -> {self.url}")


class Remote(BaseCmdModel):
    """Manage remotes."""

    model_config = CmdConfig(sub_commands=(Add,))

    def run(self) -> None:
        # Runs before the selected child; nothing to do here.
        pass


class Tool(BaseCmdModel):
    """Top-level tool: `tool remote add <url> --name origin`."""

    model_config = CmdConfig(sub_commands=(Remote,))
    verbose: int = option(default=0, count=True, abrv="v", description="increase verbosity")

    def run(self) -> None: ...


if __name__ == "__main__":
    Tool.run_and_exit()

Sub-commands can also be registered imperatively with add_sub_command, either as a class decorator or by passing the class directly - handy for reusing a command across several parents:

@Remote.add_sub_command
class Remove(BaseCmdModel):
    """Remove a remote."""

    name: str = arg(description="remote to remove")

    def run(self) -> None:
        print(f"Removed {self.name!r}")


Tool.add_sub_command(Remote)  # equivalent to listing it in sub_commands

The selected child is reachable at self.sub_command, and self.command_chain() returns the full invoked path from this command down to the selected leaf.

Propagating options to sub-commands

Normally an option declared on a parent command must be given before the sub-command token: once argparse consumes the sub-command name, the rest of the line is handed to the sub-command's parser, which does not know the parent's options. So tool --verbose remote add URL works but tool remote add URL --verbose does not.

Mark an option (or a group()) with propagate=True to make it a global option, accepted anywhere in the argument list - before or after any sub-command token, at any nesting depth:

class Tool(BaseCmdModel):
    model_config = CmdConfig(sub_commands=(Remote,))

    # A global flag: usable as `tool -v remote add URL` *or* `tool remote add URL -v`.
    verbose: int = option(default=0, count=True, abrv="v", propagate=True)
    # A group can propagate all of its flattened fields at once.
    logging: LogOpts = group(propagate=True)

    def run(self) -> None: ...

The option remains owned by the declaring command - read it as self.verbose regardless of where it was typed on the command line. If it is given at more than one level the deepest (last) value wins.

Two rules keep the "anywhere" promise honest, each enforced when the parser is built (raising InvalidCommandError):

  • the field must have a default (a propagated option must be omittable at every level it can appear, so it cannot be required), and
  • only options and groups can propagate, not positional arguments (a positional's meaning depends on its position, so "anywhere" is meaningless).

Using with Sphinx-Autoprogram

get_parser() returns the underlying argparse.ArgumentParser, so the tool documents cleanly with sphinxcontrib-autoprogram:

    .. autoprogram:: pkg_name.module:CommandClass.get_parser()

Shell Completion

Command Creator can generate completion scripts for bash, zsh, tcsh, fish and powershell via the optional shtab dependency:

pip install command_creator[shtab]

Set completion=True in your root command's model_config (see CmdConfig) and the tool automatically grows a completion <shell> sub-command:

from command_creator import BaseCmdModel, CmdConfig, arg, option


class Greet(BaseCmdModel):
    """Greet someone."""

    name: str = arg(description="who to greet")

    def run(self) -> None:
        print(f"Hello, {self.name}!")


class Tool(BaseCmdModel):
    """Example tool."""

    # completion=True -> a `completion <shell>` verb; completion_name renames it.
    model_config = CmdConfig(sub_commands=(Greet,), completion=True)

    def run(self) -> None: ...


if __name__ == "__main__":
    Tool.run_and_exit()

Users then source the script for their shell (once, or from their shell rc file):

eval "$(mytool completion bash)"        # bash
eval "$(mytool completion zsh)"         # zsh
mytool completion fish | source         # fish

Notes:

  • The script is emitted at parse time, so a command's run() output can never pollute it.
  • Rename the verb with CmdConfig(completion=True, completion_name="complete").
  • Enabling completion=True without shtab installed raises InvalidCommandError.
  • Per-argument completers (file paths, directories, custom snippets) are configured with the completer keyword on arg() / option().

Metadata

Release files for command-creator 3.0.0a3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for command-creator 3.0.0a3
File Size Uploaded
command_creator-3.0.0a3.tar.gz 119.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for command-creator 3.0.0a3
File Interpreter ABI Platform
command_creator-3.0.0a3-py3-none-any.whl Python 3 none any Details

Total release size: 165.5 kB

Release files / command_creator-3.0.0a3.tar.gz

Download URL command_creator-3.0.0a3.tar.gz
Size 119.2 kB
Tags Source
SHA-256 checksum
How to use checksums
2fdf6b95c6a2e8d3a1da812372897d0939dbdd41e31d8a8536d96edca6a61842
BLAKE2b-256 checksum
How to use checksums
41a80f262112c5291b23c4ab95777bd817a0102b53b06dc24ea5f7f13a49cba1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 Sep 3, 2026.

Transparency log

Release files / command_creator-3.0.0a3-py3-none-any.whl

Download URL command_creator-3.0.0a3-py3-none-any.whl
Size 46.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
16b8e6f7621e0ef55b85cb23b8796d223cd8cf4a3113aed6fea9e0d3bcc48f49
BLAKE2b-256 checksum
How to use checksums
9fbca85ba9e25764bf8a05a748d9c522e30e0a149b773e70e1ce6657792e55f6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 Sep 3, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page