Building Professional CLI Tools in Python with Click and Typer
Go beyond argparse -- build polished, distributable command-line tools with subcommands, progress bars, config files, and rich terminal output.
Contents
Click vs Typer vs argparse
| Feature | argparse | Click | Typer |
|---|---|---|---|
| Syntax | Verbose, class-based | Decorator-based | Type-hint-based |
| Subcommands | Possible, verbose | First-class | First-class |
| Auto help | Basic | Rich help text | Auto from docstrings |
| Testing | Awkward | Built-in CliRunner | Built-in |
| Best for | Stdlib only projects | Complex CLIs | Modern Python 3.9+ |
Click: Arguments, Options, Flags
pip install click rich
import click
@click.command()
@click.argument("filename") # positional, required
@click.option("--output", "-o", default="out.csv", # named option with default
help="Output file path", show_default=True)
@click.option("--format", type=click.Choice(["csv","json","parquet"]),
default="csv", help="Output format")
@click.option("--verbose", "-v", is_flag=True, # boolean flag
help="Enable verbose logging")
@click.option("--limit", type=int, default=0,
help="Row limit (0 = no limit)")
def process(filename, output, format, verbose, limit):
"""Process FILENAME and write results to OUTPUT."""
if verbose:
click.echo(f"Reading: {filename}")
# ...
click.echo(click.style(f"[OK] Written to {output}", fg="green", bold=True))
if __name__ == "__main__":
process()
Input Validation with Types
@click.command()
@click.argument("path", type=click.Path(exists=True, file_okay=True, readable=True))
@click.option("--port", type=click.IntRange(1, 65535), default=8080)
@click.option("--env", type=click.Choice(["dev","staging","prod"]))
@click.password_option("--db-pass", help="Database password")
def deploy(path, port, env, db_pass):
"""Deploy application at PATH."""
click.confirm(f"Deploy to {env} on port {port}?", abort=True)
click.echo("Deploying...")
with click.progressbar(range(100), label="Progress") as bar:
for _ in bar:
do_step()
Subcommands and Groups
# mycli/main.py
import click
from mycli.commands import db, server, user
@click.group()
@click.version_option("1.0.0")
@click.pass_context
def cli(ctx):
"""MyApp CLI -- manage your deployment."""
ctx.ensure_object(dict)
# Register subcommand groups
cli.add_command(db.db_group, name="db")
cli.add_command(server.srv_group, name="server")
cli.add_command(user.user_group, name="user")
# mycli/commands/db.py
import click
@click.group("db")
def db_group():
"""Database management commands."""
@db_group.command("migrate")
@click.option("--dry-run", is_flag=True)
def migrate(dry_run):
"""Run pending migrations."""
click.echo(f"Running migrations {'(dry-run)' if dry_run else ''}...")
@db_group.command("seed")
@click.option("--count", default=100, help="Number of seed records")
def seed(count):
"""Seed the database with test data."""
click.echo(f"Seeding {count} records...")
# Usage:
# mycli db migrate --dry-run
# mycli db seed --count 500
Typer: Type-Hint-Driven CLIs
pip install typer[all]
import typer
from enum import Enum
from pathlib import Path
app = typer.Typer(help="Data processing CLI")
class OutputFormat(str, Enum):
csv = "csv"
json = "json"
parquet = "parquet"
@app.command()
def process(
filename: Path = typer.Argument(..., help="Input file", exists=True),
output: Path = typer.Option(Path("out.csv"), "--output", "-o"),
fmt: OutputFormat = typer.Option(OutputFormat.csv, "--format"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
limit: int = typer.Option(0, "--limit", help="Row limit (0=all)"),
):
"""Process FILENAME and write results to OUTPUT."""
if verbose:
typer.echo(f"Reading: {filename}")
typer.echo(typer.style(f"[OK] Done -> {output}", fg=typer.colors.GREEN, bold=True))
@app.command()
def validate(
filename: Path = typer.Argument(..., exists=True),
strict: bool = typer.Option(False),
):
"""Validate a data file against schema."""
typer.echo("Validating...")
if __name__ == "__main__":
app()
Rich Terminal Output
from rich.console import Console
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, BarColumn, TimeElapsedColumn
from rich.panel import Panel
import time
console = Console()
# Pretty tables
def print_results(data: list[dict]):
table = Table(title="Processing Results", show_header=True, header_style="bold blue")
table.add_column("File", style="cyan")
table.add_column("Rows", justify="right")
table.add_column("Status", justify="center")
table.add_column("Time", justify="right", style="dim")
for row in data:
status = "[green][OK] OK[/]" if row["ok"] else "[red]? FAIL[/]"
table.add_row(row["file"], str(row["rows"]), status, f"{row['time']:.2f}s")
console.print(table)
# Rich progress bar
def run_with_progress(items):
with Progress(
SpinnerColumn(),
"[progress.description]{task.description}",
BarColumn(),
"[progress.percentage]{task.percentage:>3.0f}%",
TimeElapsedColumn(),
console=console
) as progress:
task = progress.add_task("Processing...", total=len(items))
for item in items:
process_item(item)
progress.advance(task)
# Panels and styled output
console.print(Panel.fit(
"[bold green]Pipeline Complete[/]\n"
f"Processed: [cyan]1,234[/] rows\n"
f"Errors: [red]3[/]",
title="Summary", border_style="green"
))
Config File Integration
# Support both CLI args and a config file (TOML)
import tomllib
from pathlib import Path
DEFAULT_CONFIG = Path.home() / ".config" / "mycli" / "config.toml"
@click.command()
@click.option("--config", type=click.Path(), default=str(DEFAULT_CONFIG),
help="Config file path")
@click.pass_context
def cli(ctx, config):
cfg_path = Path(config)
ctx.ensure_object(dict)
if cfg_path.exists():
with open(cfg_path, "rb") as f:
ctx.obj.update(tomllib.load(f))
# config.toml
# [database]
# url = "postgresql://localhost/mydb"
# pool_size = 10
#
# [api]
# base_url = "https://api.example.com"
# timeout = 30
Packaging and Distribution
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mycli"
version = "1.0.0"
dependencies = ["click>=8.0", "typer[all]>=0.9", "rich>=13.0"]
[project.scripts]
mycli = "mycli.main:cli" # <- installs as a shell command
# Install locally for development
pip install -e .
# Now works as system command
mycli --help
mycli db migrate
mycli server start --port 9000
# Build and publish to PyPI
pip install build twine
python -m build
twine upload dist/*
Testing CLI Commands
from click.testing import CliRunner
from mycli.main import cli
def test_process_command():
runner = CliRunner()
with runner.isolated_filesystem():
# Create a test file
with open("input.csv", "w") as f:
f.write("id,value\n1,100\n2,200\n")
result = runner.invoke(cli, ["process", "input.csv", "--format", "json"])
assert result.exit_code == 0
assert "Done" in result.output
def test_missing_file():
runner = CliRunner()
result = runner.invoke(cli, ["process", "nonexistent.csv"])
assert result.exit_code != 0
[YES] Tip: Use Typer for greenfield projects with Python 3.9+ -- the type-hint approach is cleaner and self-documenting. Use Click when you need fine-grained control or are maintaining existing codebases.
Key Takeaways: Click and Typer both eliminate argparse boilerplate. Use subcommands/groups for complex multi-action CLIs. Add Rich for polished terminal output. Always package with pyproject.toml so users can install your tool with pip.
PythonCLIClickTyperRichDevToolsTooling
0 Comments