🎉 75% of content is free forever — Unlock Premium from $10/mo →
CW
Search courses…
💼 Servicesℹ️ About✉️ ContactView Pricing Plansfrom $10

Python CLI — Command-Line Interface Tools

Python AdvancedCLI🟢 Free Lesson

Advertisement

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

Featureargparseclicktyper
SetupVerboseDecoratorsType hints
Learning curveLowMediumLow
SubcommandsManualBuilt-inBuilt-in
Auto-helpYesYesYes
Shell completionNoYesYes
Progress barsNoYesVia rich
ConfirmationManualBuilt-inBuilt-in
Best forSimple CLIsComplex CLIsModern 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

MistakeProblemSolution
No help textUsers can't discover featuresAdd help= to all options
No input validationBad inputs cause crashesUse type hints and validators
Using print() for outputCan't redirect/suppress outputUse click.echo() or typer.echo()
No exit codesScripts can't detect failuresUse sys.exit() or raise typer.Exit()
Hardcoded pathsNot portable across systemsAccept paths as arguments
No --versionUsers can't check versionAdd version option

Key Takeaways

  1. Use argparse for simple CLIs (no dependencies)
  2. Use click for complex CLIs with many options
  3. Use typer for type-hint-based CLIs
  4. Always add --help to all commands
  5. Validate inputs early and give clear error messages
  6. Use click.echo() instead of print() for CLI output
  7. Add shell completion for better UX
  8. Use rich for beautiful terminal output
  9. Support --dry-run for destructive operations
  10. Return proper exit codes (0 for success, non-zero for failure)

Premium Content

Python CLI — Command-Line Interface Tools

Unlock this lesson and 900+ advanced tutorials with a Premium plan.

🎯End-to-end Projects
💼Interview Prep
📜Certificates
🤝Community Access

Already a member? Log in

Need Expert Python Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement