Skip to main content

Getting Started with quiCkLI: Multi-Command Applications

· 4 min read
Sven Patrick Meier
Creator and maintainer of quiCkLI, an educational Python CLI framework

In the first two parts of this series you built a greeting tool and a file viewer. In this final article you will combine everything you have learned to build pyk5l, a kubectl-like multi-command application.

By the end you will understand every primitive in quickli and how they fit together in a realistic application.

What you will build

$ python app.py get pods
$ python app.py get pods --output json --namespace ops
$ python app.py describe pod api-7d4f5f6b89-l2xq9
$ python app.py logs api-7d4f5f6b89-l2xq9 --tail 5 --timestamps
$ python app.py apply manifests/web-pod.yaml --dry-run

@app.command vs @app.entrypoint

Until now you used @app.entrypoint, which registers a single handler for the whole application. When you need multiple commands, use @app.command instead:

@app.command(
name="get",
help_text="List resources in the selected namespace.",
arguments=[...],
options=[...],
)
def get(resource: str, ...) -> str:
...

The name parameter becomes the first token on the command line. Omitting name uses the function name. You can register as many commands as you need.

Custom validator factories

quickli ships with built-in validators (file_path, positive_number, and others), but you can write your own using the same pattern:

def one_of(*choices: str):
allowed = tuple(choices)
description = "one of: " + ", ".join(allowed)

def validate(value: object) -> object:
if value not in allowed:
raise ValueError(f"Expected one of: {', '.join(allowed)}")
return value

validate.description = description
return validate

A validator factory returns a callable that:

  1. Receives the converted value.
  2. Returns the value unchanged if it is valid.
  3. Raises ValueError with a clear message if it is not.

Setting validate.description provides the text that appears in help and error output.

CommandExecutionError

CommandExecutionError is for expected domain-level failures - not programming errors:

def _find_pod(name: str, namespace: str) -> Pod:
for pod in PODS:
if pod.namespace == namespace and pod.name == name:
return pod
raise CommandExecutionError(
f"Pod '{name}' was not found in namespace '{namespace}'."
)

This exception propagates unchanged out of Application.run(). Your application wrapper can catch it and turn it into an exit code:

import sys
try:
print(app.run())
except quickli.CommandExecutionError as error:
print(f"Error: {error}", file=sys.stderr)
sys.exit(1)

Global options shared across commands

Declare options once on Application. Every command handler that needs them declares the same parameter name:

app = Application(
name="pyk5l",
global_options=[
Option("namespace", short_name="n", default="default"),
Option("output", short_name="o", default="table",
validators=[one_of("table", "json", "wide")]),
Option("verbose", short_name="v", is_flag=True),
],
)

@app.command(name="get", ...)
def get(resource: str, namespace: str = "default", output: str = "table",
verbose: bool = False) -> str:
...

@app.command(name="describe", ...)
def describe(resource: str, name: str, namespace: str = "default",
verbose: bool = False, output: str = "table") -> str:
...

quickli matches global option names to parameter names for each command.

Full source

The complete example is at packages/core/examples/complex/pyk5l/app.py. Try it:

$ git clone https://github.com/spmse/quickli.git
$ cd quickli
$ pip install -e packages/core
$ cd packages/core/examples/complex/pyk5l
$ python app.py get pods

What you learned

  • @app.command registers named commands in a multi-command application.
  • Custom validator factories follow the same pattern as built-in validators.
  • CommandExecutionError signals expected domain-level failures.
  • Global options declared on Application are available to every command handler.

The complete quiCkLI primitives

PrimitivePurpose
ApplicationRoot container; owns dispatch and registration
CommandNamed operation (registered via @app.command)
ArgumentPositional, ordered input
OptionNamed input; flags, repeatable values, converters, validators
PluginReusable command bundle loaded into an application

Read the concept reference for the full API or explore the implementation guides to follow along with each example in detail.