Skip to content

Features

Commands

Commands are the core building blocks of your CLI. You define them using decorators.

from piou import Cli, Option

cli = Cli(description="A CLI tool")


@cli.command(cmd="foo", help="Run foo command")
def foo_main(
    foo1: int = Option(help="Foo arguments"),
    foo2: str = Option("-f", "--foo2", help="Foo2 arguments"),
    foo3: str | None = Option(None, "-g", "--foo3", help="Foo3 arguments"),
):
    pass

In this case: - foo1 is a required positional argument (no keyword flags, default is ...). - foo2 is a required keyword argument (has flags, default is ...). - foo3 is an optional keyword argument (has flags, default is None).

Optional Ellipsis

Option() defaults to required (...), so Option(help='...') is equivalent to Option(..., help='...').

Global Options

You can specify global options that apply to all commands:

cli = Cli(description="A CLI tool")
cli.add_option("-q", "--quiet", help="Do not output any message")

Docstrings as Descriptions

The description can also be extracted from the function docstring:

@cli.command(cmd="bar2")
def bar_2_main():
    """
    Run foo command
    """
    pass

Async Support

A command can also be asynchronous; it will be run automatically using asyncio.run.

@cli.command(cmd="bar", help="Run foo command")
async def bar_main():
    pass

Default Command (Main)

Use the @cli.main() decorator (or is_main=True) to define a default command that runs when no named command is matched.

@cli.main()
def run_main():
    pass

Run it directly:

python -m examples.simple -h

@cli.main() can also coexist with named commands. Named commands always take priority, and unmatched input falls back to the default command:

cli = Cli(description="My CLI")


@cli.main()
def default(name: str = Option(...)):
    print(f"Hello {name}")


@cli.command("greet", help="Greet someone")
def greet(name: str = Option(..., "--name")):
    print(f"Greetings, {name}!")
python run.py world          # runs default: "Hello world"
python run.py greet --name a # runs greet: "Greetings, a!"

Command Groups / Sub-commands

You can nest commands arbitrarily deep using command groups.

sub_cmd = cli.add_command_group("sub", help="A sub command")
sub_cmd.add_option("--test", help="Test mode")


@sub_cmd.command(cmd="bar", help="Run bar command")
def sub_bar_main(**kwargs):
    pass

Running python run.py sub -h will show the specific help for that group.

sub command output

Options Processor

Sometimes you need to intercept global options before a command runs (e.g., to set up logging level).

from piou import Cli, Option

cli = Cli(description="A CLI tool")


@cli.processor()
def processor(verbose: bool = Option(False, "--verbose", help="Increase verbosity")):
    print(f"Processing {verbose=}")

By default, processed options are consumed. To pass them down to the command functions as well, use propagate_options=True when creating the Cli or sub-parser.

You can also use set_options_processor() instead of the decorator. Options defined via Option() in the function signature are automatically extracted and registered as global options:

cli = Cli(description="A CLI tool")


def processor(
    verbose: bool = Option(False, "-v", "--verbose"),
    log_level: str | None = Option(None, "--log-level"),
): ...


cli.set_options_processor(processor)

This is equivalent to using the @cli.processor() decorator.

Error Handling

CommandError

Use CommandError to display a user-friendly error message and exit with code 1, without showing a traceback.

from piou import Cli, Option
from piou.exceptions import CommandError

cli = Cli(description="A CLI tool")


@cli.command(cmd="deploy", help="Deploy the application")
def deploy_main(
    env: str = Option(help="Target environment"),
):
    if env not in ("staging", "production"):
        raise CommandError(f"Unknown environment: {env!r}")
    ...

When raised inside a command, CommandError is caught by Cli.run() and the message is printed using the configured formatter — no Python traceback is shown to the user.

command error output

show_help_on_error

By default, usage errors (unknown command, missing argument, unknown option, ...) only print an error message. Set show_help_on_error=True to also print the contextual help after the error:

from piou import Cli

cli = Cli(description="A CLI tool", show_help_on_error=True)
$ my-cli sub eor
Unknown command 'eor'. Possible commands are "foo"

USAGE
     my-cli sub foo <value>
...

The help is contextual: an unknown command inside a command group shows that group's help, and an argument error shows the failing command's help. If the group has no commands to suggest, only the error message is printed.

Shell Completion

Any piou CLI can complete its commands, options and Literal/static choices in bash, zsh and fish, with help text shown as descriptions in zsh and fish. Add one line to your shell config (my-cli being your installed script):

source <(my-cli --completions bash)          # ~/.bashrc
source <(my-cli --completions zsh)           # ~/.zshrc, after compinit
my-cli --completions fish | source           # ~/.config/fish/config.fish

Completion is dynamic: on <TAB> the shell re-runs your CLI with _PIOU_COMPLETE=<shell>, which prints candidates instead of running a command. Command bodies never run. The script is registered for the running script's name; pass another one when calling through a wrapper, an alias or python -m (my-cli --completions zsh my-alias). From Python, cli.completion_script("zsh", prog="my-cli") returns the same script.

Candidates are quoted for the target shell, so a value like a b or prod;rm -rf is inserted as a single literal argument.

Callable choices run on every <TAB>, so they are off by default: enable them with Cli(complete_dynamic_choices=True) when they are cheap, or per user with export PIOU_COMPLETE_DYNAMIC=1 (an explicit complete_dynamic_choices= wins over the env var). A choices function that fails gives no candidates.