Skip to content

CLI Tools — Xây dựng Ứng dụng Dòng lệnh Python

Mỗi hệ thống production đều cần một lớp vận hành tự động — deploy, migrate database, seed data, kiểm tra health check. Nếu bạn đang copy-paste lệnh từ wiki rồi chỉnh tay từng tham số, bạn đang lãng phí hàng trăm giờ kỹ sư mỗi năm.

Một đội infrastructure từng mất 45 phút mỗi lần deploy vì phải chạy 12 script riêng lẻ theo đúng thứ tự, truyền output của script trước làm input cho script sau. Sau khi xây một CLI tool duy nhất với Typer — deploy run --env staging --migrate --seed — toàn bộ quy trình còn 3 phút, có progress bar, có rollback tự động, có log structured.

Insight nhanh: CLI tool tốt không chỉ là wrapper cho script — nó là hợp đồng giao diện giữa con người và hệ thống, với validation, help text, và exit code chuẩn POSIX.

🎯 Mục tiêu

🎯 Sau bài này bạn sẽ nắm được:

  • So sánh và chọn đúng framework: argparse vs Click vs Typer
  • Xây dựng CLI application hoàn chỉnh với commands, options, progress bars
  • Cấu hình entry_points trong pyproject.toml để cài đặt CLI system-wide
  • Thiết lập auto-completion và testing cho CLI applications

Bức tranh tư duy

Hãy hình dung CLI tool như bảng điều khiển trong buồng lái máy bay. Phi công không cần biết chi tiết từng mạch điện bên trong — họ cần các nút bấm rõ ràng, phản hồi tức thì, và cảnh báo khi thao tác sai. CLI tool cũng vậy: ẩn đi complexity bên dưới, để lộ ra giao diện sạch sẽ và an toàn.

Tiến hóa của Python CLI

argparse (2011)          Click (2014)            Typer (2019)
┌──────────────┐    ┌──────────────┐     ┌──────────────────┐
│ Built-in     │    │ Decorator    │     │ Type hints =     │
│ Verbose API  │ →  │ @click.cmd() │  →  │ CLI definition   │
│ Manual setup │    │ Composable   │     │ Auto-completion  │
│ No testing   │    │ CliRunner    │     │ Rich integration │
└──────────────┘    └──────────────┘     └──────────────────┘
  stdlib, ổn định     de-facto standard    modern, Pythonic

So sánh ba framework

Tiêu chíargparseClickTyper
Cài đặtBuilt-inpip install clickpip install typer
Cú phápImperative, verboseDecorator-basedType hints
Nested commandsPhức tạp@group / @commandadd_typer()
Auto-completionTự viếtPlugin click-completion--install-completion
Testingunittest.mockCliRunner tích hợpCliRunner tích hợp
Output đẹpprint() thủ côngclick.echo()Rich integration
Khi nào dùngScript nhỏ, zero-dependencyLibrary phổ biến, plugin ecosystemProject mới, type-safe

Nguyên tắc chọn: Nếu không cần dependency ngoài → argparse. Nếu cần ecosystem plugin phong phú → Click. Nếu bắt đầu project mới và muốn code ngắn gọn nhất → Typer.

Cốt lõi kỹ thuật

argparse — Thư viện built-in

argparse nằm trong standard library, không cần cài thêm gì. Phù hợp cho script nhỏ hoặc khi tuyệt đối không muốn dependency bên ngoài.

python
import argparse
import sys
from pathlib import Path

parser = argparse.ArgumentParser(prog="datactl", description="Công cụ quản lý dữ liệu")
subparsers = parser.add_subparsers(dest="command", required=True)

export_cmd = subparsers.add_parser("export", help="Xuất dữ liệu")
export_cmd.add_argument("database", type=Path, help="Đường dẫn database")
export_cmd.add_argument("--format", "-f", choices=["csv", "json", "parquet"], default="csv")
export_cmd.add_argument("--output", "-o", type=Path, default=Path("output"))

validate_cmd = subparsers.add_parser("validate", help="Kiểm tra dữ liệu")
validate_cmd.add_argument("files", nargs="+", type=Path)
validate_cmd.add_argument("--strict", action="store_true")

args = parser.parse_args()
if args.command == "export":
    if not args.database.exists():
        print(f"Lỗi: Không tìm thấy '{args.database}'", file=sys.stderr)
        sys.exit(2)
    print(f"Xuất {args.database}{args.output}/ ({args.format})")
elif args.command == "validate":
    for fp in args.files:
        print(f"  {'' if fp.exists() else ''} {fp}")

Click — Decorator-based, composable

Click biến mỗi function thành một command thông qua decorator. Context (@click.pass_context) cho phép chia sẻ state giữa các command group.

python
import click
from pathlib import Path

@click.group()
@click.option("--verbose", "-v", is_flag=True, help="Hiển thị chi tiết")
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
    """Công cụ quản lý dữ liệu production."""
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose

@cli.command()
@click.argument("database", type=click.Path(exists=True, path_type=Path))
@click.option("--format", "-f", "fmt", type=click.Choice(["csv", "json", "parquet"]),
              default="csv", show_default=True, help="Định dạng xuất")
@click.option("--output", "-o", type=click.Path(path_type=Path), default=Path("output"))
@click.pass_context
def export(ctx: click.Context, database: Path, fmt: str, output: Path) -> None:
    """Xuất dữ liệu từ DATABASE ra file."""
    output.mkdir(parents=True, exist_ok=True)
    dest = output / f"data.{fmt}"
    if ctx.obj["verbose"]:
        click.echo(f"Đọc từ: {database} → Ghi ra: {dest}")
    click.echo(f"✓ Xuất thành công → {dest}")

@cli.command()
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
@click.option("--strict", is_flag=True, help="Chế độ nghiêm ngặt")
def validate(files: tuple[Path, ...], strict: bool) -> None:
    """Kiểm tra tính hợp lệ của FILES."""
    errors = 0
    for fp in files:
        if strict and fp.stat().st_size == 0:
            click.echo(f"  ✗ {fp} — file rỗng", err=True)
            errors += 1
        else:
            click.echo(f"  ✓ {fp} ({fp.stat().st_size:,} bytes)")
    if errors:
        raise SystemExit(1)

if __name__ == "__main__":
    cli()

Typer — Modern, type-hint-based

Typer xây dựng trên Click nhưng dùng type hints làm nguồn sự thật duy nhất. Khai báo kiểu Python — Typer tự suy ra argument, option, validation.

python
import typer
from pathlib import Path
from enum import Enum

app = typer.Typer(help="Công cụ quản lý dữ liệu production")
db_app = typer.Typer(help="Thao tác database")
app.add_typer(db_app, name="db")

class ExportFormat(str, Enum):
    csv = "csv"
    json = "json"
    parquet = "parquet"

@app.command()
def export(
    database: Path = typer.Argument(..., help="Đường dẫn database", exists=True),
    fmt: ExportFormat = typer.Option(ExportFormat.csv, "--format", "-f", help="Định dạng xuất"),
    output: Path = typer.Option(Path("output"), "--output", "-o", help="Thư mục đích"),
    verbose: bool = typer.Option(False, "--verbose", "-v", help="Hiển thị chi tiết"),
) -> None:
    """Xuất dữ liệu từ DATABASE ra file."""
    output.mkdir(parents=True, exist_ok=True)
    if verbose:
        typer.echo(f"Đọc từ: {database}")
    typer.echo(f"✓ Xuất thành công → {output / f'data.{fmt.value}'}")

@db_app.command()
def migrate(
    revision: str = typer.Argument("head", help="Migration revision"),
    dry_run: bool = typer.Option(False, "--dry-run", help="Chỉ hiển thị, không thực thi"),
) -> None:
    """Chạy database migration đến REVISION."""
    typer.echo(f"{'Kiểm tra' if dry_run else 'Áp dụng'} migration → {revision}")

@db_app.command()
def seed(
    count: int = typer.Option(100, "--count", "-n", min=1, max=10000, help="Số bản ghi"),
) -> None:
    """Tạo dữ liệu mẫu cho database."""
    from rich.progress import track
    for _ in track(range(count), description="Seeding..."):
        pass  # Giả lập insert
    typer.echo(f"✓ Đã tạo {count:,} bản ghi")

if __name__ == "__main__":
    app()

Entry points trong pyproject.toml

Entry points biến module Python thành lệnh hệ thống. Sau pip install, người dùng gõ tên lệnh trực tiếp — không cần python -m.

toml
[project]
name = "datactl"
version = "1.0.0"
dependencies = ["typer>=0.9.0", "rich>=13.0.0"]

[project.scripts]
datactl = "datactl.cli:app"
python
# src/datactl/cli.py
import typer

app = typer.Typer()

@app.command()
def version() -> None:
    """Hiển thị phiên bản."""
    typer.echo("datactl v1.0.0")

# $ pip install -e . → $ datactl version → datactl v1.0.0

Thiết lập auto-completion

python
# Typer — tích hợp sẵn, user chạy một lần:
# $ datactl --install-completion
# $ datactl --show-completion  # Kiểm tra script

# Click — generate thủ công:
# $ _DATACTL_COMPLETE=bash_source datactl > ~/.datactl-complete.bash
# $ echo '. ~/.datactl-complete.bash' >> ~/.bashrc

# Custom completion cho giá trị động
import typer

def complete_environment(incomplete: str) -> list[str]:
    """Gợi ý environment names khi user nhấn Tab."""
    environments = ["development", "staging", "production"]
    return [env for env in environments if env.startswith(incomplete)]

@app.command()
def deploy(
    env: str = typer.Argument(..., autocompletion=complete_environment),
) -> None:
    """Deploy ứng dụng lên ENV."""
    typer.echo(f"Deploying → {env}")

Testing CLI applications

python
from typer.testing import CliRunner
from datactl.cli import app

runner = CliRunner()

def test_export_csv_default() -> None:
    result = runner.invoke(app, ["export", "test.db"])
    assert result.exit_code == 0
    assert "Xuất thành công" in result.output

def test_export_invalid_format() -> None:
    result = runner.invoke(app, ["export", "test.db", "--format", "xlsx"])
    assert result.exit_code != 0

def test_help_text_displayed() -> None:
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    assert "Công cụ quản lý dữ liệu" in result.output

def test_db_migrate_dry_run() -> None:
    result = runner.invoke(app, ["db", "migrate", "--dry-run"])
    assert result.exit_code == 0
    assert "Kiểm tra" in result.output

def test_isolated_filesystem() -> None:
    """Test với file system cách ly."""
    from click.testing import CliRunner as ClickRunner
    click_runner = ClickRunner()
    with click_runner.isolated_filesystem():
        Path("test.db").write_text("data")
        result = runner.invoke(app, ["export", "test.db"])
        assert result.exit_code == 0

Thực chiến

Scenario: CLI tool quản lý deployment

Đội platform cần một công cụ deploy thống nhất — thay vì 5 script rời rạc, một CLI duy nhất xử lý toàn bộ quy trình: check → build → deploy → verify.

python
# deploy_cli.py
import typer
import time
from pathlib import Path
from enum import Enum, IntEnum
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table

app = typer.Typer(name="deploy", help="Công cụ deployment production", rich_markup_mode="rich")
console = Console()

class Environment(str, Enum):
    development = "development"
    staging = "staging"
    production = "production"

class ExitCode(IntEnum):
    SUCCESS = 0
    GENERAL_ERROR = 1
    PRECONDITION_FAILED = 2

@app.command()
def run(
    env: Environment = typer.Argument(..., help="Môi trường deploy"),
    version_tag: str = typer.Option(..., "--tag", "-t", help="Version tag (v1.2.3)"),
    migrate: bool = typer.Option(False, "--migrate", "-m", help="Chạy database migration"),
    skip_tests: bool = typer.Option(False, "--skip-tests", help="Bỏ qua test suite"),
    dry_run: bool = typer.Option(False, "--dry-run", help="Chỉ hiển thị kế hoạch"),
) -> None:
    """Deploy ứng dụng lên [bold]ENV[/bold] với version [bold]TAG[/bold]."""
    console.print(f"\n[bold]🚀 Deploy {version_tag}{env.value}[/bold]\n")

    if env == Environment.production and skip_tests:
        console.print("  [red]✗[/red] Không được skip tests khi deploy production")
        raise typer.Exit(ExitCode.PRECONDITION_FAILED)

    if dry_run:
        console.print("[yellow]DRY RUN[/yellow]")
        console.print(f"  Môi trường: {env.value}  |  Version: {version_tag}")
        console.print(f"  Migration: {'' if migrate else 'Không'}")
        raise typer.Exit(ExitCode.SUCCESS)

    steps = ["Chạy test suite", "Build artifacts", "Deploy containers", "Health check"]
    if migrate:
        steps.insert(2, "Database migration")

    with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as progress:
        for step in steps:
            task = progress.add_task(step, total=None)
            time.sleep(0.8)
            progress.update(task, completed=True)

    console.print(f"\n[bold green]✓ Deploy {version_tag}{env.value} thành công[/bold green]")

@app.command()
def status(env: Environment = typer.Argument(..., help="Môi trường cần kiểm tra")) -> None:
    """Kiểm tra trạng thái deployment hiện tại."""
    table = Table(title=f"Status: {env.value}")
    table.add_column("Service", style="cyan")
    table.add_column("Version", style="magenta")
    table.add_column("Status", style="green")
    table.add_row("api-gateway", "v2.1.0", "✓ Running")
    table.add_row("worker", "v2.1.0", "✓ Running")
    table.add_row("scheduler", "v2.0.9", "⚠ Outdated")
    console.print(table)

@app.command()
def rollback(
    env: Environment = typer.Argument(..., help="Môi trường rollback"),
    steps: int = typer.Option(1, "--steps", "-n", min=1, max=5, help="Số version lùi lại"),
    force: bool = typer.Option(False, "--force", help="Không cần xác nhận"),
) -> None:
    """Rollback về version trước đó."""
    if env == Environment.production and not force:
        typer.confirm(f"Rollback {env.value} lùi {steps} version?", abort=True)
    console.print(f"[yellow]↩ Rollback {env.value} lùi {steps} version...[/yellow]")
    console.print("[green]✓ Rollback thành công[/green]")

if __name__ == "__main__":
    app()

Entry points:

toml
[project.scripts]
deploy = "deploy_cli:app"

Sai lầm điển hình

1. Không dùng entry_points — chạy bằng python script.py

python
# ❌ SAI: Yêu cầu user biết đường dẫn file
# $ python /opt/tools/scripts/deploy.py --env staging
# $ python -m mypackage.cli run staging

# ✅ ĐÚNG: Khai báo entry_points trong pyproject.toml
# pyproject.toml:
# [project.scripts]
# deploy = "mypackage.cli:app"

# Sau pip install:
# $ deploy run staging --tag v2.1.0

Entry points tạo ra executable wrapper tự động — hoạt động trên mọi OS, tự xử lý Python path.

2. Error message mơ hồ, thiếu context

python
# ❌ SAI: User không biết sai ở đâu
@app.command()
def process(filename: str) -> None:
    with open(filename) as f:  # FileNotFoundError traceback dài 20 dòng
        data = f.read()

# ✅ ĐÚNG: Error message rõ ràng, exit code chuẩn
@app.command()
def process(
    filename: Path = typer.Argument(..., exists=True, readable=True),
) -> None:
    """Typer tự validate file tồn tại và có quyền đọc."""
    data = filename.read_text()

# Hoặc handle thủ công khi cần message tùy chỉnh:
@app.command()
def process(filename: str) -> None:
    path = Path(filename)
    if not path.exists():
        typer.echo(f"Lỗi: Không tìm thấy '{filename}'", err=True)
        typer.echo(f"Gợi ý: Kiểm tra đường dẫn hoặc chạy 'ls' để xem file có sẵn", err=True)
        raise typer.Exit(code=2)
    data = path.read_text()

3. Không viết help text cho commands và options

python
# ❌ SAI: --help hiển thị thông tin vô nghĩa
@app.command()
def run(env: str, tag: str, m: bool = False, s: bool = False) -> None:
    pass
# Usage: run [OPTIONS] ENV TAG
# — Không ai hiểu m và s là gì

# ✅ ĐÚNG: Mọi parameter đều có mô tả
@app.command()
def run(
    env: str = typer.Argument(..., help="Môi trường: development|staging|production"),
    tag: str = typer.Option(..., "--tag", "-t", help="Version tag (ví dụ: v1.2.3)"),
    migrate: bool = typer.Option(False, "--migrate", "-m", help="Chạy database migration sau deploy"),
    skip_tests: bool = typer.Option(False, "--skip-tests", help="Bỏ qua test suite (chỉ cho dev)"),
) -> None:
    """Deploy ứng dụng lên môi trường chỉ định."""
    pass

4. Hardcode đường dẫn và giá trị cấu hình

python
# ❌ SAI: Hardcode path — chỉ chạy trên máy tác giả
@app.command()
def backup() -> None:
    shutil.copy("/home/dev/app/data.db", "/tmp/backup.db")

# ✅ ĐÚNG: Dùng environment variable + option với giá trị mặc định hợp lý
import os

@app.command()
def backup(
    source: Path = typer.Option(
        Path(os.getenv("APP_DB_PATH", "data.db")),
        "--source", "-s",
        help="Đường dẫn database nguồn",
    ),
    dest: Path = typer.Option(
        Path(os.getenv("BACKUP_DIR", "/tmp")) / "backup.db",
        "--dest", "-d",
        help="Đường dẫn file backup",
    ),
) -> None:
    """Sao lưu database. Cấu hình qua APP_DB_PATH và BACKUP_DIR."""
    import shutil
    shutil.copy2(source, dest)
    typer.echo(f"✓ Backup: {source}{dest}")

5. Không test CLI — chỉ test logic bên trong

python
# ❌ SAI: Test function thuần nhưng không test CLI interface
def test_process_logic():
    assert process_data("input") == "output"  # Logic đúng...
# ...nhưng CLI parse sai argument, exit code sai, help text thiếu

# ✅ ĐÚNG: Test qua CliRunner — test cả interface lẫn logic
from typer.testing import CliRunner

runner = CliRunner()

def test_process_success() -> None:
    result = runner.invoke(app, ["process", "input.txt"])
    assert result.exit_code == 0
    assert "thành công" in result.output

def test_process_missing_file() -> None:
    result = runner.invoke(app, ["process", "nonexistent.txt"])
    assert result.exit_code == 2
    assert "Không tìm thấy" in result.output

def test_help_shows_all_commands() -> None:
    result = runner.invoke(app, ["--help"])
    assert "process" in result.output
    assert "export" in result.output

Under the Hood

Click decorator chain — cơ chế hoạt động

Khi bạn viết @click.command(), Click không gọi function ngay. Nó tạo Command object, wrap function gốc, đăng ký metadata (options, arguments). Khi CLI được invoke, Click parse sys.argv theo metadata, validate, rồi mới gọi function với đúng tham số.

Typer thêm một layer: đọc type hints từ function signature → tự tạo Click Option/Argument tương ứng (Enum → click.Choice, Path → click.Path, bool → is_flag) → delegate toàn bộ parsing cho Click.

Entry points — từ pyproject.toml đến terminal

Khi khai báo [project.scripts] datactl = "datactl.cli:app", pip install tạo wrapper script (Linux: ~/.local/bin/datactl, Windows: Scripts\datactl.exe). Wrapper chỉ chứa: from datactl.cli import app; app(). Cơ chế: pip đọc entry_points metadata, generate executable trỏ đến function chỉ định.

Shell completion — cách auto-complete hoạt động

Khi user nhấn Tab: shell gọi completion script → set biến môi trường đặc biệt (VD: _DATACTL_COMPLETE=bash_complete) → chạy lại CLI app → Click/Typer detect biến → parse partial input → trả danh sách gợi ý thay vì chạy command.

Bảng so sánh trade-offs

Khía cạnhargparseClickTyper
Ưu điểmZero dependencyEcosystem plugin phong phúCode ngắn, type-safe
Nhược điểmVerbose, khó composeDecorator nesting sâuPhụ thuộc Click + Rich
Nested commandsSubParser phức tạp@group tự nhiênadd_typer() gọn
TestingMock sys.argvCliRunner tích hợpCliRunner tích hợp
Rich outputTự implementclick.echoRich tích hợp sẵn
Phù hợpScript đơn, zero-depLibrary mở rộngApp mới, team-based

Checklist ghi nhớ

✅ Checklist triển khai

Thiết kế CLI

  • [ ] Mỗi command làm đúng MỘT việc — tách command thay vì nhồi flag
  • [ ] Mọi option/argument đều có help text mô tả rõ ràng
  • [ ] Dùng Enum cho giá trị hữu hạn (--format csv|json) thay vì string tự do
  • [ ] Exit code chuẩn POSIX: 0 = success, 1 = general error, 2 = usage error
  • [ ] Hỗ trợ --version--help ở mọi level (app + subcommand)

Entry points & Distribution

  • [ ] Khai báo [project.scripts] trong pyproject.toml
  • [ ] Test entry point sau pip install -e . — gõ tên lệnh trực tiếp
  • [ ] Đặt tên lệnh ngắn, dễ nhớ, không trùng tool phổ biến (tránh test, run, build)

Error Handling

  • [ ] Catch exception cụ thể → error message thân thiện + exit code phù hợp
  • [ ] Ghi stderr cho error (err=True), stdout cho output chính
  • [ ] Validate input sớm — trước khi bắt đầu xử lý nặng
  • [ ] Confirm trước thao tác nguy hiểm (delete, deploy production)

Auto-completion & UX

  • [ ] Cài --install-completion (Typer) hoặc generate completion script (Click)
  • [ ] Custom completion cho giá trị động (environment names, file names)
  • [ ] Progress bar cho tác vụ lâu — user cần biết tool đang chạy, không phải bị treo

Testing

  • [ ] Dùng CliRunner test cả interface lẫn logic
  • [ ] Test exit code, output text, error messages
  • [ ] Test edge cases: input rỗng, file không tồn tại, Unicode, path có space
  • [ ] Test --help hiển thị đầy đủ mô tả

Bài tập luyện tập

Bài 1: CLI tool quản lý TODO list — Foundation

Đề bài: Xây dựng CLI todo với 3 commands:

  • todo add "Nội dung task" — thêm task mới
  • todo list — liệt kê tất cả task (hiển thị ID, nội dung, trạng thái)
  • todo done <id> — đánh dấu task hoàn thành

Yêu cầu: dùng Typer, lưu data vào JSON file, có --file option để chọn file lưu trữ.

🧠 Quiz

Câu hỏi kiểm tra: Trong Typer, cách nào đúng để khai báo argument bắt buộc kiểu Path với validation file phải tồn tại?

  • [ ] A. file: Path — Typer tự validate
  • [ ] B. file: Path = typer.Argument(exists=True)
  • [x] C. file: Path = typer.Argument(..., exists=True)
  • [ ] D. file: str = typer.Option(..., type=click.Path(exists=True))Giải thích: typer.Argument(...) với ... (Ellipsis) đánh dấu argument là bắt buộc. Parameter exists=True yêu cầu Typer validate file phải tồn tại trước khi gọi function. Option A thiếu validation, B thiếu ... marker, D dùng Option thay vì Argument.
💡 Gợi ý
  • Tạo typer.Typer() app với 3 @app.command()
  • Lưu tasks dạng list[dict] trong JSON file
  • Dùng typer.Option(Path("todos.json"), "--file") cho file path
  • Mỗi task: {"id": 1, "content": "...", "done": False}
✅ Lời giải
python
import typer
import json
from pathlib import Path

app = typer.Typer(help="Quản lý TODO list")

DEFAULT_FILE = Path("todos.json")


def load_tasks(file: Path) -> list[dict]:
    if not file.exists():
        return []
    return json.loads(file.read_text(encoding="utf-8"))


def save_tasks(file: Path, tasks: list[dict]) -> None:
    file.write_text(json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8")


@app.command()
def add(
    content: str = typer.Argument(..., help="Nội dung task"),
    file: Path = typer.Option(DEFAULT_FILE, "--file", "-f", help="File lưu trữ"),
) -> None:
    """Thêm task mới."""
    tasks = load_tasks(file)
    new_id = max((t["id"] for t in tasks), default=0) + 1
    tasks.append({"id": new_id, "content": content, "done": False})
    save_tasks(file, tasks)
    typer.echo(f"✓ Thêm task #{new_id}: {content}")


@app.command("list")
def list_tasks(
    file: Path = typer.Option(DEFAULT_FILE, "--file", "-f", help="File lưu trữ"),
) -> None:
    """Liệt kê tất cả tasks."""
    tasks = load_tasks(file)
    if not tasks:
        typer.echo("Chưa có task nào.")
        return
    for task in tasks:
        status = "" if task["done"] else ""
        typer.echo(f"  {status} #{task['id']} {task['content']}")


@app.command()
def done(
    task_id: int = typer.Argument(..., help="ID của task cần hoàn thành"),
    file: Path = typer.Option(DEFAULT_FILE, "--file", "-f", help="File lưu trữ"),
) -> None:
    """Đánh dấu task hoàn thành."""
    tasks = load_tasks(file)
    for task in tasks:
        if task["id"] == task_id:
            task["done"] = True
            save_tasks(file, tasks)
            typer.echo(f"✓ Task #{task_id} đã hoàn thành")
            return
    typer.echo(f"Lỗi: Không tìm thấy task #{task_id}", err=True)
    raise typer.Exit(code=1)


if __name__ == "__main__":
    app()

Phân tích: Mỗi command đúng single responsibility. JSON file làm storage đơn giản. Exit code 1 khi task không tìm thấy.

Bài 2: Viết test suite cho deployment CLI — Intermediate

Đề bài: Viết ít nhất 5 test cases cho deployment CLI ở phần Thực chiến. Bao gồm: test run thành công, test dry-run, test rollback cần confirm, test exit code khi precondition fail, test --help.

🧠 Quiz

Câu hỏi kiểm tra: Khi test CLI command có typer.confirm(), cách nào đúng để simulate user nhập "y"?

  • [ ] A. runner.invoke(app, ["rollback", "staging"], input="y")
  • [x] B. runner.invoke(app, ["rollback", "staging"], input="y\n")
  • [ ] C. runner.invoke(app, ["rollback", "staging", "--yes"])
  • [ ] D. Không thể test — phải mock typer.confirmGiải thích: CliRunner.invoke() nhận parameter input để simulate stdin. Cần \n (newline) để simulate nhấn Enter sau khi gõ "y". Option A thiếu newline nên confirm không nhận input. Option C chỉ đúng nếu command đã implement flag --yes. Option D sai vì CliRunner hỗ trợ stdin simulation.
💡 Gợi ý
  • runner.invoke(app, ["run", "staging", "--tag", "v1.0", "--dry-run"])
  • Test result.exit_coderesult.output cho mỗi scenario
  • Dùng input="y\n" để simulate confirm prompt
  • Test precondition: deploy production với --skip-tests
✅ Lời giải
python
from typer.testing import CliRunner
from deploy_cli import app

runner = CliRunner()


def test_run_dry_run_shows_plan() -> None:
    result = runner.invoke(app, ["run", "staging", "--tag", "v1.0.0", "--dry-run"])
    assert result.exit_code == 0
    assert "DRY RUN" in result.output
    assert "staging" in result.output
    assert "v1.0.0" in result.output


def test_run_production_skip_tests_rejected() -> None:
    result = runner.invoke(app, ["run", "production", "--tag", "v1.0.0", "--skip-tests"])
    assert result.exit_code == 2  # PRECONDITION_FAILED


def test_status_displays_table() -> None:
    result = runner.invoke(app, ["status", "staging"])
    assert result.exit_code == 0
    assert "api-gateway" in result.output


def test_rollback_confirm_abort() -> None:
    result = runner.invoke(app, ["rollback", "production"], input="n\n")
    assert result.exit_code != 0  # Aborted


def test_help_lists_all_commands() -> None:
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    assert "run" in result.output
    assert "status" in result.output
    assert "rollback" in result.output

Phân tích: 5 tests bao phủ happy path (dry-run), validation (precondition), output (status table), user interaction (confirm), và discoverability (help). Time: O(1) cho mỗi test — không I/O thật.

Bài 3: Thêm auto-completion động — Advanced

Đề bài: Mở rộng deployment CLI: command deploy run có auto-completion cho argument env dựa trên file environments.json. Viết completion function và test nó.

✅ Lời giải
python
import json
from pathlib import Path

def complete_environment(incomplete: str) -> list[str]:
    """Auto-complete environment names từ cấu hình."""
    config = Path("environments.json")
    default = ["development", "staging", "production"]
    try:
        envs = json.loads(config.read_text())
        names = [e["name"] for e in envs if isinstance(e, dict) and "name" in e]
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        names = default
    return [n for n in names if n.startswith(incomplete)]

def test_complete_partial() -> None:
    assert "staging" in complete_environment("st")
    assert "production" not in complete_environment("st")

def test_complete_empty() -> None:
    assert len(complete_environment("")) >= 3

Liên kết học tiếp

Từ khóa glossary: CLI, command line interface, Click, Typer, argparse, entry_points, auto-completion, shell completion, CliRunner, subcommand, exit code, pyproject.toml, Rich, progress bar

Tìm kiếm liên quan: xây dựng CLI Python, Click vs Typer, entry points pyproject, auto-completion shell, test CLI application, deployment tool Python