CLI Development
Build professional CLIs like netapi. Click for structure, Rich for beautiful output.
Click Basics
Click is the standard for Python CLIs. It’s composable, well-documented, and handles edge cases.
Installation
uv add click rich
Simple Command
import click
@click.command()
@click.option("--name", "-n", default="World", help="Name to greet")
@click.option("--count", "-c", default=1, type=int, help="Number of greetings")
def hello(name: str, count: int):
"""Simple program that greets NAME."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == "__main__":
hello()
python hello.py --name Evan --count 3
python hello.py -n Evan -c 3
python hello.py --help
Arguments vs Options
import click
@click.command()
@click.argument("hostname") # Required positional
@click.option("--port", "-p", default=443, help="Port number") # Optional flag
def connect(hostname: str, port: int):
"""Connect to HOSTNAME."""
click.echo(f"Connecting to {hostname}:{port}")
# Usage:
# connect ise-01 # hostname=ise-01, port=443
# connect ise-01 --port 9060
Options
Required Options
@click.command()
@click.option("--username", "-u", required=True, help="Username")
@click.option("--password", "-p", prompt=True, hide_input=True, help="Password")
def login(username: str, password: str):
"""Login with credentials."""
# --password prompts if not provided, hides input
click.echo(f"Logging in as {username}")
Option Types
@click.command()
@click.option("--port", type=int, default=443)
@click.option("--timeout", type=float, default=30.0)
@click.option("--verbose", is_flag=True) # Boolean flag
@click.option("--format", type=click.Choice(["json", "table", "csv"]))
@click.option("--config", type=click.Path(exists=True)) # File must exist
@click.option("--output", type=click.File("w")) # Opens file for writing
def process(port, timeout, verbose, format, config, output):
pass
Multiple Values
@click.command()
@click.option("--host", "-h", multiple=True, help="Host (can repeat)")
@click.option("--vlan", type=int, nargs=2, help="VLAN range (start end)")
def scan(host: tuple[str, ...], vlan: tuple[int, int] | None):
"""Scan hosts."""
for h in host:
click.echo(f"Scanning {h}")
if vlan:
click.echo(f"VLAN range: {vlan[0]} to {vlan[1]}")
# Usage:
# scan -h ise-01 -h ise-02 --vlan 10 20
Environment Variables
@click.command()
@click.option("--username", envvar="NETAPI_USERNAME")
@click.option("--password", envvar="NETAPI_PASSWORD")
def login(username: str, password: str):
"""Login using env vars or options."""
pass
# Priority: CLI option > Environment variable > Default
Callbacks
def validate_mac(ctx, param, value):
"""Validate MAC address format."""
import re
if value and not re.match(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", value):
raise click.BadParameter(f"Invalid MAC format: {value}")
return value.upper() if value else value
@click.command()
@click.option("--mac", callback=validate_mac)
def lookup(mac: str):
"""Look up endpoint by MAC."""
click.echo(f"Looking up {mac}")
Command Groups
Basic Group
import click
@click.group()
def cli():
"""netapi - Infrastructure automation CLI."""
pass
@cli.command()
def version():
"""Show version."""
click.echo("netapi v0.1.0")
@cli.command()
@click.argument("hostname")
def ping(hostname: str):
"""Ping a host."""
click.echo(f"Pinging {hostname}...")
if __name__ == "__main__":
cli()
# Usage:
# netapi version
# netapi ping ise-01
Nested Groups
@click.group()
def cli():
"""netapi CLI."""
pass
@cli.group()
def ise():
"""ISE operations."""
pass
@ise.command()
def endpoints():
"""List endpoints."""
click.echo("Listing endpoints...")
@ise.command()
@click.argument("mac")
def lookup(mac: str):
"""Look up endpoint."""
click.echo(f"Looking up {mac}")
@cli.group()
def vault():
"""Vault operations."""
pass
@vault.command()
def secrets():
"""List secrets."""
click.echo("Listing secrets...")
# Usage:
# netapi ise endpoints
# netapi ise lookup 00:11:22:33:44:55
# netapi vault secrets
Registering Commands from Modules
# cli/main.py
import click
from cli import ise, vault
@click.group()
@click.version_option(prog_name="netapi")
def cli():
"""netapi - Infrastructure automation CLI."""
pass
cli.add_command(ise.ise)
cli.add_command(vault.vault)
# cli/ise.py
import click
@click.group()
def ise():
"""ISE operations."""
pass
@ise.command()
def endpoints():
click.echo("Listing endpoints...")
Context
Passing Context
import click
@click.group()
@click.option("--debug/--no-debug", default=False)
@click.option("--format", "-f", type=click.Choice(["json", "table"]), default="table")
@click.pass_context
def cli(ctx, debug: bool, format: str):
"""CLI with shared context."""
ctx.ensure_object(dict)
ctx.obj["debug"] = debug
ctx.obj["format"] = format
@cli.command()
@click.pass_context
def status(ctx):
"""Show status."""
if ctx.obj["debug"]:
click.echo("Debug mode enabled")
click.echo(f"Output format: {ctx.obj['format']}")
# Usage:
# netapi --debug --format json status
Context Object Pattern
from dataclasses import dataclass
import click
@dataclass
class AppContext:
debug: bool = False
format: str = "table"
config_path: str = "~/.config/netapi/config.yaml"
pass_context = click.make_pass_decorator(AppContext, ensure=True)
@click.group()
@click.option("--debug/--no-debug", default=False)
@click.option("--format", "-f", type=click.Choice(["json", "table"]), default="table")
@pass_context
def cli(ctx: AppContext, debug: bool, format: str):
ctx.debug = debug
ctx.format = format
@cli.command()
@pass_context
def status(ctx: AppContext):
if ctx.debug:
click.echo("Debug mode")
click.echo(f"Format: {ctx.format}")
Rich Output
Rich makes CLI output beautiful with colors, tables, and progress bars.
Basic Output
from rich.console import Console
from rich import print as rprint
console = Console()
# Styled text
console.print("Hello", style="bold red")
console.print("[green]Success![/green]")
console.print("[bold blue]Info:[/bold blue] Processing...")
# Shorthand
rprint("[bold magenta]Hello World[/bold magenta]")
Tables
from rich.console import Console
from rich.table import Table
console = Console()
def print_endpoints(endpoints: list[dict]):
table = Table(title="Endpoints")
table.add_column("MAC", style="cyan")
table.add_column("IP", style="green")
table.add_column("VLAN", justify="right")
table.add_column("Status")
for ep in endpoints:
status_style = "green" if ep["status"] == "active" else "red"
table.add_row(
ep["mac"],
ep["ip"],
str(ep["vlan"]),
f"[{status_style}]{ep['status']}[/{status_style}]"
)
console.print(table)
# Usage
endpoints = [
{"mac": "00:11:22:33:44:55", "ip": "10.50.10.100", "vlan": 10, "status": "active"},
{"mac": "00:11:22:33:44:56", "ip": "10.50.10.101", "vlan": 10, "status": "inactive"},
]
print_endpoints(endpoints)
Progress Bars
from rich.progress import Progress, SpinnerColumn, TextColumn
import time
# Simple progress
with Progress() as progress:
task = progress.add_task("Processing...", total=100)
for i in range(100):
time.sleep(0.05)
progress.update(task, advance=1)
# Multiple tasks
with Progress() as progress:
task1 = progress.add_task("[red]Downloading...", total=1000)
task2 = progress.add_task("[green]Processing...", total=1000)
while not progress.finished:
progress.update(task1, advance=5)
progress.update(task2, advance=3)
time.sleep(0.02)
# Spinner for indeterminate progress
from rich.console import Console
console = Console()
with console.status("[bold green]Connecting to ISE...") as status:
time.sleep(2) # Simulate work
status.update("[bold blue]Fetching endpoints...")
time.sleep(2)
JSON Output
import json
from rich.console import Console
from rich.json import JSON
console = Console()
data = {
"hostname": "ise-01",
"endpoints": [
{"mac": "00:11:22:33:44:55", "status": "active"},
{"mac": "00:11:22:33:44:56", "status": "inactive"}
]
}
# Pretty JSON with syntax highlighting
console.print(JSON(json.dumps(data)))
# Or with print_json
console.print_json(data=data)
Panels and Markdown
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
# Panel
console.print(Panel("Important message", title="Warning", border_style="red"))
# Markdown
md = """
# ISE Status
- **PAN**: ise-01 (running)
- **PSN**: ise-02 (running)
- **MNT**: ise-03 (stopped)
## Actions Required
1. Restart MNT node
2. Check replication status
"""
console.print(Markdown(md))
Error Handling
Click Exceptions
import click
@click.command()
@click.argument("mac")
def lookup(mac: str):
try:
endpoint = api.get_endpoint(mac)
if not endpoint:
raise click.ClickException(f"Endpoint not found: {mac}")
click.echo(endpoint)
except ConnectionError as e:
raise click.ClickException(f"Connection failed: {e}")
# Abort with message
@click.command()
def dangerous():
if not click.confirm("This will delete all data. Continue?"):
raise click.Abort()
Exit Codes
import sys
import click
@click.command()
def check():
"""Check system status."""
try:
result = run_checks()
if result.all_passed:
click.echo("All checks passed")
sys.exit(0)
else:
click.echo(f"Failed: {result.failures}")
sys.exit(1)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(2)
Practical CLI Pattern
Full Example
# cli/main.py
import click
from rich.console import Console
from dataclasses import dataclass
from enum import Enum
class OutputFormat(str, Enum):
TABLE = "table"
JSON = "json"
CSV = "csv"
@dataclass
class AppContext:
console: Console
format: OutputFormat
debug: bool
pass_ctx = click.make_pass_decorator(AppContext, ensure=True)
@click.group()
@click.option("--format", "-f",
type=click.Choice([f.value for f in OutputFormat]),
default="table",
help="Output format")
@click.option("--debug/--no-debug", default=False, help="Enable debug output")
@click.version_option(prog_name="netapi")
@pass_ctx
def cli(ctx: AppContext, format: str, debug: bool):
"""netapi - Infrastructure automation CLI."""
ctx.console = Console()
ctx.format = OutputFormat(format)
ctx.debug = debug
@cli.group()
def ise():
"""ISE operations."""
pass
@ise.command()
@click.option("--host", "-h", required=True, envvar="ISE_HOST")
@click.option("--limit", "-l", type=int, default=100)
@pass_ctx
def endpoints(ctx: AppContext, host: str, limit: int):
"""List ISE endpoints."""
from rich.table import Table
import json
if ctx.debug:
ctx.console.print(f"[dim]Connecting to {host}...[/dim]")
# Simulated data
data = [
{"mac": "00:11:22:33:44:55", "ip": "10.50.10.100", "group": "Employees"},
{"mac": "00:11:22:33:44:56", "ip": "10.50.10.101", "group": "Guests"},
]
match ctx.format:
case OutputFormat.JSON:
ctx.console.print_json(data=data)
case OutputFormat.CSV:
click.echo("mac,ip,group")
for ep in data:
click.echo(f"{ep['mac']},{ep['ip']},{ep['group']}")
case OutputFormat.TABLE:
table = Table(title="Endpoints")
table.add_column("MAC", style="cyan")
table.add_column("IP", style="green")
table.add_column("Group")
for ep in data:
table.add_row(ep["mac"], ep["ip"], ep["group"])
ctx.console.print(table)
@ise.command()
@click.argument("mac")
@pass_ctx
def lookup(ctx: AppContext, mac: str):
"""Look up endpoint by MAC."""
with ctx.console.status(f"Looking up {mac}..."):
# API call here
pass
if __name__ == "__main__":
cli()
pyproject.toml Entry Point
[project.scripts]
netapi = "netapi.cli.main:cli"
Testing CLI
from click.testing import CliRunner
from netapi.cli.main import cli
def test_version():
runner = CliRunner()
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "netapi" in result.output
def test_endpoints_json():
runner = CliRunner()
result = runner.invoke(cli, ["--format", "json", "ise", "endpoints", "--host", "ise-01"])
assert result.exit_code == 0
import json
data = json.loads(result.output)
assert isinstance(data, list)
def test_missing_required():
runner = CliRunner()
result = runner.invoke(cli, ["ise", "endpoints"])
assert result.exit_code != 0
assert "Missing option" in result.output
Next Module
HTTP & APIs - requests, httpx, REST patterns, authentication.