Building Professional CLI Tools with Python Click and Rich: From Simple Script to Polished Command-Line App

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Three Ways People Build CLI Tools in Python (And Why Most Pick Wrong)

When you first need to turn a Python script into a proper command-line tool, three paths come up repeatedly: sys.argv parsing by hand, the standard library’s argparse, or third-party libraries like Click. I’ve used all three across dozens of internal tools. The choice has a bigger impact than it looks.

The fourth ingredient — making your CLI look good — usually gets skipped entirely. That’s where Rich comes in. Together, Click and Rich handle both behavior and presentation. The gap between a tool your teammates reach for every day and one they quietly replace with a shell alias often comes down to how clearly it reports progress and errors.

Approach Comparison: sys.argv vs argparse vs Click

Raw sys.argv

This is the “just ship it” approach. You grab sys.argv[1:], do some manual parsing, and call it done.

import sys

def main():
    if len(sys.argv) < 2:
        print("Usage: tool.py <filename>")
        sys.exit(1)
    filename = sys.argv[1]
    process(filename)

Fine for a single positional argument on a throwaway script. Add optional flags, multiple subcommands, or type validation, and it turns painful fast.

argparse (stdlib)

argparse has been the default answer for years. It handles flags, positional args, help text generation, and basic type coercion — no extra install required.

import argparse

parser = argparse.ArgumentParser(description="Process files")
parser.add_argument("filename", help="Input file")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()

For small tools, argparse does the job. But once you add multiple subcommands, nested groups, callbacks, or tests, the boilerplate stacks up and the code gets hard to follow.

Click

Click uses Python decorators to define CLI interfaces directly on your functions. The result reads naturally and stays maintainable as the tool grows.

import click

@click.command()
@click.argument("filename")
@click.option("--verbose", is_flag=True, help="Enable verbose output")
def process(filename, verbose):
    """Process a file and report results."""
    if verbose:
        click.echo(f"Processing: {filename}")

Same functionality, but the intent is immediately clear. Each parameter is documented where it’s declared. Testing is also cleaner — Click ships a built-in test runner called CliRunner.

Pros and Cons Worth Knowing Before You Commit

argparse Pros & Cons

  • Pro: Zero dependencies — ships with Python
  • Pro: Stable, well-documented, widely understood
  • Con: Subcommands require verbose setup with subparsers
  • Con: Testing means mocking sys.argv or spinning up a subprocess
  • Con: No built-in color or styled output

Click Pros & Cons

  • Pro: Decorator-based — the interface lives next to the logic
  • Pro: Subcommands are first-class via @click.group()
  • Pro: Built-in test utilities (CliRunner)
  • Pro: Composable — commands can be assembled from separate modules
  • Con: External dependency (though tiny and stable)
  • Con: Some argument-parsing edge cases differ from argparse conventions

Rich Pros & Cons

  • Pro: Tables, progress bars, syntax highlighting, panels — all built in
  • Pro: Automatic terminal detection (no color codes bleed into piped output)
  • Pro: Pairs cleanly with Click — no conflicts
  • Con: Adds ~2MB to your dependency footprint
  • Con: Overkill for a single-purpose script you’ll run once and forget

My recommendation: use argparse for small internal scripts that one person runs occasionally. Use Click + Rich for anything shared with a team or packaged for distribution. Tools like httpie, black, and poetry are all built on Click — not coincidentally, they’re also tools people genuinely enjoy using. Clear progress output and readable error messages are what separate a tool people trust from one they work around.

Recommended Setup

Install Dependencies

pip install click rich

Project Structure

Keep the CLI layer thin. Business logic belongs in modules, not inside Click decorators.

mytool/
├── cli.py          ← Click commands
├── core.py         ← Actual logic
├── __init__.py
pyproject.toml       ← Entry point config

pyproject.toml Entry Point

[project.scripts]
mytool = "mytool.cli:main"

After pip install -e ., users run mytool directly from any directory — no python -m prefix required.

Implementation Guide: Building a Real CLI Tool

Step 1 — Basic Command Group

Start with a group to support multiple subcommands from day one. Retrofitting one later — after commands have shipped — is more painful than it sounds.

import click
from rich.console import Console

console = Console()

@click.group()
@click.version_option(version="1.0.0")
def main():
    """MyTool — file processing utility."""
    pass

@main.command()
@click.argument("filepath", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["json", "csv", "table"]), default="table")
@click.option("--verbose", "-v", is_flag=True)
def analyze(filepath, format, verbose):
    """Analyze a file and display results."""
    if verbose:
        console.print(f"[dim]Reading: {filepath}[/dim]")
    # ... call core logic here

Step 2 — Rich Tables for Structured Output

Plain print() works, but a table makes results instantly scannable. Rich tables support colors, alignment, and row highlighting — zero boilerplate.

from rich.table import Table

def display_results(records):
    table = Table(title="Analysis Results", show_lines=True)
    table.add_column("File", style="cyan", no_wrap=True)
    table.add_column("Size", justify="right")
    table.add_column("Status", justify="center")

    for r in records:
        status_color = "green" if r["ok"] else "red"
        table.add_row(
            r["name"],
            f"{r['size']:,} bytes",
            f"[{status_color}]{r['status']}[/{status_color}]"
        )

    console.print(table)

Step 3 — Progress Bars for Long Operations

Anything that takes more than half a second needs visible feedback. Rich’s progress bar integrates cleanly with loops and handles multiple simultaneous tasks without extra threading work.

from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn

def process_files(files):
    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        TextColumn("{task.completed}/{task.total}"),
    ) as progress:
        task = progress.add_task("Processing files...", total=len(files))
        for f in files:
            process_single(f)
            progress.advance(task)

Step 4 — Consistent Error Handling

Raising ClickException prints a formatted error message and exits with a non-zero status code. Pair it with Rich’s markup so the error stands out visually.

@main.command()
@click.argument("config", type=click.Path())
def run(config):
    """Run with a config file."""
    try:
        settings = load_config(config)
    except FileNotFoundError:
        console.print(f"[bold red]Error:[/bold red] Config file not found: {config}")
        raise click.Abort()
    except ValueError as e:
        raise click.ClickException(str(e))

Step 5 — Testing with CliRunner

Click’s built-in test runner lets you invoke commands programmatically. No subprocess overhead, no mocking sys.argv — just call invoke() and inspect the result. Tests run fast and stay isolated.

from click.testing import CliRunner
from mytool.cli import main

def test_analyze_command():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open("sample.txt", "w") as f:
            f.write("test content")
        result = runner.invoke(main, ["analyze", "sample.txt"])
        assert result.exit_code == 0
        assert "Results" in result.output

Small Patterns That Make a Big Difference

  • Use click.Path(exists=True) instead of a raw string argument — Click validates the path before your code runs and shows a clean error automatically.
  • Add --dry-run to destructive commands as a flag from day one. Users trust tools more when they can preview what will happen before committing.
  • Use console.print() for display, click.echo() for machine-readable output that might be piped. Rich’s Console correctly separates stderr from stdout.
  • Keep --help text short and verb-focused. “Analyze a file” beats “This command is used for analyzing files.” Click shows the docstring directly — write it for users, not documentation generators.

When Not to Use Click

If your script runs once inside a CI pipeline and output always gets piped to another tool, Click’s interactivity adds nothing. A straightforward argparse script with --output json is the right call there. Click earns its place when a human is on the other end of the terminal.

Need a full-screen TUI with keyboard navigation? Look at Textual — built by the same team as Rich, designed specifically for that use case.

Share: