Docs

cli applications

💻 Building CLI Applications

📌 What You'll Learn

  • How to build professional command-line tools
  • Using argparse (built-in), Click, and Typer
  • Subcommands, options, and arguments
  • Progress bars and colored output
  • Interactive prompts and configuration

🔍 What is a CLI Application?

A Command-Line Interface (CLI) application runs in the terminal and accepts user input through commands, arguments, and options.

# Example CLI commands you've used
git commit -m "message"
pip install requests
python -m pytest --verbose

Why Build CLI Tools?

  1. Automation - Scripts for repetitive tasks
  2. DevOps - Deployment and infrastructure tools
  3. Data processing - Batch processing pipelines
  4. Developer tools - Linters, formatters, generators

📦 argparse - Built-in Module

The standard library's argument parsing module.

Basic Example

import argparse

# Create parser
parser = argparse.ArgumentParser(
    description="A simple greeting program",
    epilog="Example: python greet.py Alice --formal"
)

# Add positional argument
parser.add_argument(
    "name",
    help="Name of the person to greet"
)

# Add optional flag
parser.add_argument(
    "-f", "--formal",
    action="store_true",
    help="Use formal greeting"
)

# Add optional with value
parser.add_argument(
    "-c", "--count",
    type=int,
    default=1,
    help="Number of times to greet (default: 1)"
)

# Parse arguments
args = parser.parse_args()

# Use the arguments
greeting = "Good day" if args.formal else "Hello"
for _ in range(args.count):
    print(f"{greeting}, {args.name}!")

Usage:

python greet.py Alice              # Hello, Alice!
python greet.py Alice --formal     # Good day, Alice!
python greet.py Alice -c 3         # Greets 3 times
python greet.py --help             # Shows help text

Argument Types

import argparse

parser = argparse.ArgumentParser()

# Different argument types
parser.add_argument("--count", type=int)           # Integer
parser.add_argument("--rate", type=float)          # Float
parser.add_argument("--file", type=argparse.FileType('r'))  # File

# Choices - restricted values
parser.add_argument(
    "--level",
    choices=["debug", "info", "warning", "error"],
    default="info"
)

# nargs - multiple values
parser.add_argument("--files", nargs="+")  # One or more
parser.add_argument("--coords", nargs=2, type=float)  # Exactly 2

# Mutually exclusive options
group = parser.add_mutually_exclusive_group()
group.add_argument("--verbose", action="store_true")
group.add_argument("--quiet", action="store_true")

Subcommands

import argparse

parser = argparse.ArgumentParser(prog="myapp")
subparsers = parser.add_subparsers(dest="command", help="Available commands")

# 'create' subcommand
create_parser = subparsers.add_parser("create", help="Create a new item")
create_parser.add_argument("name", help="Item name")
create_parser.add_argument("--type", default="basic")

# 'delete' subcommand
delete_parser = subparsers.add_parser("delete", help="Delete an item")
delete_parser.add_argument("id", type=int, help="Item ID")
delete_parser.add_argument("--force", action="store_true")

# 'list' subcommand
list_parser = subparsers.add_parser("list", help="List all items")
list_parser.add_argument("--all", action="store_true")

args = parser.parse_args()

if args.command == "create":
    print(f"Creating {args.name} of type {args.type}")
elif args.command == "delete":
    print(f"Deleting item {args.id}")
elif args.command == "list":
    print("Listing items...")

Usage:

python myapp.py create "New Item" --type advanced
python myapp.py delete 123 --force
python myapp.py list --all

🖱️ Click - Decorator-Based CLI

Click is a popular third-party library with cleaner syntax.

pip install click

Basic Example

import click

@click.command()
@click.argument("name")
@click.option("--formal", "-f", is_flag=True, help="Use formal greeting")
@click.option("--count", "-c", default=1, help="Number of greetings")
def greet(name: str, formal: bool, count: int):
    """Greet someone by NAME."""
    greeting = "Good day" if formal else "Hello"
    for _ in range(count):
        click.echo(f"{greeting}, {name}!")

if __name__ == "__main__":
    greet()

Click Features

import click

@click.command()
@click.option("--name", prompt="Your name", help="The person to greet")
@click.option("--password", prompt=True, hide_input=True)  # Hidden input
@click.option("--shout/--no-shout", default=False)  # Boolean flag
@click.option("--color", type=click.Choice(["red", "green", "blue"]))
def cli(name, password, shout, color):
    """Example showing Click features."""
    msg = f"Hello, {name}!"
    if shout:
        msg = msg.upper()
    if color:
        click.secho(msg, fg=color)  # Colored output
    else:
        click.echo(msg)

# Progress bar
@click.command()
def download():
    with click.progressbar(range(100)) as bar:
        for item in bar:
            import time
            time.sleep(0.05)

if __name__ == "__main__":
    cli()

Click Groups (Subcommands)

import click

@click.group()
def cli():
    """My CLI application."""
    pass

@cli.command()
@click.argument("name")
def create(name):
    """Create a new item."""
    click.echo(f"Created: {name}")

@cli.command()
@click.argument("item_id", type=int)
@click.option("--force", is_flag=True)
def delete(item_id, force):
    """Delete an item by ID."""
    if force:
        click.echo(f"Force deleted: {item_id}")
    else:
        click.echo(f"Deleted: {item_id}")

@cli.command("list")  # Use 'list' as command name
def list_items():
    """List all items."""
    click.echo("Listing items...")

if __name__ == "__main__":
    cli()

⚡ Typer - Modern CLI with Type Hints

Typer builds on Click with type hint support.

pip install typer[all]  # Includes rich for better output

Basic Example

import typer

app = typer.Typer()

@app.command()
def greet(
    name: str,
    formal: bool = typer.Option(False, "--formal", "-f", help="Formal greeting"),
    count: int = typer.Option(1, "--count", "-c", help="Times to greet")
):
    """Greet someone by NAME."""
    greeting = "Good day" if formal else "Hello"
    for _ in range(count):
        typer.echo(f"{greeting}, {name}!")

if __name__ == "__main__":
    app()

Typer with Type Inference

import typer
from typing import Optional
from enum import Enum

class Color(str, Enum):
    RED = "red"
    GREEN = "green"
    BLUE = "blue"

app = typer.Typer()

@app.command()
def process(
    # Positional argument (required)
    filename: str,

    # Optional with default
    output: str = "output.txt",

    # Flag
    verbose: bool = False,

    # Enum choices
    color: Color = Color.RED,

    # Optional (can be None)
    config: Optional[str] = None
):
    """Process a file."""
    typer.echo(f"Processing {filename}")
    if verbose:
        typer.echo(f"Output: {output}")
        typer.echo(f"Color: {color.value}")

@app.command()
def version():
    """Show version."""
    typer.echo("v1.0.0")

if __name__ == "__main__":
    app()

🎨 Rich - Beautiful Terminal Output

pip install rich

Colored and Formatted Output

from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import track
from rich import print  # Enhanced print

console = Console()

# Colored output
console.print("[bold red]Error:[/bold red] Something went wrong!")
console.print("[green]Success![/green] Operation completed.")

# Tables
table = Table(title="Users")
table.add_column("ID", style="cyan")
table.add_column("Name", style="magenta")
table.add_column("Email")

table.add_row("1", "Alice", "alice@example.com")
table.add_row("2", "Bob", "bob@example.com")
table.add_row("3", "Charlie", "charlie@example.com")

console.print(table)

# Panels
panel = Panel("This is important information!", title="Notice", border_style="blue")
console.print(panel)

# Progress bar
import time
for item in track(range(100), description="Processing..."):
    time.sleep(0.02)

# Status spinner
with console.status("[bold green]Working...") as status:
    time.sleep(2)
    status.update("[bold blue]Almost done...")
    time.sleep(1)

📝 Interactive Prompts

Using prompt_toolkit

pip install prompt_toolkit
from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.validation import Validator

# Simple prompt
name = prompt("Enter your name: ")

# With auto-completion
commands = WordCompleter(["create", "delete", "list", "help", "quit"])
command = prompt("Command: ", completer=commands)

# With validation
def is_valid_number(text):
    return text.isdigit()

validator = Validator.from_callable(
    is_valid_number,
    error_message="Please enter a valid number"
)
number = prompt("Enter a number: ", validator=validator)

# Password input
password = prompt("Password: ", is_password=True)

Using questionary

pip install questionary
import questionary

# Text input
name = questionary.text("What's your name?").ask()

# Selection
choice = questionary.select(
    "Choose an option:",
    choices=["Option 1", "Option 2", "Option 3"]
).ask()

# Checkbox (multiple selection)
selected = questionary.checkbox(
    "Select items:",
    choices=["Item A", "Item B", "Item C", "Item D"]
).ask()

# Confirmation
if questionary.confirm("Are you sure?").ask():
    print("Confirmed!")

# Password
password = questionary.password("Enter password:").ask()

📁 Configuration Files

Reading YAML/TOML Config

import tomllib  # Python 3.11+, use tomli for earlier
from pathlib import Path

# Read TOML config
def load_config(path: str = "config.toml") -> dict:
    config_path = Path(path)
    if config_path.exists():
        with open(config_path, "rb") as f:
            return tomllib.load(f)
    return {}

# config.toml:
# [database]
# host = "localhost"
# port = 5432
#
# [logging]
# level = "INFO"

config = load_config()
db_host = config.get("database", {}).get("host", "localhost")

XDG Config Directories

from pathlib import Path
import os

def get_config_dir(app_name: str) -> Path:
    """Get platform-appropriate config directory."""
    if os.name == "nt":  # Windows
        base = Path(os.environ.get("APPDATA", "~"))
    else:  # Linux/Mac
        base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config"))

    config_dir = base.expanduser() / app_name
    config_dir.mkdir(parents=True, exist_ok=True)
    return config_dir

# Usage
config_dir = get_config_dir("myapp")
config_file = config_dir / "config.toml"

🚀 Complete CLI Example

#!/usr/bin/env python3
"""
A complete CLI application example.
"""
import typer
from rich.console import Console
from rich.table import Table
from pathlib import Path
from typing import Optional
import json

app = typer.Typer(help="Task Manager CLI")
console = Console()

TASKS_FILE = Path.home() / ".tasks.json"

def load_tasks() -> list[dict]:
    if TASKS_FILE.exists():
        return json.loads(TASKS_FILE.read_text())
    return []

def save_tasks(tasks: list[dict]) -> None:
    TASKS_FILE.write_text(json.dumps(tasks, indent=2))

@app.command()
def add(description: str, priority: int = 1):
    """Add a new task."""
    tasks = load_tasks()
    task = {
        "id": len(tasks) + 1,
        "description": description,
        "priority": priority,
        "done": False
    }
    tasks.append(task)
    save_tasks(tasks)
    console.print(f"[green]✓[/green] Added task: {description}")

@app.command()
def list(all: bool = False):
    """List all tasks."""
    tasks = load_tasks()
    if not all:
        tasks = [t for t in tasks if not t["done"]]

    if not tasks:
        console.print("[yellow]No tasks found.[/yellow]")
        return

    table = Table(title="Tasks")
    table.add_column("ID", style="cyan")
    table.add_column("Description")
    table.add_column("Priority", justify="center")
    table.add_column("Status", justify="center")

    for task in tasks:
        status = "[green]✓[/green]" if task["done"] else "[red]○[/red]"
        table.add_row(
            str(task["id"]),
            task["description"],
            str(task["priority"]),
            status
        )

    console.print(table)

@app.command()
def done(task_id: int):
    """Mark a task as done."""
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            task["done"] = True
            save_tasks(tasks)
            console.print(f"[green]✓[/green] Marked task {task_id} as done")
            return
    console.print(f"[red]Task {task_id} not found[/red]")

@app.command()
def delete(task_id: int, force: bool = False):
    """Delete a task."""
    if not force:
        confirm = typer.confirm(f"Delete task {task_id}?")
        if not confirm:
            console.print("Cancelled")
            return

    tasks = load_tasks()
    tasks = [t for t in tasks if t["id"] != task_id]
    save_tasks(tasks)
    console.print(f"[green]✓[/green] Deleted task {task_id}")

if __name__ == "__main__":
    app()

📋 CLI Best Practices

  1. Always provide --help - argparse/Click/Typer do this automatically
  2. Use descriptive names - --output-file not -o
  3. Provide sensible defaults - Most options shouldn't be required
  4. Show progress - For long operations, use progress bars
  5. Use exit codes - 0 for success, non-zero for errors
  6. Support --quiet and --verbose - Control output verbosity
  7. Read from stdin, write to stdout - Enable piping
  8. Use color sparingly - Make output machine-parseable when needed

🎯 Next Steps

After learning CLI applications, proceed to 27_api_development to learn how to build REST APIs!

Cli Applications - Python Tutorial | DeepML