CLI Tool Development
Build command-line tools with argparse, Click, and Typer, and beautify output with Rich.
argparse — Standard Library
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description="File conversion tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python cli.py convert input.csv -o output.json --format json
python cli.py info input.csv --verbose
""",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# convert subcommand
convert_parser = subparsers.add_parser("convert", help="Convert file format")
convert_parser.add_argument("input", help="Input file path")
convert_parser.add_argument("-o", "--output", required=True, help="Output file path")
convert_parser.add_argument(
"--format",
choices=["json", "csv", "yaml"],
default="json",
help="Output format (default: json)",
)
convert_parser.add_argument("--encoding", default="utf-8")
# info subcommand
info_parser = subparsers.add_parser("info", help="Show file information")
info_parser.add_argument("input", help="File to analyze")
info_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
args = parser.parse_args()
if args.command == "convert":
convert(args.input, args.output, args.format, args.encoding)
elif args.command == "info":
show_info(args.input, args.verbose)
def convert(input_file, output_file, fmt, encoding):
print(f"{input_file} → {output_file} ({fmt})")
def show_info(input_file, verbose):
print(f"File: {input_file}, Verbose: {verbose}")
if __name__ == "__main__":
main()
Click — Decorator-Based CLI
pip install click
import click
from pathlib import Path
@click.group()
@click.version_option(version="1.0.0")
def cli():
"""File processing CLI tool"""
pass
@cli.command()
@click.argument("input_file", type=click.Path(exists=True))
@click.option("-o", "--output", required=True, type=click.Path(), help="Output file")
@click.option("--format", "fmt", type=click.Choice(["json", "csv", "yaml"]), default="json")
@click.option("--encoding", default="utf-8", show_default=True)
@click.option("--dry-run", is_flag=True, help="Simulate without making changes")
def convert(input_file, output, fmt, encoding, dry_run):
"""Convert file format."""
if dry_run:
click.echo(f"[DRY-RUN] {input_file} → {output} ({fmt})")
return
click.echo(f"Converting: {input_file}", err=True) # stderr
# ... actual conversion logic
click.secho(f"✅ Done: {output}", fg="green", bold=True)
@cli.command()
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("--verbose", "-v", is_flag=True)
@click.pass_context
def info(ctx, files, verbose):
"""Show file information."""
for file in files:
path = Path(file)
size = path.stat().st_size
click.echo(f"{path.name}: {size:,} bytes")
if verbose:
click.echo(f" Absolute path: {path.resolve()}")
click.echo(f" Extension: {path.suffix}")
# Prompt + confirmation
@cli.command()
@click.argument("target")
@click.confirmation_option(prompt="Are you sure you want to delete?")
def delete(target):
"""Delete a file or directory."""
click.echo(f"Deleting: {target}")
# Progress bar
@cli.command()
@click.argument("items", type=int, default=100)
def process(items):
"""Simulate large-scale processing"""
with click.progressbar(range(items), label="Processing") as bar:
for _ in bar:
import time; time.sleep(0.01)
if __name__ == "__main__":
cli()
Typer — Type Hint-Based CLI
pip install typer[all]
import typer
from pathlib import Path
from typing import Optional
from enum import Enum
app = typer.Typer(help="File processing CLI tool", rich_markup_mode="rich")
class OutputFormat(str, Enum):
json = "json"
csv = "csv"
yaml = "yaml"
@app.command()
def convert(
input_file: Path = typer.Argument(..., help="Input file", exists=True),
output: Path = typer.Option(..., "--output", "-o", help="Output path"),
fmt: OutputFormat = typer.Option(OutputFormat.json, "--format", help="Output format"),
encoding: str = typer.Option("utf-8", help="Encoding"),
dry_run: bool = typer.Option(False, "--dry-run", help="Simulation mode"),
):
"""[bold]Convert[/bold] file format."""
if dry_run:
typer.echo(f"[DRY-RUN] {input_file} → {output}")
return
typer.echo(f"Converting: {input_file}")
typer.secho("Done!", fg=typer.colors.GREEN, bold=True)
@app.command()
def info(
files: list[Path] = typer.Argument(..., help="Files to analyze"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
):
"""Show file information."""
for file in files:
size = file.stat().st_size
typer.echo(f"{file.name}: {size:,} bytes")
if verbose:
typer.echo(f" Path: {file.resolve()}")
# Sub-app (nested commands)
db_app = typer.Typer(help="Database commands")
app.add_typer(db_app, name="db")
@db_app.command("migrate")
def db_migrate(
revision: str = typer.Argument("head"),
dry_run: bool = typer.Option(False),
):
"""Run database migration"""
typer.echo(f"Migrating: {revision}")
if __name__ == "__main__":
app()
Rich — Terminal Output Styling
pip install rich
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
from rich.syntax import Syntax
from rich.tree import Tree
from rich.live import Live
from rich import print as rprint
import time
console = Console()
# ── Basic markup ─────────────────────────────────────────
console.print("[bold green]Success![/bold green] Task complete")
console.print("[red]Error:[/red] File not found", style="bold")
console.rule("[blue]Section Divider[/blue]")
# ── Panel ────────────────────────────────────────────────
console.print(Panel.fit(
"[bold]Deployment Complete[/bold]\nServer: prod-01\nVersion: v2.3.1",
title="[green]✅ SUCCESS[/green]",
border_style="green",
))
# ── Table ────────────────────────────────────────────────
table = Table(title="Server Status", show_lines=True)
table.add_column("Server", style="cyan", no_wrap=True)
table.add_column("Status", justify="center")
table.add_column("CPU", justify="right")
table.add_column("Memory", justify="right")
table.add_row("web-01", "[green]●[/green] OK", "23%", "4.2 GB")
table.add_row("web-02", "[green]●[/green] OK", "31%", "3.8 GB")
table.add_row("db-01", "[yellow]●[/yellow] Warn", "87%", "14.1 GB")
table.add_row("cache-01","[red]●[/red] Error", "—", "—")
console.print(table)
# ── Progress bar ─────────────────────────────────────────
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console,
) as progress:
task1 = progress.add_task("Downloading", total=100)
task2 = progress.add_task("Extracting", total=50)
for i in range(100):
progress.update(task1, advance=1)
if i >= 50:
progress.update(task2, advance=1)
time.sleep(0.03)
# ── Syntax highlighting ───────────────────────────────────
code = '''
def hello(name: str) -> str:
return f"Hello, {name}!"
'''
console.print(Syntax(code, "python", theme="monokai", line_numbers=True))
# ── Tree structure ────────────────────────────────────────
tree = Tree("[bold]project[/bold]")
src = tree.add("[blue]src/[/blue]")
src.add("main.py")
src.add("config.py")
tests = tree.add("[yellow]tests/[/yellow]")
tests.add("test_main.py")
console.print(tree)
# ── Logging integration ───────────────────────────────────
from rich.logging import RichHandler
import logging
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[RichHandler(console=console, rich_tracebacks=True)],
)
logger = logging.getLogger(__name__)
logger.info("Server started")
logger.warning("Low disk space")
logger.error("Connection failed")
Click + Rich Integration Pattern
import click
from rich.console import Console
from rich.table import Table
console = Console()
err_console = Console(stderr=True)
def success(msg: str):
console.print(f"[bold green]✅[/bold green] {msg}")
def error(msg: str):
err_console.print(f"[bold red]❌[/bold red] {msg}")
raise SystemExit(1)
@click.command()
@click.argument("query")
@click.option("--limit", default=10, show_default=True)
def search(query: str, limit: int):
"""Search the database"""
console.print(f"Searching: [cyan]{query}[/cyan]")
table = Table()
table.add_column("ID")
table.add_column("Name")
table.add_column("Score", justify="right")
for i in range(1, limit + 1):
table.add_row(str(i), f"Item {i}", f"{90 + i}")
console.print(table)
success(f"{limit} results found")
if __name__ == "__main__":
search()
Summary
| Tool | Strengths | Best For |
|---|---|---|
argparse | Standard library, no deps | Dependency-free distribution |
Click | Decorators, intuitive | General CLI tools |
Typer | Type hints, auto-completion | Type-driven development |
Rich | Beautiful terminal output | Enhancing any CLI output |
Click + Rich is the most practical combination. Typer is a natural fit for FastAPI developers.