Skip to content

CLI Tools (Click/Typer) Trung cấp

CLI = Command Line Interface = Giao diện dòng lệnh chuyên nghiệp cho Python apps

Learning Outcomes

Sau khi hoàn thành trang này, bạn sẽ:

  • ✅ So sánh Click vs Typer vs argparse và biết khi nào dùng cái nào
  • ✅ Xây dựng CLI applications với commands, options, arguments
  • ✅ Thiết lập auto-completion cho shell (bash, zsh, fish)
  • ✅ Generate help documentation tự động
  • ✅ Viết tests cho CLI applications

Click vs Typer vs argparse

So sánh tổng quan

FeatureargparseClickTyper
Built-in
Type hints
Decorator-based
Auto-completionManualPluginBuilt-in
Nested commandsComplexEasyEasy
TestingManualBuilt-inBuilt-in
Learning curveMediumLowVery Low
DependenciesNoneFewClick + typing

Quick Comparison

python
# === ARGPARSE (Built-in, verbose) ===
import argparse

parser = argparse.ArgumentParser(description="Greet someone")
parser.add_argument("name", help="Name to greet")
parser.add_argument("--count", "-c", type=int, default=1, help="Number of greetings")
parser.add_argument("--formal", action="store_true", help="Use formal greeting")

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

# === CLICK (Decorator-based, popular) ===
import click

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

if __name__ == "__main__":
    greet()

# === TYPER (Type hints, modern) ===
import typer

app = typer.Typer()

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

if __name__ == "__main__":
    app()

Click Deep Dive

Installation

bash
pip install click

Basic Structure

python
import click

@click.command()
@click.option("--name", "-n", default="World", help="Name to greet")
@click.option("--count", "-c", default=1, type=int, help="Number of greetings")
def hello(name: str, count: int):
    """Simple program that greets NAME for COUNT times."""
    for _ in range(count):
        click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    hello()
bash
$ python hello.py --help
Usage: hello.py [OPTIONS]

  Simple program that greets NAME for COUNT times.

Options:
  -n, --name TEXT     Name to greet  [default: World]
  -c, --count INTEGER Number of greetings  [default: 1]
  --help              Show this message and exit.

$ python hello.py --name Alice --count 3
Hello, Alice!
Hello, Alice!
Hello, Alice!

Arguments vs Options

python
import click

@click.command()
@click.argument("filename")                    # Required positional
@click.argument("dest", required=False)        # Optional positional
@click.option("--verbose", "-v", is_flag=True) # Optional flag
@click.option("--output", "-o", type=click.Path()) # Optional with value
def process(filename: str, dest: str | None, verbose: bool, output: str | None):
    """Process FILENAME and optionally save to DEST."""
    if verbose:
        click.echo(f"Processing {filename}...")
    
    # Process file...
    
    if dest:
        click.echo(f"Saving to {dest}")
    if output:
        click.echo(f"Output: {output}")

# Usage:
# python cli.py input.txt                    # filename only
# python cli.py input.txt output.txt         # filename + dest
# python cli.py input.txt -v -o result.json  # with options

Option Types

python
import click

@click.command()
# String (default)
@click.option("--name", type=str)

# Integer
@click.option("--count", type=int)

# Float
@click.option("--rate", type=float)

# Boolean flag
@click.option("--verbose", is_flag=True)

# Choice (enum-like)
@click.option("--format", type=click.Choice(["json", "csv", "xml"]))

# File path (with validation)
@click.option("--input", type=click.Path(exists=True))
@click.option("--output", type=click.Path(writable=True))

# File (auto-open)
@click.option("--config", type=click.File("r"))

# Multiple values
@click.option("--tag", "-t", multiple=True)

# Tuple
@click.option("--point", nargs=2, type=float)

# Range
@click.option("--level", type=click.IntRange(1, 10))

# Password (hidden input)
@click.option("--password", prompt=True, hide_input=True)

# Confirmation
@click.option("--yes", is_flag=True, callback=lambda ctx, param, value: value or click.confirm("Continue?"))
def example(**kwargs):
    click.echo(kwargs)

Command Groups (Subcommands)

python
import click

@click.group()
@click.option("--debug/--no-debug", default=False)
@click.pass_context
def cli(ctx, debug: bool):
    """My CLI application."""
    ctx.ensure_object(dict)
    ctx.obj["DEBUG"] = debug

@cli.command()
@click.argument("name")
@click.pass_context
def create(ctx, name: str):
    """Create a new item."""
    if ctx.obj["DEBUG"]:
        click.echo(f"[DEBUG] Creating {name}")
    click.echo(f"Created: {name}")

@cli.command()
@click.argument("name")
@click.option("--force", "-f", is_flag=True)
@click.pass_context
def delete(ctx, name: str, force: bool):
    """Delete an item."""
    if not force:
        click.confirm(f"Delete {name}?", abort=True)
    click.echo(f"Deleted: {name}")

@cli.command()
def list():
    """List all items."""
    click.echo("Items: item1, item2, item3")

if __name__ == "__main__":
    cli()
bash
$ python cli.py --help
Usage: cli.py [OPTIONS] COMMAND [ARGS]...

  My CLI application.

Options:
  --debug / --no-debug
  --help                Show this message and exit.

Commands:
  create  Create a new item.
  delete  Delete an item.
  list    List all items.

$ python cli.py --debug create myitem
[DEBUG] Creating myitem
Created: myitem

$ python cli.py delete myitem
Delete myitem? [y/N]: y
Deleted: myitem

Nested Command Groups

python
import click

@click.group()
def cli():
    """Database management CLI."""
    pass

@cli.group()
def db():
    """Database commands."""
    pass

@db.command()
def init():
    """Initialize database."""
    click.echo("Database initialized")

@db.command()
def migrate():
    """Run migrations."""
    click.echo("Migrations complete")

@cli.group()
def user():
    """User management commands."""
    pass

@user.command()
@click.argument("username")
def create(username: str):
    """Create a new user."""
    click.echo(f"User {username} created")

@user.command()
def list():
    """List all users."""
    click.echo("Users: admin, user1, user2")

if __name__ == "__main__":
    cli()
bash
$ python cli.py db init
Database initialized

$ python cli.py user create alice
User alice created

Typer Deep Dive

Installation

bash
pip install typer[all]  # Includes rich for pretty output
# or
pip install typer

Basic Structure

python
import typer

app = typer.Typer()

@app.command()
def hello(name: str = "World", count: int = 1):
    """Simple program that greets NAME for COUNT times."""
    for _ in range(count):
        typer.echo(f"Hello, {name}!")

if __name__ == "__main__":
    app()

Type Hints = CLI Definition

python
import typer
from typing import Optional
from enum import Enum
from pathlib import Path

app = typer.Typer()

class Format(str, Enum):
    json = "json"
    csv = "csv"
    xml = "xml"

@app.command()
def process(
    # Required argument (no default)
    filename: Path,
    
    # Optional argument
    dest: Optional[Path] = typer.Argument(None),
    
    # Options with defaults
    format: Format = Format.json,
    verbose: bool = False,
    count: int = typer.Option(1, "--count", "-c", min=1, max=100),
    
    # Multiple values
    tags: Optional[list[str]] = typer.Option(None, "--tag", "-t"),
    
    # Password (hidden)
    password: str = typer.Option(..., prompt=True, hide_input=True),
):
    """Process FILENAME with various options."""
    if verbose:
        typer.echo(f"Processing {filename} as {format.value}")
    
    if tags:
        typer.echo(f"Tags: {', '.join(tags)}")
    
    typer.echo(f"Count: {count}")

if __name__ == "__main__":
    app()

Command Groups

python
import typer

app = typer.Typer(help="My CLI application")
db_app = typer.Typer(help="Database commands")
user_app = typer.Typer(help="User management")

app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")

@db_app.command()
def init():
    """Initialize database."""
    typer.echo("Database initialized")

@db_app.command()
def migrate(revision: str = "head"):
    """Run migrations to REVISION."""
    typer.echo(f"Migrating to {revision}")

@user_app.command()
def create(username: str, admin: bool = False):
    """Create a new user."""
    role = "admin" if admin else "user"
    typer.echo(f"Created {role}: {username}")

@user_app.command("list")  # Rename command
def list_users():
    """List all users."""
    typer.echo("Users: admin, user1, user2")

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

if __name__ == "__main__":
    app()

Rich Integration (Pretty Output)

python
import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track
from rich import print as rprint
import time

app = typer.Typer()
console = Console()

@app.command()
def users():
    """List users in a pretty table."""
    table = Table(title="Users")
    table.add_column("ID", style="cyan")
    table.add_column("Name", style="magenta")
    table.add_column("Role", style="green")
    
    table.add_row("1", "Alice", "Admin")
    table.add_row("2", "Bob", "User")
    table.add_row("3", "Charlie", "User")
    
    console.print(table)

@app.command()
def process(count: int = 100):
    """Process items with progress bar."""
    for _ in track(range(count), description="Processing..."):
        time.sleep(0.01)
    
    rprint("[green]✓[/green] Processing complete!")

@app.command()
def status():
    """Show status with colors."""
    rprint("[bold green]✓ Database:[/bold green] Connected")
    rprint("[bold green]✓ Cache:[/bold green] Ready")
    rprint("[bold yellow]⚠ Queue:[/bold yellow] 5 pending jobs")
    rprint("[bold red]✗ External API:[/bold red] Unavailable")

if __name__ == "__main__":
    app()

Auto-completion Setup

Click Auto-completion

bash
# Bash
_MYAPP_COMPLETE=bash_source myapp > ~/.myapp-complete.bash
echo '. ~/.myapp-complete.bash' >> ~/.bashrc

# Zsh
_MYAPP_COMPLETE=zsh_source myapp > ~/.myapp-complete.zsh
echo '. ~/.myapp-complete.zsh' >> ~/.zshrc

# Fish
_MYAPP_COMPLETE=fish_source myapp > ~/.config/fish/completions/myapp.fish

Typer Auto-completion

bash
# Install completion
myapp --install-completion

# Or manually for specific shell
myapp --install-completion bash
myapp --install-completion zsh
myapp --install-completion fish
myapp --install-completion powershell

# Show completion script (for debugging)
myapp --show-completion

Custom Completions

python
import click

def get_usernames(ctx, args, incomplete):
    """Return list of usernames for completion."""
    users = ["alice", "bob", "charlie", "david"]
    return [u for u in users if u.startswith(incomplete)]

@click.command()
@click.argument("username", shell_complete=get_usernames)
def greet(username: str):
    """Greet a user."""
    click.echo(f"Hello, {username}!")
python
# Typer custom completion
import typer

def complete_username(incomplete: str) -> list[str]:
    users = ["alice", "bob", "charlie", "david"]
    return [u for u in users if u.startswith(incomplete)]

app = typer.Typer()

@app.command()
def greet(
    username: str = typer.Argument(..., autocompletion=complete_username)
):
    """Greet a user."""
    typer.echo(f"Hello, {username}!")

Help Generation

Docstrings = Help Text

python
import typer

app = typer.Typer(
    name="myapp",
    help="My awesome CLI application.",
    add_completion=True,
)

@app.command()
def process(
    filename: str = typer.Argument(..., help="Input file to process"),
    output: str = typer.Option("output.txt", "--output", "-o", help="Output file path"),
    verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
):
    """
    Process a file and generate output.
    
    This command reads FILENAME, processes its contents,
    and writes the result to OUTPUT.
    
    Example:
        $ myapp process input.txt -o result.txt -v
    """
    if verbose:
        typer.echo(f"Processing {filename}...")
    typer.echo(f"Output: {output}")

@app.command()
def version():
    """Show the application version."""
    typer.echo("myapp v1.0.0")
bash
$ myapp --help
Usage: myapp [OPTIONS] COMMAND [ARGS]...

  My awesome CLI application.

Options:
  --install-completion  Install completion for the current shell.
  --show-completion     Show completion for the current shell.
  --help                Show this message and exit.

Commands:
  process  Process a file and generate output.
  version  Show the application version.

$ myapp process --help
Usage: myapp process [OPTIONS] FILENAME

  Process a file and generate output.

  This command reads FILENAME, processes its contents, and writes the result
  to OUTPUT.

  Example:
      $ myapp process input.txt -o result.txt -v

Arguments:
  FILENAME  Input file to process  [required]

Options:
  -o, --output TEXT  Output file path  [default: output.txt]
  -v, --verbose      Enable verbose output
  --help             Show this message and exit.

Rich Help Panels

python
import typer

app = typer.Typer(rich_markup_mode="rich")

@app.command()
def process(
    filename: str = typer.Argument(..., help="Input [bold]file[/bold] to process"),
    output: str = typer.Option(
        "output.txt",
        "--output", "-o",
        help="Output file path",
        rich_help_panel="Output Options",
    ),
    format: str = typer.Option(
        "json",
        "--format", "-f",
        help="Output format",
        rich_help_panel="Output Options",
    ),
    verbose: bool = typer.Option(
        False,
        "--verbose", "-v",
        help="Enable verbose output",
        rich_help_panel="Debug Options",
    ),
    debug: bool = typer.Option(
        False,
        "--debug",
        help="Enable debug mode",
        rich_help_panel="Debug Options",
    ),
):
    """
    Process a file and generate output.
    
    [bold]Examples:[/bold]
    
    [dim]$ myapp process input.txt[/dim]
    
    [dim]$ myapp process input.txt -o result.json -f json[/dim]
    """
    pass

CLI Testing Patterns

Testing Click Applications

python
# cli.py
import click

@click.command()
@click.argument("name")
@click.option("--count", "-c", default=1, type=int)
def greet(name: str, count: int):
    """Greet someone."""
    for _ in range(count):
        click.echo(f"Hello, {name}!")

# test_cli.py
from click.testing import CliRunner
from cli import greet

def test_greet_default():
    runner = CliRunner()
    result = runner.invoke(greet, ["Alice"])
    
    assert result.exit_code == 0
    assert "Hello, Alice!" in result.output

def test_greet_with_count():
    runner = CliRunner()
    result = runner.invoke(greet, ["Bob", "--count", "3"])
    
    assert result.exit_code == 0
    assert result.output.count("Hello, Bob!") == 3

def test_greet_missing_argument():
    runner = CliRunner()
    result = runner.invoke(greet, [])
    
    assert result.exit_code != 0
    assert "Missing argument" in result.output

def test_greet_invalid_count():
    runner = CliRunner()
    result = runner.invoke(greet, ["Alice", "--count", "invalid"])
    
    assert result.exit_code != 0

Testing Typer Applications

python
# cli.py
import typer

app = typer.Typer()

@app.command()
def greet(name: str, count: int = 1):
    """Greet someone."""
    for _ in range(count):
        typer.echo(f"Hello, {name}!")

# test_cli.py
from typer.testing import CliRunner
from cli import app

runner = CliRunner()

def test_greet_default():
    result = runner.invoke(app, ["Alice"])
    
    assert result.exit_code == 0
    assert "Hello, Alice!" in result.output

def test_greet_with_count():
    result = runner.invoke(app, ["Bob", "--count", "3"])
    
    assert result.exit_code == 0
    assert result.output.count("Hello, Bob!") == 3

def test_help():
    result = runner.invoke(app, ["--help"])
    
    assert result.exit_code == 0
    assert "Greet someone" in result.output

Testing with File I/O

python
from click.testing import CliRunner
from cli import process

def test_process_file():
    runner = CliRunner()
    
    with runner.isolated_filesystem():
        # Create test file
        with open("input.txt", "w") as f:
            f.write("test content")
        
        # Run command
        result = runner.invoke(process, ["input.txt", "-o", "output.txt"])
        
        assert result.exit_code == 0
        
        # Verify output file
        with open("output.txt") as f:
            assert "processed" in f.read()

Testing with Environment Variables

python
from click.testing import CliRunner
from cli import app

def test_with_env_vars():
    runner = CliRunner()
    
    result = runner.invoke(
        app,
        ["command"],
        env={"API_KEY": "test-key", "DEBUG": "true"}
    )
    
    assert result.exit_code == 0

Production Pitfalls ⚠️

1. Không handle errors gracefully

python
# ❌ BAD: Exception bubbles up
@app.command()
def process(filename: str):
    with open(filename) as f:  # FileNotFoundError!
        data = f.read()

# ✅ GOOD: Handle errors với nice messages
@app.command()
def process(filename: str):
    try:
        with open(filename) as f:
            data = f.read()
    except FileNotFoundError:
        typer.echo(f"Error: File '{filename}' not found", err=True)
        raise typer.Exit(code=1)
    except PermissionError:
        typer.echo(f"Error: Permission denied for '{filename}'", err=True)
        raise typer.Exit(code=1)

2. Không validate input sớm

python
# ❌ BAD: Validate sau khi đã process
@app.command()
def process(count: int):
    # Long processing...
    time.sleep(10)
    
    if count < 1:  # Too late!
        typer.echo("Error: count must be positive")
        raise typer.Exit(1)

# ✅ GOOD: Validate ngay từ đầu
@app.command()
def process(
    count: int = typer.Option(..., min=1, max=1000)
):
    # count đã được validate
    pass

# Hoặc với callback
def validate_count(value: int) -> int:
    if value < 1:
        raise typer.BadParameter("count must be positive")
    return value

@app.command()
def process(
    count: int = typer.Option(..., callback=validate_count)
):
    pass

3. Không support stdin/stdout

python
# ❌ BAD: Chỉ support file paths
@app.command()
def process(input_file: str, output_file: str):
    with open(input_file) as f:
        data = f.read()
    with open(output_file, "w") as f:
        f.write(process_data(data))

# ✅ GOOD: Support stdin/stdout với "-"
import sys

@app.command()
def process(
    input_file: str = typer.Argument("-"),
    output_file: str = typer.Option("-", "--output", "-o"),
):
    # Read from stdin or file
    if input_file == "-":
        data = sys.stdin.read()
    else:
        with open(input_file) as f:
            data = f.read()
    
    result = process_data(data)
    
    # Write to stdout or file
    if output_file == "-":
        typer.echo(result)
    else:
        with open(output_file, "w") as f:
            f.write(result)

# Usage:
# cat input.txt | myapp process - -o output.txt
# myapp process input.txt | other_command

4. Hardcoded exit codes

python
# ❌ BAD: Magic numbers
raise typer.Exit(1)
raise typer.Exit(2)
raise typer.Exit(3)

# ✅ GOOD: Named exit codes
from enum import IntEnum

class ExitCode(IntEnum):
    SUCCESS = 0
    GENERAL_ERROR = 1
    FILE_NOT_FOUND = 2
    PERMISSION_DENIED = 3
    INVALID_INPUT = 4

raise typer.Exit(ExitCode.FILE_NOT_FOUND)

5. Không test edge cases

python
# ✅ GOOD: Test edge cases
def test_empty_input():
    result = runner.invoke(app, [""])
    assert result.exit_code != 0

def test_special_characters():
    result = runner.invoke(app, ["file with spaces.txt"])
    # Should handle properly

def test_unicode():
    result = runner.invoke(app, ["tệp_tiếng_việt.txt"])
    # Should handle Unicode

def test_very_long_input():
    result = runner.invoke(app, ["a" * 10000])
    # Should handle or reject gracefully

Quick Reference

python
# === CLICK ===
import click

@click.command()
@click.argument("name")
@click.option("--count", "-c", default=1, type=int)
@click.option("--verbose", "-v", is_flag=True)
def cmd(name, count, verbose):
    """Docstring = help text."""
    click.echo(f"Hello, {name}!")

# === TYPER ===
import typer

app = typer.Typer()

@app.command()
def cmd(
    name: str,
    count: int = typer.Option(1, "--count", "-c"),
    verbose: bool = False,
):
    """Docstring = help text."""
    typer.echo(f"Hello, {name}!")

# === TESTING ===
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["arg", "--option", "value"])
assert result.exit_code == 0
assert "expected" in result.output