Building Python CLI Tools with Click and Typer

!DOCTYPE html> Building Professional CLI Tools in Python with Click and Typer

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.

Click vs Typer vs argparse

FeatureargparseClickTyper
SyntaxVerbose, class-basedDecorator-basedType-hint-based
SubcommandsPossible, verboseFirst-classFirst-class
Auto helpBasicRich help textAuto from docstrings
TestingAwkwardBuilt-in CliRunnerBuilt-in
Best forStdlib only projectsComplex CLIsModern 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

Post a Comment

0 Comments