Part of Python for Beginners

Build Command Line Tools with argparse, Click, and Rich - Python CLI Tutorial #37

Sandy LaneSandy Lane

Video: Build Command Line Tools with argparse, Click, and Rich - Python CLI Tutorial #37 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python CLI Tools: argparse, Click, Rich

argparse (stdlib) handles command-line parsing. Click (third-party) is friendlier with decorators and subcommands. Rich adds colors, tables, progress bars. Pick argparse for one-shot scripts, Click for multi-command tools, Rich for any output worth styling.

A good CLI tool is self-documenting (--help shows everything), gives clear error messages, and feels native. Python has three layers of the puzzle.

argparse: the standard library option

import argparse

parser = argparse.ArgumentParser(
  description="Analyze a text file",
  epilog="Example: python script.py sample.txt --lines"
)

parser.add_argument("filename", help="file to analyze")
parser.add_argument("--lines", action="store_true", help="count lines")
parser.add_argument("--top", type=int, default=5, help="top N words")
parser.add_argument("--search", type=str, help="search for a word")

args = parser.parse_args()

# Use the parsed arguments
print(args.filename, args.lines, args.top)

Run it:

python script.py data.txt --lines --top 10
python script.py --help     # auto-generated usage

add_argument:

  • Positional: just a name ("filename").
  • Optional: starts with -- ("--lines").
  • action="store_true" for boolean flags.
  • type=int, type=float for conversion.
  • default= for fallback.
  • required=True for mandatory options.
  • choices=["a", "b"] to restrict values.

argparse: --help is free

$ python script.py --help
usage: script.py [-h] [--lines] [--top TOP] [--search SEARCH] filename

Analyze a text file

positional arguments:
  filename         file to analyze

options:
  -h, --help       show this help message and exit
  --lines          count lines
  --top TOP        top N words (default: 5)
  --search SEARCH  search for a word

Example: python script.py sample.txt --lines

Auto-generated from the add_argument calls. Add help= to each — your future users (including future you) will thank you.

Click: decorator-based CLIs

import click

@click.command()
@click.argument("filename")
@click.option("--lines", is_flag=True, help="count lines")
@click.option("--top", default=5, help="top N words")
@click.option("--search", help="search for a word")
def main(filename, lines, top, search):
  """Analyze a text file."""
  click.echo(f"Analyzing {filename}")
  if lines:
    ...

if __name__ == "__main__":
  main()

pip install click. Define one function per command, decorate it with arguments and options, and Click handles parsing, help, and error messages.

@click.argument for positional, @click.option for ---style. The function parameters automatically receive the parsed values.

Click: subcommands

For tools with multiple actions (like git add, git commit):

@click.group()
def cli():
  """Employee directory tool."""
  pass

@cli.command()
@click.option("--dept", help="Filter by department")
def list(dept):
  """List all employees."""
  ...

@cli.command()
@click.argument("name")
def search(name):
  """Search by name."""
  ...

@cli.command()
@click.argument("name")
@click.option("--format", "fmt", type=click.Choice(["short", "full"]), default="short")
def info(name, fmt):
  """Show employee info."""
  ...

if __name__ == "__main__":
  cli()

Run:

python tool.py list --dept Engineering
python tool.py search Alice
python tool.py info alice --format full

@click.group() defines the parent command; @cli.command() registers subcommands. Each gets its own --help.

Click: validation and types

@click.option("--port", type=click.IntRange(1, 65535))
@click.option("--config", type=click.Path(exists=True))
@click.option("--verbose", count=True)    # -v, -vv, -vvv
@click.option("--name", prompt="Your name")  # prompt if missing
@click.password_option()                   # hide password input

Click has rich validation built in. IntRange, Path(exists=True), Choice, DateTime, etc.

Click: colors and confirmations

click.echo(click.style("Success!", fg="green", bold=True))
click.echo(click.style("Error", fg="red"))

if click.confirm("Delete?"):
  ...

# Progress bar
with click.progressbar(items) as bar:
  for item in bar:
    process(item)

click.echo is like print but smart about Unicode and pipe-friendly. click.style applies ANSI colors that disable themselves when output isn't a TTY (so logs don't get garbage codes).

Rich: pretty terminal output

pip install rich. Rich is a library for beautiful terminal output — colors, tables, progress bars, syntax highlighting.

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

console = Console()
console.print("[bold red]Error[/bold red] something went wrong")
console.print("[green]✓[/green] Done!")

table = Table(title="Employees")
table.add_column("Name", style="cyan")
table.add_column("Department")
table.add_column("Role", justify="right")

table.add_row("Alice", "Engineering", "Senior Dev")
table.add_row("Bob", "Engineering", "Tech Lead")

console.print(table)

Output:

                Employees
┏━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Name   ┃ Department  ┃ Role         ┃
┡━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Alice  │ Engineering │   Senior Dev │
│ Bob    │ Engineering │    Tech Lead │
└────────┴─────────────┴──────────────┘

Better than printing tabs.

Rich: progress bars

from rich.progress import track

for item in track(items, description="Processing..."):
  process(item)

Or for more control:

from rich.progress import Progress

with Progress() as progress:
  task = progress.add_task("Downloading...", total=100)
  for chunk in download():
    progress.update(task, advance=len(chunk))

Rich: tracebacks

from rich.traceback import install
install(show_locals=True)

One line at startup. Now any uncaught exception prints with colors, syntax-highlighted source, and (optionally) local variables. Game-changer for debugging.

Combining Click + Rich

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

console = Console()

@click.command()
@click.argument("filename")
def main(filename):
  table = Table(title=f"Analysis of {filename}")
  table.add_column("Metric")
  table.add_column("Value", justify="right")

  with open(filename) as f:
    content = f.read()
  table.add_row("Lines", str(content.count('\n')))
  table.add_row("Words", str(len(content.split())))
  table.add_row("Chars", str(len(content)))

  console.print(table)

if __name__ == "__main__":
  main()

Click for argument parsing, Rich for pretty output. Each focuses on what it does best.

When to use each

  • argparse — small, single-script tools. Stdlib, no dependencies. Fine for under ~50 lines.
  • Click — medium-to-large tools, multiple subcommands, polished UX. Industry standard for production CLIs.
  • Typer — Click-like but type-hint based. Same author. Fine alternative.
  • Rich — anywhere output matters. Logs, tables, progress, errors.

For new projects with subcommands: Click + Rich is the modern stack.

A complete tool

import click
from rich.console import Console
from rich.table import Table
from rich.traceback import install

install()
console = Console()

@click.group()
def cli():
  """Employee directory."""
  pass

@cli.command()
@click.option("--dept")
def ls(dept):
  """List employees."""
  table = Table(title="Employees")
  table.add_column("Name", style="cyan")
  table.add_column("Dept")
  table.add_column("Email")
  for emp in EMPLOYEES.values():
    if dept and emp["dept"] != dept:
      continue
    table.add_row(emp["name"], emp["dept"], emp["email"])
  console.print(table)

@cli.command()
@click.argument("name")
def info(name):
  """Show employee details."""
  emp = EMPLOYEES.get(name.lower())
  if not emp:
    console.print(f"[red]Not found:[/red] {name}")
    raise click.exceptions.Exit(1)
  for key, value in emp.items():
    console.print(f"  [cyan]{key}:[/cyan] {value}")

if __name__ == "__main__":
  cli()

Subcommands. Tables. Color. Proper exit codes on error. Looks polished, took 30 lines.

Distribution

To make your tool installable as mytool (instead of python tool.py), use a pyproject.toml:

[project]
name = "mytool"
version = "0.1.0"
dependencies = ["click", "rich"]

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

pip install . and mytool list works system-wide. Lesson 40 covers packaging.

Common stumbles

Forgetting --help. Don't. Use the libraries' auto-generated help; never roll your own.

Bad exit codes. Use sys.exit(1) (or click.exceptions.Exit(1)) for errors. Shells need this for scripting.

Mixing prints. print writes to stdout, errors should go to stderr (click.echo(..., err=True) or sys.stderr.write).

Hard-coded paths. Use click.Path(exists=True) or pathlib.Path so paths work cross-platform.

Globals in CLI handlers. Tests get hard. Keep functions pure; pass state explicitly.

Rich output in pipes. console.print auto-detects TTY and disables formatting when piped — usually correct. If not, configure Console(force_terminal=True).

Subcommand naming clash with builtins. def list(...): shadows the builtin in that scope. Use def ls(...): or rename via name=.

What's next

Lesson 38: FastAPI. Modern web API framework — async, type-hinted, auto-docs.

Recap

argparse (stdlib) for simple tools. Click for multi-command CLIs with decorators. Rich for tables, colors, progress bars, tracebacks. Combine Click + Rich for production-quality tools. Always add help= text and let the framework generate --help. Use click.Choice, IntRange, Path for typed validation. Distribute via pyproject.toml + [project.scripts].

Next lesson: FastAPI.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.