Giao diện
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
| Feature | argparse | Click | Typer |
|---|---|---|---|
| Built-in | ✅ | ❌ | ❌ |
| Type hints | ❌ | ❌ | ✅ |
| Decorator-based | ❌ | ✅ | ✅ |
| Auto-completion | Manual | Plugin | Built-in |
| Nested commands | Complex | Easy | Easy |
| Testing | Manual | Built-in | Built-in |
| Learning curve | Medium | Low | Very Low |
| Dependencies | None | Few | Click + 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 clickBasic 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 optionsOption 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: myitemNested 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 createdTyper Deep Dive
Installation
bash
pip install typer[all] # Includes rich for pretty output
# or
pip install typerBasic 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.fishTyper 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-completionCustom 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]
"""
passCLI 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 != 0Testing 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.outputTesting 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 == 0Production 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)
):
pass3. 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_command4. 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 gracefullyQuick 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.outputCross-links
- Prerequisites: Type Hinting - Type hints cho Typer
- Prerequisites: pyproject.toml - Entry points configuration
- Next: Distribution & Publishing - Publish CLI to PyPI
- Related: Pytest Fundamentals - Testing CLI applications