Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Python CLI Applications

Modern Python command-line interface development using Click and Rich for building beautiful, functional terminal applications.

Python CLI Applications

Modern Python command-line interface development using Click and Rich for building beautiful, functional terminal applications.

Overview

Click is a composable command-line interface toolkit that makes creating CLI tools simple through decorators and automatic help generation. Rich is a Python library for rendering sophisticated formatting and styling in the terminal, including colours, tables, progress bars, syntax highlighting, and more. Together, they enable creation of professional CLI applications with minimal boilerplate.

CLI InvocationClick ParserCommandsOptions/FlagsArgumentsCommand HandlerRich ConsoleStyled OutputTables/PanelsProgress BarsSyntax HighlightingTerminal DisplayCLI InvocationClick ParserCommandsOptions/FlagsArgumentsCommand HandlerRich ConsoleStyled OutputTables/PanelsProgress BarsSyntax HighlightingTerminal Display

Click Basics

Click uses Python decorators to turn functions into CLI commands with automatic help generation, type conversion, and validation.

Key Concepts

  • Commands: Functions decorated with @click.command() or @click.group()
  • Options: Named parameters prefixed with -- or - (e.g., --verbose, -v)
  • Arguments: Positional parameters without prefixes
  • Context: Shared state object passed between commands
  • Decorators: Stack decorators to define CLI structure

Common Patterns

import click

# Basic command
@click.command()
@click.option('--count', default=1, help='Number of iterations')
@click.option('--name', prompt='Your name', help='The person to greet')
@click.argument('output', type=click.File('w'), default='-')
def hello(count, name, output):
    """Simple programme that greets NAME COUNT times."""
    for _ in range(count):
        click.echo(f'Hello, {name}!', file=output)

# Multiple options with short flags
@click.command()
@click.option('-v', '--verbose', is_flag=True, help='Enable verbose mode')
@click.option('-q', '--quiet', is_flag=True, help='Suppress output')
@click.option('-o', '--output', type=click.Path(), help='Output file path')
def process(verbose, quiet, output):
    """Process data with various output options."""
    if verbose:
        click.echo('Verbose mode enabled')
    if not quiet:
        click.echo('Processing...')

# Choice options
@click.command()
@click.option('--format', type=click.Choice(['json', 'yaml', 'xml']),
              default='json', help='Output format')
@click.option('--level', type=click.IntRange(0, 10), default=5)
def export(format, level):
    """Export data in specified format."""
    click.echo(f'Exporting as {format} with level {level}')

# Multiple values
@click.command()
@click.option('--exclude', multiple=True, help='Patterns to exclude')
@click.argument('paths', nargs=-1, required=True, type=click.Path(exists=True))
def search(exclude, paths):
    """Search in multiple paths with exclusions."""
    click.echo(f'Searching in: {paths}')
    click.echo(f'Excluding: {exclude}')

# Password prompts
@click.command()
@click.option('--username', prompt=True)
@click.option('--password', prompt=True, hide_input=True,
              confirmation_prompt=True)
def login(username, password):
    """Login with credentials."""
    click.echo(f'Logging in as {username}')

Examples

Command groups for subcommands:

@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
    """Main CLI entry point."""
    ctx.ensure_object(dict)
    ctx.obj['DEBUG'] = debug

@cli.command()
@click.pass_context
def init(ctx):
    """Initialise the application."""
    if ctx.obj['DEBUG']:
        click.echo('Debug mode enabled')
    click.echo('Initialising...')

@cli.command()
@click.argument('name')
@click.pass_context
def create(ctx, name):
    """Create a new resource."""
    click.echo(f'Creating {name}')

# Usage: cli.py init
#        cli.py create myapp

Nested command groups:

@click.group()
def cli():
    """Application CLI."""
    pass

@cli.group()
def database():
    """Database commands."""
    pass

@database.command()
def migrate():
    """Run database migrations."""
    click.echo('Running migrations...')

@database.command()
def seed():
    """Seed database with initial data."""
    click.echo('Seeding database...')

# Usage: cli.py database migrate
#        cli.py database seed

Click Advanced Features

Click provides powerful features for validation, callbacks, prompts, and custom types.

Key Concepts

  • Callbacks: Functions that transform/validate option values
  • Custom types: Extend Click's type system
  • Confirmation prompts: Safety checks for destructive operations
  • Environment variables: Automatic environment variable support
  • Parameter decorators: Reusable parameter definitions

Common Patterns

import click
import os
from pathlib import Path

# Validation callback
def validate_port(ctx, param, value):
    """Ensure port is in valid range."""
    if value < 1024 or value > 65535:
        raise click.BadParameter('Port must be between 1024 and 65535')
    return value

@click.command()
@click.option('--port', default=8080, callback=validate_port, type=int)
def serve(port):
    """Start server on specified port."""
    click.echo(f'Starting server on port {port}')

# Custom type
class EmailType(click.ParamType):
    name = 'email'

    def convert(self, value, param, ctx):
        if '@' not in value:
            self.fail(f'{value} is not a valid email', param, ctx)
        return value.lower()

EMAIL = EmailType()

@click.command()
@click.option('--email', type=EMAIL, required=True)
def register(email):
    """Register with email address."""
    click.echo(f'Registered: {email}')

# Confirmation prompts
@click.command()
@click.option('--yes', is_flag=True, help='Skip confirmation')
@click.argument('filename')
def delete(yes, filename):
    """Delete a file with confirmation."""
    if not yes:
        click.confirm(f'Delete {filename}?', abort=True)
    click.echo(f'Deleting {filename}')

# Environment variables
@click.command()
@click.option('--api-key', envvar='API_KEY', required=True,
              help='API key (or set API_KEY env var)')
@click.option('--endpoint', envvar='API_ENDPOINT',
              default='https://api.example.com')
def api_call(api_key, endpoint):
    """Make API call using credentials."""
    click.echo(f'Calling {endpoint}')

# Reusable parameter decorator
def common_options(f):
    """Decorator for common CLI options."""
    f = click.option('--verbose', '-v', is_flag=True)(f)
    f = click.option('--dry-run', is_flag=True)(f)
    f = click.option('--config', type=click.Path(exists=True))(f)
    return f

@click.command()
@common_options
def deploy(verbose, dry_run, config):
    """Deploy application."""
    if verbose:
        click.echo('Verbose output enabled')
    if dry_run:
        click.echo('Dry run - no changes made')

Examples

Progress indication with Click:

import click
import time

@click.command()
@click.argument('files', nargs=-1, type=click.Path(exists=True))
def process_files(files):
    """Process multiple files with progress."""
    with click.progressbar(files, label='Processing files') as bar:
        for file in bar:
            # Simulate processing
            time.sleep(0.1)
    click.echo('Complete!')

# Alternative with manual updates
@click.command()
def long_task():
    """Long running task with progress."""
    items = range(100)
    with click.progressbar(items) as bar:
        for item in bar:
            time.sleep(0.05)

Colour and styling:

@click.command()
def status():
    """Show coloured status output."""
    click.echo(click.style('Success', fg='green', bold=True))
    click.echo(click.style('Warning', fg='yellow'))
    click.echo(click.style('Error', fg='red', bold=True))
    click.echo(click.style('Info', fg='blue'))

    # Background colours
    click.echo(click.style('Highlighted', bg='yellow', fg='black'))

# Secho is shorthand for styled echo
@click.command()
def alerts():
    """Show alerts."""
    click.secho('✓ Operation successful', fg='green')
    click.secho('✗ Operation failed', fg='red', bold=True)

Context passing and chaining:

@click.group()
@click.option('--config-file', type=click.Path())
@click.pass_context
def cli(ctx, config_file):
    """CLI with shared configuration."""
    ctx.ensure_object(dict)
    # Load and store config
    ctx.obj['config'] = load_config(config_file) if config_file else {}

@cli.command()
@click.option('--env', default='production')
@click.pass_context
def deploy(ctx, env):
    """Deploy to environment."""
    config = ctx.obj['config']
    click.echo(f'Deploying to {env} with config: {config}')

@cli.command()
@click.pass_context
def status(ctx):
    """Check status."""
    config = ctx.obj['config']
    click.echo(f'Status check with config: {config}')

Rich Console Output

Rich provides beautiful, formatted terminal output with minimal effort, supporting colours, styles, tables, and more.

Key Concepts

  • Console: Main output interface with formatting capabilities
  • Styles: Colour and text formatting (bold, italic, underline)
  • Print inspection: Automatic pretty-printing of Python objects
  • Markup: BBCode-style inline formatting
  • Live display: Dynamically updating output

Common Patterns

from rich.console import Console
from rich.style import Style
from rich.theme import Theme

# Basic console
console = Console()

# Simple output
console.print("Hello, [bold magenta]World[/bold magenta]!")
console.print("[red]Error:[/red] Something went wrong")
console.print("[green]✓[/green] Success")

# Styles
console.print("Bold", style="bold")
console.print("Red on white", style="red on white")
console.print("Italic cyan", style="italic cyan")

# Custom styles
error_style = Style(color="red", bold=True)
console.print("Critical error", style=error_style)

# Custom theme
custom_theme = Theme({
    "info": "cyan",
    "warning": "yellow",
    "error": "bold red",
    "success": "bold green"
})
console = Console(theme=custom_theme)
console.print("This is informational", style="info")
console.print("This is a warning", style="warning")

# Pretty printing Python objects
data = {
    'name': 'Alice',
    'age': 30,
    'skills': ['Python', 'Docker', 'Kubernetes'],
    'active': True
}
console.print(data)

# JSON highlighting
import json
json_data = json.dumps(data, indent=2)
console.print_json(json_data)

# Rule/divider
console.rule("[bold blue]Section Title")
console.rule()  # Simple line

# Padding and alignment
console.print("Centered text", justify="center")
console.print("Right-aligned", justify="right")

Examples

Logging with Rich:

import logging
from rich.logging import RichHandler

# Configure logging with Rich
logging.basicConfig(
    level=logging.INFO,
    format="%(message)s",
    handlers=[RichHandler(rich_tracebacks=True)]
)

log = logging.getLogger("rich")
log.info("Starting application")
log.warning("This is a warning")
log.error("An error occurred")

# With custom formatting
handler = RichHandler(
    show_time=True,
    show_path=True,
    markup=True,
    rich_tracebacks=True,
    tracebacks_show_locals=True
)

Status indicators and spinners:

from rich.console import Console
import time

console = Console()

# Status spinner
with console.status("[bold green]Processing...") as status:
    time.sleep(2)
    status.update("[bold blue]Still processing...")
    time.sleep(2)

console.print("[green]✓ Complete!")

# Different spinner styles
with console.status("Loading...", spinner="dots"):
    time.sleep(3)

Pretty exceptions:

from rich.console import Console
from rich.traceback import install

# Install Rich traceback handler
install(show_locals=True)

console = Console()

# Now all exceptions are beautiful
def buggy_function():
    x = 1
    y = 0
    return x / y

try:
    buggy_function()
except Exception:
    console.print_exception(show_locals=True)

Rich Tables

Rich tables provide flexible, styled tabular data display with automatic sizing and alignment.

Key Concepts

  • Table: Main table object with configurable appearance
  • Columns: Define headers, styles, and alignment
  • Rows: Data rows added incrementally
  • Styling: Per-cell, per-row, or per-column styling
  • Box styles: Built-in border styles

Common Patterns

from rich.console import Console
from rich.table import Table
from rich import box

console = Console()

# Basic table
table = Table(title="Users")
table.add_column("ID", style="cyan", justify="right")
table.add_column("Name", style="magenta")
table.add_column("Email", style="green")

table.add_row("1", "Alice", "alice@example.com")
table.add_row("2", "Bob", "bob@example.com")
table.add_row("3", "Charlie", "charlie@example.com")

console.print(table)

# Table with different box style
table = Table(title="Server Status", box=box.ROUNDED)
table.add_column("Server", style="cyan")
table.add_column("Status", style="green")
table.add_column("Uptime", justify="right")

table.add_row("web-01", "✓ Running", "99.9%")
table.add_row("web-02", "✓ Running", "99.8%")
table.add_row("db-01", "✗ Down", "0.0%", style="red")

console.print(table)

# Auto-sizing and overflow
table = Table(show_header=True, header_style="bold blue",
              show_lines=True, expand=True)
table.add_column("ID", width=10)
table.add_column("Description", overflow="fold")
table.add_column("Status")

# Grid table (no borders)
from rich.table import Table
grid = Table.grid(padding=(0, 2))
grid.add_column(style="cyan")
grid.add_column(style="magenta")
grid.add_row("Name:", "Alice")
grid.add_row("Age:", "30")
grid.add_row("Location:", "London")
console.print(grid)

Examples

Dynamic table from data:

from rich.console import Console
from rich.table import Table

def display_data(data):
    """Display list of dicts as table."""
    console = Console()

    if not data:
        console.print("[yellow]No data to display[/yellow]")
        return

    table = Table(show_header=True, header_style="bold cyan")

    # Add columns from first item
    for key in data[0].keys():
        table.add_column(key.title())

    # Add rows
    for item in data:
        table.add_row(*[str(v) for v in item.values()])

    console.print(table)

# Usage
users = [
    {'id': 1, 'name': 'Alice', 'role': 'Admin'},
    {'id': 2, 'name': 'Bob', 'role': 'User'},
    {'id': 3, 'name': 'Charlie', 'role': 'User'}
]
display_data(users)

Conditional row styling:

from rich.console import Console
from rich.table import Table

console = Console()
table = Table(title="Process Status")

table.add_column("PID", style="cyan")
table.add_column("Name", style="white")
table.add_column("CPU %", justify="right")
table.add_column("Status")

processes = [
    (1234, "python", 45.2, "high"),
    (5678, "docker", 12.1, "normal"),
    (9012, "node", 78.9, "critical")
]

for pid, name, cpu, status in processes:
    if status == "critical":
        style = "bold red"
    elif status == "high":
        style = "yellow"
    else:
        style = "green"

    table.add_row(str(pid), name, f"{cpu:.1f}", status, style=style)

console.print(table)

Rich Progress Bars

Rich provides sophisticated progress tracking with multiple tasks, columns, and real-time updates.

Key Concepts

  • Progress: Main progress tracker supporting multiple tasks
  • Task: Individual progress item with percentage completion
  • Columns: Customisable progress display elements
  • Context manager: Automatic start/stop handling
  • Live updates: Real-time display updates

Common Patterns

from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
from rich.console import Console
import time

# Basic progress bar
with Progress() as progress:
    task = progress.add_task("[cyan]Processing...", total=100)

    while not progress.finished:
        progress.update(task, advance=1)
        time.sleep(0.05)

# Multiple tasks
with Progress() as progress:
    download_task = progress.add_task("[red]Downloading...", total=1000)
    process_task = progress.add_task("[green]Processing...", total=500)

    while not progress.finished:
        progress.update(download_task, advance=5)
        progress.update(process_task, advance=2)
        time.sleep(0.02)

# Custom columns
progress = Progress(
    SpinnerColumn(),
    TextColumn("[progress.description]{task.description}"),
    BarColumn(),
    TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
    TextColumn("•"),
    TextColumn("[blue]{task.completed}/{task.total}"),
)

with progress:
    task = progress.add_task("Processing files", total=50)
    for i in range(50):
        time.sleep(0.1)
        progress.update(task, advance=1)

# Indeterminate progress (unknown total)
with Progress() as progress:
    task = progress.add_task("[cyan]Working...", total=None)
    # Task runs indefinitely until manually stopped
    for _ in range(100):
        progress.update(task, advance=1)
        time.sleep(0.05)

Examples

File processing with progress:

from rich.progress import Progress, BarColumn, TimeRemainingColumn
from pathlib import Path
import time

def process_files(directory):
    """Process all files in directory with progress."""
    files = list(Path(directory).glob("*.txt"))

    with Progress(
        "[progress.description]{task.description}",
        BarColumn(),
        "[progress.percentage]{task.percentage:>3.0f}%",
        TimeRemainingColumn(),
    ) as progress:
        task = progress.add_task("Processing files", total=len(files))

        for file in files:
            # Process file
            process_file(file)
            progress.update(task, advance=1)

def process_file(filepath):
    """Simulate file processing."""
    time.sleep(0.1)

Nested progress (downloads with extraction):

from rich.progress import Progress
import time

with Progress() as progress:
    # Overall task
    overall = progress.add_task("[green]Overall", total=100)

    # Download task
    download = progress.add_task("[cyan]Download", total=100)
    for i in range(100):
        progress.update(download, advance=1)
        progress.update(overall, advance=0.5)
        time.sleep(0.02)

    # Extract task
    extract = progress.add_task("[yellow]Extract", total=100)
    for i in range(100):
        progress.update(extract, advance=1)
        progress.update(overall, advance=0.5)
        time.sleep(0.02)

Progress with console output:

from rich.progress import Progress
from rich.console import Console
import time

console = Console()

with Progress() as progress:
    task = progress.add_task("[cyan]Processing items...", total=10)

    for i in range(10):
        # You can still print during progress
        console.log(f"Processing item {i+1}")
        time.sleep(0.5)
        progress.update(task, advance=1)

Rich Panels and Formatting

Rich panels, markdown rendering, and syntax highlighting provide polished terminal output.

Key Concepts

  • Panels: Bordered boxes for content
  • Markdown: Native markdown rendering
  • Syntax: Code syntax highlighting
  • Columns: Multi-column layout
  • Tree: Hierarchical tree structures

Common Patterns

from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from rich.syntax import Syntax
from rich.columns import Columns
from rich.tree import Tree

console = Console()

# Basic panel
console.print(Panel("Hello, World!"))
console.print(Panel("Error message", title="Error", border_style="red"))

# Panel with content
from rich.align import Align
message = Align.center("Centred message in panel")
console.print(Panel(message, title="Status", border_style="green"))

# Markdown rendering
markdown_text = """
# Heading 1
## Heading 2

This is **bold** and this is *italic*.

- List item 1
- List item 2
- List item 3

```python
def hello():
    print("Hello, World!")

""" console.print(Markdown(markdown_text))

Syntax highlighting

code = ''' def greet(name: str) -> None: """Greet someone.""" print(f"Hello, {name}!")

if name == "main": greet("Alice") '''

syntax = Syntax(code, "python", theme="monokai", line_numbers=True) console.print(Panel(syntax, title="example.py"))

Tree structure

tree = Tree("📁 Project") tree.add("📄 README.md") tree.add("📄 setup.py") src = tree.add("📁 src") src.add("📄 init.py") src.add("📄 main.py") tests = tree.add("📁 tests") tests.add("📄 test_main.py") console.print(tree)

Columns layout

console.print(Columns([ Panel("Panel 1", border_style="green"), Panel("Panel 2", border_style="blue"), Panel("Panel 3", border_style="red") ]))

### Examples

**Configuration display:**

```python
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

def show_config(config):
    """Display configuration in panels."""
    console = Console()

    # Create table for config
    table = Table(show_header=False, box=None, padding=(0, 2))
    table.add_column("Key", style="cyan")
    table.add_column("Value", style="white")

    for key, value in config.items():
        table.add_row(key, str(value))

    # Wrap in panel
    console.print(Panel(table, title="Configuration", border_style="blue"))

show_config({
    'host': 'localhost',
    'port': 8080,
    'debug': True,
    'workers': 4
})

Help text with panels:

from rich.console import Console
from rich.panel import Panel
from rich.columns import Columns

console = Console()

commands = [
    Panel("[cyan]init[/cyan]\nInitialise project", border_style="cyan"),
    Panel("[green]start[/green]\nStart server", border_style="green"),
    Panel("[yellow]stop[/yellow]\nStop server", border_style="yellow"),
    Panel("[red]clean[/red]\nClean up files", border_style="red")
]

console.print(Panel("Available Commands", style="bold white"))
console.print(Columns(commands, equal=True, expand=True))

Code snippet display:

from rich.console import Console
from rich.syntax import Syntax
from rich.panel import Panel

def show_snippet(code, language="python", title="Code"):
    """Display code snippet with syntax highlighting."""
    console = Console()
    syntax = Syntax(code, language, theme="dracula", line_numbers=True)
    console.print(Panel(syntax, title=title, border_style="cyan"))

show_snippet(
    code="SELECT * FROM users WHERE active = true;",
    language="sql",
    title="Query Example"
)

Combining Rich with Click

Integrate Rich's beautiful output with Click's CLI framework for professional command-line tools.

Key Concepts

  • Rich console in Click commands: Use Rich for all output
  • Rich-click: Drop-in replacement for Click with Rich formatting
  • Custom error handling: Rich exceptions in CLI
  • Progress in CLI: Track long-running operations
  • Formatted help: Beautiful help messages

Common Patterns

import click
from rich.console import Console
from rich.table import Table
from rich.progress import Progress
from rich.panel import Panel
import time

console = Console()

@click.group()
def cli():
    """Application CLI with Rich output."""
    pass

@cli.command()
@click.argument('name')
def greet(name):
    """Greet someone with style."""
    console.print(f"[bold green]Hello, {name}![/bold green]")
    console.print(Panel(f"Welcome, {name}!", border_style="blue"))

@cli.command()
def status():
    """Show system status."""
    table = Table(title="System Status")
    table.add_column("Component", style="cyan")
    table.add_column("Status", style="green")
    table.add_column("Uptime")

    table.add_row("Database", "✓ Running", "99.9%")
    table.add_row("API", "✓ Running", "99.8%")
    table.add_row("Cache", "✓ Running", "100%")

    console.print(table)

@cli.command()
@click.option('--count', default=10, help='Number of items')
def process(count):
    """Process items with progress bar."""
    with Progress() as progress:
        task = progress.add_task("[cyan]Processing...", total=count)

        for i in range(count):
            time.sleep(0.1)
            progress.update(task, advance=1)

    console.print("[green]✓ Complete![/green]")

@cli.command()
def error_demo():
    """Demonstrate error handling."""
    try:
        # Simulate error
        raise ValueError("Something went wrong")
    except Exception:
        console.print_exception(show_locals=True)

if __name__ == '__main__':
    cli()

Examples

Using rich-click for automatic formatting:

import rich_click as click
from rich.console import Console

# Configure rich-click
click.rich_click.USE_RICH_MARKUP = True
click.rich_click.SHOW_ARGUMENTS = True
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True

@click.group()
def cli():
    """
    [bold cyan]My Application[/bold cyan]

    A beautiful CLI application with Rich formatting.
    """
    pass

@cli.command()
@click.option('--name', help='Your name', required=True)
@click.option('--count', default=1, help='Number of greetings')
def hello(name, count):
    """
    Greet someone multiple times.

    This command demonstrates Rich-formatted help text.
    """
    console = Console()
    for _ in range(count):
        console.print(f"[green]Hello, {name}![/green]")

Complex CLI with Rich tables and panels:

import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box

console = Console()

@click.group()
@click.option('--format', type=click.Choice(['table', 'json']), default='table')
@click.pass_context
def cli(ctx, format):
    """Data management CLI."""
    ctx.ensure_object(dict)
    ctx.obj['format'] = format

@cli.command()
@click.pass_context
def list_users(ctx):
    """List all users."""
    users = [
        {'id': 1, 'name': 'Alice', 'role': 'Admin', 'active': True},
        {'id': 2, 'name': 'Bob', 'role': 'User', 'active': True},
        {'id': 3, 'name': 'Charlie', 'role': 'User', 'active': False}
    ]

    if ctx.obj['format'] == 'json':
        import json
        console.print_json(json.dumps(users))
    else:
        table = Table(title="Users", box=box.ROUNDED)
        table.add_column("ID", style="cyan", justify="right")
        table.add_column("Name", style="magenta")
        table.add_column("Role", style="blue")
        table.add_column("Status")

        for user in users:
            status = "[green]✓ Active[/green]" if user['active'] else "[red]✗ Inactive[/red]"
            table.add_row(
                str(user['id']),
                user['name'],
                user['role'],
                status
            )

        console.print(table)

@cli.command()
@click.argument('name')
@click.option('--role', type=click.Choice(['Admin', 'User']), default='User')
def create_user(name, role):
    """Create a new user."""
    console.print(Panel(
        f"Creating user: [cyan]{name}[/cyan]\nRole: [blue]{role}[/blue]",
        title="User Creation",
        border_style="green"
    ))
    console.print("[green]✓ User created successfully![/green]")

if __name__ == '__main__':
    cli()

CLI with live updates:

import click
from rich.live import Live
from rich.table import Table
import time
import random

@click.command()
@click.option('--duration', default=10, help='Duration in seconds')
def monitor(duration):
    """Monitor system metrics in real-time."""

    def generate_table():
        """Generate metrics table."""
        table = Table(title="System Metrics")
        table.add_column("Metric", style="cyan")
        table.add_column("Value", justify="right", style="green")

        table.add_row("CPU", f"{random.randint(0, 100)}%")
        table.add_row("Memory", f"{random.randint(0, 100)}%")
        table.add_row("Disk", f"{random.randint(0, 100)}%")

        return table

    with Live(generate_table(), refresh_per_second=4) as live:
        for _ in range(duration * 4):
            time.sleep(0.25)
            live.update(generate_table())

if __name__ == '__main__':
    monitor()

argparse Alternative

argparse is Python's standard library option for CLI parsing—lightweight but less ergonomic than Click.

Key Concepts

  • ArgumentParser: Main parser object
  • add_argument(): Define positional and optional arguments
  • parse_args(): Parse command-line arguments
  • Subparsers: Support for subcommands
  • Manual help formatting: Requires more code than Click

Common Patterns

import argparse

# Basic parser
parser = argparse.ArgumentParser(
    description='Process some data',
    epilog='Example: python script.py input.txt --output result.txt'
)

# Positional argument
parser.add_argument('input', help='Input file path')

# Optional arguments
parser.add_argument('--output', '-o', help='Output file path', default='output.txt')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
parser.add_argument('--count', type=int, default=1, help='Number of iterations')
parser.add_argument('--format', choices=['json', 'yaml', 'xml'], default='json')

# Parse arguments
args = parser.parse_args()

# Use arguments
if args.verbose:
    print(f'Processing {args.input}')
    print(f'Output: {args.output}')
    print(f'Format: {args.format}')

# Subcommands
parser = argparse.ArgumentParser(prog='myapp')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# Create subcommand
init_parser = subparsers.add_parser('init', help='Initialise project')
init_parser.add_argument('--name', required=True, help='Project name')

# Deploy subcommand
deploy_parser = subparsers.add_parser('deploy', help='Deploy application')
deploy_parser.add_argument('--env', choices=['dev', 'staging', 'prod'], required=True)

args = parser.parse_args()

if args.command == 'init':
    print(f'Initialising {args.name}')
elif args.command == 'deploy':
    print(f'Deploying to {args.env}')

Examples

argparse with type validation:

import argparse
from pathlib import Path

def valid_port(value):
    """Validate port number."""
    ivalue = int(value)
    if ivalue < 1024 or ivalue > 65535:
        raise argparse.ArgumentTypeError(f'{value} is not a valid port')
    return ivalue

def existing_file(value):
    """Validate file exists."""
    path = Path(value)
    if not path.exists():
        raise argparse.ArgumentTypeError(f'{value} does not exist')
    return path

parser = argparse.ArgumentParser()
parser.add_argument('--port', type=valid_port, default=8080)
parser.add_argument('--config', type=existing_file)

args = parser.parse_args()

CLI Best Practices

Design principles and patterns for building robust, user-friendly command-line applications.

Key Concepts

  • UNIX philosophy: Do one thing well, composable tools
  • Exit codes: Return meaningful exit codes (0 for success, non-zero for errors)
  • Configuration hierarchy: Command-line > environment > config file > defaults
  • Input/output: Support stdin/stdout for pipelines
  • Colour detection: Respect NO_COLOR, check TTY

Common Patterns

import click
from rich.console import Console
import sys
import os

# Configuration hierarchy
@click.command()
@click.option('--config', type=click.Path(exists=True), envvar='APP_CONFIG')
@click.option('--debug', is_flag=True, envvar='APP_DEBUG')
def app(config, debug):
    """Application with config hierarchy."""
    # Priority: CLI > ENV > config file > defaults
    pass

# Proper exit codes
@click.command()
def deploy():
    """Deploy with proper exit handling."""
    try:
        # Deployment logic
        if not validate_deployment():
            click.echo("Validation failed", err=True)
            sys.exit(1)

        perform_deployment()
        click.echo("Deployment successful")
        sys.exit(0)

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(2)

# Stdin/stdout support
@click.command()
@click.argument('input', type=click.File('r'), default='-')
@click.argument('output', type=click.File('w'), default='-')
def transform(input, output):
    """Transform input to output (supports pipes)."""
    for line in input:
        processed = line.upper()
        output.write(processed)

# Usage: echo "hello" | python app.py transform | grep HELLO

# Colour detection
console = Console()

def supports_colour():
    """Check if terminal supports colour."""
    if os.getenv('NO_COLOR'):
        return False
    if not sys.stdout.isatty():
        return False
    return True

if not supports_colour():
    console = Console(force_terminal=False, no_color=True)

# Verbose/quiet modes
@click.command()
@click.option('--verbose', '-v', count=True, help='Increase verbosity')
@click.option('--quiet', '-q', is_flag=True, help='Suppress output')
def process(verbose, quiet):
    """Process with configurable output."""
    if verbose >= 2:
        click.echo("DEBUG: Detailed information")
    elif verbose >= 1:
        click.echo("INFO: Standard information")

    if not quiet:
        click.echo("Processing...")

# Dry-run mode
@click.command()
@click.option('--dry-run', is_flag=True, help='Show what would be done')
def cleanup(dry_run):
    """Clean up files."""
    files_to_delete = ['temp.txt', 'cache.db']

    for file in files_to_delete:
        if dry_run:
            click.echo(f"Would delete: {file}")
        else:
            click.echo(f"Deleting: {file}")
            os.remove(file)

Examples

Structured logging in CLI:

import click
import logging
from rich.logging import RichHandler

@click.group()
@click.option('--log-level', default='INFO',
              type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR']))
@click.pass_context
def cli(ctx, log_level):
    """CLI with structured logging."""
    ctx.ensure_object(dict)

    # Configure logging
    logging.basicConfig(
        level=log_level,
        format="%(message)s",
        handlers=[RichHandler()]
    )
    ctx.obj['logger'] = logging.getLogger('app')

@cli.command()
@click.pass_context
def process(ctx):
    """Process data with logging."""
    logger = ctx.obj['logger']
    logger.debug("Starting process")
    logger.info("Processing data")
    logger.warning("Resource usage high")

Configuration file support:

import click
import json
from pathlib import Path

DEFAULT_CONFIG = {
    'host': 'localhost',
    'port': 8080,
    'debug': False
}

def load_config(config_path):
    """Load configuration from file."""
    if config_path and Path(config_path).exists():
        with open(config_path) as f:
            return {**DEFAULT_CONFIG, **json.load(f)}
    return DEFAULT_CONFIG

@click.command()
@click.option('--config', type=click.Path(),
              envvar='APP_CONFIG',
              default='~/.config/myapp/config.json')
@click.option('--host', envvar='APP_HOST', help='Override host')
@click.option('--port', type=int, envvar='APP_PORT', help='Override port')
def serve(config, host, port):
    """Start server with configuration."""
    cfg = load_config(config)

    # CLI options override config file
    if host:
        cfg['host'] = host
    if port:
        cfg['port'] = port

    click.echo(f"Starting server on {cfg['host']}:{cfg['port']}")

Error handling patterns:

import click
from rich.console import Console
import sys

console = Console()

class AppError(Exception):
    """Base application error."""
    pass

class ValidationError(AppError):
    """Validation failed."""
    pass

@click.command()
def deploy():
    """Deploy with error handling."""
    try:
        validate_environment()
        perform_deployment()
        console.print("[green]✓ Deployment successful[/green]")

    except ValidationError as e:
        console.print(f"[yellow]Validation Error:[/yellow] {e}")
        sys.exit(1)

    except AppError as e:
        console.print(f"[red]Error:[/red] {e}")
        sys.exit(2)

    except KeyboardInterrupt:
        console.print("\n[yellow]Interrupted by user[/yellow]")
        sys.exit(130)

    except Exception as e:
        console.print("[red]Unexpected error:[/red]")
        console.print_exception(show_locals=True)
        sys.exit(3)

Quick Reference

Click Command Decorators

Decorator Purpose
@click.command() Define a command
@click.group() Define a command group
@click.option('--name') Add named option/flag
@click.argument('name') Add positional argument
@click.pass_context Pass Click context object
@click.pass_obj Pass context object data

Click Option Types

Type Example
type=int Integer value
type=float Float value
type=click.Path() File/directory path
type=click.File('r') Open file handle
type=click.Choice([...]) Multiple choice
type=click.IntRange(0, 10) Integer range
is_flag=True Boolean flag
multiple=True Accept multiple values

Rich Console Methods

Method Purpose
console.print() Print with formatting
console.print_json() Print JSON with highlighting
console.log() Print with timestamp
console.rule() Print horizontal rule
console.clear() Clear screen
console.status() Show status spinner

Rich Styling

Style Example
Colour [red]text[/red], [blue]text[/blue]
Bold [bold]text[/bold]
Italic [italic]text[/italic]
Underline [underline]text[/underline]
Combined [bold red on white]text[/bold red on white]

Common Imports

# Click imports
import click
from click import (
    command, group, option, argument,
    pass_context, pass_obj, echo, secho,
    Path, File, Choice, IntRange
)

# Rich imports
from rich.console import Console
from rich.table import Table
from rich.progress import Progress, track
from rich.panel import Panel
from rich.syntax import Syntax
from rich.markdown import Markdown
from rich.tree import Tree
from rich import box

# Rich-click (drop-in Click replacement)
import rich_click as click

Exit Codes Convention

Code Meaning
0 Success
1 General error
2 Misuse of command
126 Command cannot execute
127 Command not found
130 Terminated by Ctrl+C

Common Issues and Solutions

Issue Solution
Click options not working Check decorator order—options should be closest to function definition
Rich not displaying colours Check NO_COLOR env var; verify terminal supports colour; use Console(force_terminal=True)
Progress bar not updating Ensure you're calling progress.update(task, advance=n) in loop
Context not passing between commands Use @click.pass_context and ctx.ensure_object(dict) in parent command
Multiple values not working Use multiple=True in option/argument definition
Table columns not aligning Specify column widths explicitly or use Table(expand=True)
Unicode characters not rendering Ensure terminal encoding is UTF-8; check locale settings
Stdin/stdout not working with Rich Use Console(file=sys.stderr) for progress/status whilst writing to stdout
Tests failing with Rich output Use Console(file=io.StringIO()) to capture output in tests
Click command not found in group Verify command is added with @group.command() not @click.command()

Debugging Tips

# Debug Click context
@click.command()
@click.pass_context
def debug_ctx(ctx):
    """Debug Click context."""
    click.echo(f"Context: {ctx.obj}")
    click.echo(f"Parent: {ctx.parent}")
    click.echo(f"Params: {ctx.params}")

# Test Rich output
from rich.console import Console
import io

output = io.StringIO()
console = Console(file=output)
console.print("[red]Test[/red]")
result = output.getvalue()
assert "Test" in result

# Verify colour support
from rich.console import Console
console = Console()
print(f"Colour system: {console.color_system}")
print(f"Is terminal: {console.is_terminal}")
print(f"Legacy Windows: {console.legacy_windows}")

# Profile Rich rendering
import cProfile
from rich.console import Console

console = Console()
cProfile.run('console.print(large_table)')

Related Topics

The following topics complement Python CLI application development:

  1. Python - pytest: Testing CLI applications with fixtures and mocks
  2. Python - Type Hints and Static Analysis: Type safety for CLI code
  3. Python - Packaging and Dependency Management: Distributing CLI tools via PyPI
  4. Python - asyncio: Async/await patterns for I/O-heavy CLI operations
  5. Python - Logging: Structured logging for production CLI tools
  6. Bash: Shell scripting for CLI tool automation and integration