Giao diện
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, PythonicSo sánh ba framework
| Tiêu chí | argparse | Click | Typer |
|---|---|---|---|
| Cài đặt | Built-in | pip install click | pip install typer |
| Cú pháp | Imperative, verbose | Decorator-based | Type hints |
| Nested commands | Phức tạp | @group / @command | add_typer() |
| Auto-completion | Tự viết | Plugin click-completion | --install-completion |
| Testing | unittest.mock | CliRunner tích hợp | CliRunner tích hợp |
| Output đẹp | print() thủ công | click.echo() | Rich integration |
| Khi nào dùng | Script nhỏ, zero-dependency | Library phổ biến, plugin ecosystem | Project 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.0Thiế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 == 0Thự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: {'Có' 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.0Entry 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."""
pass4. 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.outputUnder 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ạnh | argparse | Click | Typer |
|---|---|---|---|
| Ưu điểm | Zero dependency | Ecosystem plugin phong phú | Code ngắn, type-safe |
| Nhược điểm | Verbose, khó compose | Decorator nesting sâu | Phụ thuộc Click + Rich |
| Nested commands | SubParser phức tạp | @group tự nhiên | add_typer() gọn |
| Testing | Mock sys.argv | CliRunner tích hợp | CliRunner tích hợp |
| Rich output | Tự implement | click.echo | Rich tích hợp sẵn |
| Phù hợp | Script đơn, zero-dep | Library mở rộng | App 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ó
helptext 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ợ
--versionvà--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
CliRunnertest 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
--helphiể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ớitodo 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. Parameterexists=Trueyê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ùngOptionthay 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 parameterinputđể 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_codevàresult.outputcho 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.outputPhâ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("")) >= 3Liê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