Python CLI — Command-Line Interface Tools
CLIs make your Python scripts reusable and automatable from the terminal. A well-designed CLI is essential for DevOps tools, data pipelines, and developer utilities. This tutorial covers argparse, click, and typer with real-world examples.
Learning Objectives
- Build CLIs with argparse (standard library)
- Create rich CLIs with click (decorators)
- Build type-safe CLIs with typer
- Add subcommands, options, and validation
- Use rich for beautiful terminal output
argparse — Standard Library
Basic Arguments
import argparse
# Create parser
parser = argparse.ArgumentParser(
description="Process files and generate reports",
epilog="Example: python process.py data.csv -o report.html -v"
)
# Positional arguments
parser.add_argument("input", help="Input file path")
# Optional arguments
parser.add_argument("-o", "--output", default="output.txt",
help="Output file (default: output.txt)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Enable verbose output")
parser.add_argument("-n", "--count", type=int, default=1,
help="Number of times to repeat")
parser.add_argument("--format", choices=["csv", "json", "html"],
default="csv", help="Output format")
# Parse and use
args = parser.parse_args()
print(f"Input: {args.input}")
print(f"Output: {args.output}")
print(f"Verbose: {args.verbose}")
Subcommands
import argparse
parser = argparse.ArgumentParser(description="File manager")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# cp command
cp_parser = subparsers.add_parser("cp", help="Copy files")
cp_parser.add_argument("source", help="Source file")
cp_parser.add_argument("dest", help="Destination")
cp_parser.add_argument("-r", "--recursive", action="store_true")
# mv command
mv_parser = subparsers.add_parser("mv", help="Move files")
mv_parser.add_argument("source", help="Source file")
mv_parser.add_argument("dest", help="Destination")
# rm command
rm_parser = subparsers.add_parser("rm", help="Remove files")
rm_parser.add_argument("file", help="File to remove")
rm_parser.add_argument("-f", "--force", action="store_true")
args = parser.parse_args()
if args.command == "cp":
copy_file(args.source, args.dest, recursive=args.recursive)
elif args.command == "mv":
move_file(args.source, args.dest)
elif args.command == "rm":
remove_file(args.file, force=args.force)
else:
parser.print_help()
Custom Actions
import argparse
class KeyValueAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, dict())
for value in values:
key, val = value.split('=')
getattr(namespace, self.dest)[key] = val
parser = argparse.ArgumentParser()
parser.add_argument("--config", nargs="+", action=KeyValueAction,
help="Config as key=value pairs")
# Usage: python app.py --config name=app --config version=1.0
args = parser.parse_args()
print(args.config) # {'name': 'app', 'version': '1.0'}
click — Decorator-Based CLI
Basic Commands
import click
@click.command()
@click.argument("filename")
@click.option("--count", "-n", default=1, help="Number of times to repeat")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--format", type=click.Choice(["csv", "json", "html"]), default="csv")
def process(filename, count, verbose, format):
"""Process a file and generate output."""
for i in range(count):
if verbose:
click.echo(f"Processing {filename} ({i+1}/{count})")
click.echo(f"Done! Format: {format}")
Subcommands with Groups
import click
@click.group()
@click.option("--debug/--no-debug", default=False, help="Enable debug mode")
@click.pass_context
def cli(ctx, debug):
"""Application CLI with subcommands."""
ctx.ensure_object(dict)
ctx.obj["DEBUG"] = debug
@cli.command()
@click.argument("name")
@click.pass_context
def greet(ctx, name):
"""Say hello to someone."""
if ctx.obj["DEBUG"]:
click.echo(f"[DEBUG] Greeting {name}")
click.echo(f"Hello, {name}!")
@cli.command()
@click.argument("name")
def goodbye(name):
"""Say goodbye to someone."""
click.echo(f"Goodbye, {name}!")
@cli.group()
def admin():
"""Admin commands."""
@admin.command("list-users")
def list_users():
"""List all users."""
click.echo("User 1: Alice")
click.echo("User 2: Bob")
@admin.command("delete-user")
@click.argument("user_id", type=int)
def delete_user(user_id):
"""Delete a user by ID."""
click.echo(f"Deleted user {user_id}")
if __name__ == "__main__":
cli()
# Usage:
# python app.py greet Alice
# python app.py --debug greet Alice
# python app.py admin list-users
# python app.py admin delete-user 42
Progress Bars and Confirmation
import click
import time
@click.command()
@click.option("--items", default=100, help="Number of items to process")
@click.confirmation_option(prompt="Are you sure?")
def process(items):
"""Process items with a progress bar."""
with click.progressbar(range(items), label="Processing") as bar:
for i in bar:
time.sleep(0.01) # Simulate work
click.echo("Done!")
if __name__ == "__main__":
process()
typer — Modern CLI with Type Hints
Basic Commands
import typer
from typing import Optional
from enum import Enum
app = typer.Typer()
class Format(str, Enum):
csv = "csv"
json = "json"
html = "html"
@app.command()
def process(
filename: str,
count: int = typer.Option(1, "--count", "-n"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
format: Format = typer.Option(Format.csv),
):
"""Process a file and generate output."""
for i in range(count):
if verbose:
typer.echo(f"Processing {filename} ({i+1}/{count})")
typer.echo(f"Done! Format: {format.value}")
@app.command()
def hello(name: str, formal: bool = False):
"""Say hello to someone."""
if formal:
typer.echo(f"Good day, {name}.")
else:
typer.echo(f"Hey, {name}!")
if __name__ == "__main__":
app()
Subcommands with typer
import typer
from typing import Optional
app = typer.Typer()
users_app = typer.Typer(help="Manage users")
app.add_typer(users_app, name="users")
@app.command()
def version():
"""Show version info."""
typer.echo("v1.0.0")
@users_app.command("list")
def list_users():
"""List all users."""
typer.echo("Alice\nBob\nCharlie")
@users_app.command("create")
def create_user(
name: str,
email: str = typer.Option(..., prompt=True),
age: int = typer.Option(None, prompt=True),
):
"""Create a new user."""
typer.echo(f"Created user: {name} ({email}, age {age})")
@users_app.command("delete")
def delete_user(
user_id: int,
force: bool = typer.Option(False, "--force", "-f"),
):
"""Delete a user."""
if not force:
confirm = typer.confirm(f"Delete user {user_id}?")
if not confirm:
raise typer.Abort()
typer.echo(f"Deleted user {user_id}")
if __name__ == "__main__":
app()
Using rich for Beautiful Output
# pip install rich
from rich.console import Console
from rich.table import Table
from rich.progress import Progress
from rich.panel import Panel
from rich import print as rprint
console = Console()
# Colored output
console.print("[bold green]Success![/bold green]")
console.print("[red]Error:[/red] File not found")
# Tables
table = Table(title="Users")
table.add_column("ID", style="cyan")
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")
console.print(table)
# Panels
console.print(Panel("Important message", title="Warning", style="yellow"))
# Progress bars
with Progress() as progress:
task = progress.add_task("[cyan]Processing...", total=100)
for i in range(100):
progress.update(task, advance=1)
Comparison
| Feature | argparse | click | typer |
|---|
| Setup | Verbose | Decorators | Type hints |
| Learning curve | Low | Medium | Low |
| Subcommands | Manual | Built-in | Built-in |
| Auto-help | Yes | Yes | Yes |
| Shell completion | No | Yes | Yes |
| Progress bars | No | Yes | Via rich |
| Confirmation | Manual | Built-in | Built-in |
| Best for | Simple CLIs | Complex CLIs | Modern CLIs |
Building a Complete CLI Tool
import typer
from rich.console import Console
from rich.table import Table
from pathlib import Path
from typing import Optional
from enum import Enum
app = typer.Typer(help="File organizer CLI")
console = Console()
class SortBy(str, Enum):
name = "name"
size = "size"
date = "date"
@app.command()
def organize(
directory: str = typer.Argument(".", help="Directory to organize"),
sort_by: SortBy = typer.Option(SortBy.name, help="Sort files by"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes"),
):
"""Organize files in a directory by type."""
dir_path = Path(directory)
if not dir_path.exists():
console.print(f"[red]Error: {directory} does not exist[/red]")
raise typer.Exit(1)
extensions = {
"Documents": [".pdf", ".docx", ".txt", ".md"],
"Images": [".jpg", ".png", ".gif", ".svg"],
"Code": [".py", ".js", ".ts", ".html", ".css"],
"Data": [".csv", ".json", ".xml", ".yaml"],
}
table = Table(title=f"Files in {directory}")
table.add_column("File", style="cyan")
table.add_column("Category", style="green")
table.add_column("Action", style="yellow")
for file in dir_path.iterdir():
if file.is_file():
category = "Other"
for cat, exts in extensions.items():
if file.suffix.lower() in exts:
category = cat
break
dest = dir_path / category / file.name
action = f"Move to {category}/"
if dry_run:
action = f"[dry-run] {action}"
table.add_row(file.name, category, action)
if not dry_run:
(dir_path / category).mkdir(exist_ok=True)
file.rename(dest)
console.print(table)
console.print(f"\n[green]Organized {len(list(dir_path.iterdir()))} files[/green]")
@app.command()
def stats(directory: str = typer.Argument(".")):
"""Show file statistics for a directory."""
dir_path = Path(directory)
extensions = {}
total_size = 0
for file in dir_path.rglob("*"):
if file.is_file():
ext = file.suffix.lower() or "no extension"
extensions[ext] = extensions.get(ext, 0) + 1
total_size += file.stat().st_size
table = Table(title="File Statistics")
table.add_column("Extension", style="cyan")
table.add_column("Count", style="magenta", justify="right")
for ext, count in sorted(extensions.items(), key=lambda x: -x[1]):
table.add_row(ext, str(count))
console.print(table)
console.print(f"\nTotal size: {total_size / 1024 / 1024:.2f} MB")
if __name__ == "__main__":
app()
Common Mistakes
| Mistake | Problem | Solution |
|---|
| No help text | Users can't discover features | Add help= to all options |
| No input validation | Bad inputs cause crashes | Use type hints and validators |
Using print() for output | Can't redirect/suppress output | Use click.echo() or typer.echo() |
| No exit codes | Scripts can't detect failures | Use sys.exit() or raise typer.Exit() |
| Hardcoded paths | Not portable across systems | Accept paths as arguments |
No --version | Users can't check version | Add version option |
Key Takeaways
- Use argparse for simple CLIs (no dependencies)
- Use click for complex CLIs with many options
- Use typer for type-hint-based CLIs
- Always add
--help to all commands
- Validate inputs early and give clear error messages
- Use
click.echo() instead of print() for CLI output
- Add shell completion for better UX
- Use rich for beautiful terminal output
- Support
--dry-run for destructive operations
- Return proper exit codes (0 for success, non-zero for failure)