Skip to main content
Advertisement

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

ToolStrengthsBest For
argparseStandard library, no depsDependency-free distribution
ClickDecorators, intuitiveGeneral CLI tools
TyperType hints, auto-completionType-driven development
RichBeautiful terminal outputEnhancing any CLI output

Click + Rich is the most practical combination. Typer is a natural fit for FastAPI developers.

Advertisement