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.