Xây Dựng CLI Tool Chuyên Nghiệp với Python Click và Rich: Từ Script Đơn Giản Đến Ứng Dụng Dòng Lệnh Hoàn Chỉnh

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Ba Cách Mọi Người Xây Dựng CLI Tool trong Python (Và Tại Sao Hầu Hết Chọn Sai)

Khi bạn cần biến một script Python thành một công cụ dòng lệnh thực sự, có ba con đường thường xuất hiện: tự parse sys.argv bằng tay, dùng argparse trong thư viện chuẩn, hoặc các thư viện bên thứ ba như Click. Tôi đã dùng cả ba qua hàng chục công cụ nội bộ. Sự lựa chọn ảnh hưởng nhiều hơn bạn nghĩ.

Yếu tố thứ tư — làm cho CLI trông đẹp — thường bị bỏ qua hoàn toàn. Đó là lúc Rich phát huy tác dụng. Kết hợp lại, Click và Rich xử lý cả hành vi lẫn giao diện. Khoảng cách giữa một công cụ mà đồng nghiệp dùng hàng ngày và một cái họ âm thầm thay bằng shell alias thường nằm ở chỗ nó báo cáo tiến trình và lỗi rõ ràng đến đâu.

So Sánh Các Cách Tiếp Cận: sys.argv vs argparse vs Click

Raw sys.argv

Đây là cách tiếp cận “cứ ship thôi”. Bạn lấy sys.argv[1:], parse thủ công rồi xong việc.

import sys

def main():
    if len(sys.argv) < 2:
        print("Cách dùng: tool.py <tên_file>")
        sys.exit(1)
    filename = sys.argv[1]
    process(filename)

Ổn với một tham số vị trí duy nhất trong script dùng một lần. Nhưng thêm flag tùy chọn, nhiều subcommand, hay validation kiểu dữ liệu vào là nó trở nên cực nhọc ngay.

argparse (stdlib)

argparse đã là câu trả lời mặc định nhiều năm nay. Nó xử lý flag, tham số vị trí, tự sinh help text và ép kiểu cơ bản — không cần cài thêm gì.

import argparse

parser = argparse.ArgumentParser(description="Xử lý file")
parser.add_argument("filename", help="File đầu vào")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()

Với các tool nhỏ, argparse làm được việc. Nhưng khi bạn thêm nhiều subcommand, nhóm lồng nhau, callback hay test, boilerplate chồng chất và code ngày càng khó theo dõi.

Click

Click dùng decorator Python để định nghĩa giao diện CLI trực tiếp trên hàm của bạn. Kết quả đọc rất tự nhiên và dễ bảo trì khi tool lớn dần.

import click

@click.command()
@click.argument("filename")
@click.option("--verbose", is_flag=True, help="Bật chế độ chi tiết")
def process(filename, verbose):
    """Xử lý một file và báo cáo kết quả."""
    if verbose:
        click.echo(f"Đang xử lý: {filename}")

Cùng chức năng, nhưng ý định rõ ràng ngay lập tức. Mỗi tham số được ghi chú ngay tại nơi khai báo. Test cũng gọn hơn — Click đi kèm sẵn test runner tích hợp tên CliRunner.

Ưu và Nhược Điểm Cần Biết Trước Khi Quyết Định

Ưu & Nhược Điểm của argparse

  • Ưu: Không phụ thuộc bên ngoài — đi kèm sẵn với Python
  • Ưu: Ổn định, tài liệu tốt, được hiểu rộng rãi
  • Nhược: Subcommand yêu cầu thiết lập dài dòng với subparsers
  • Nhược: Test phải mock sys.argv hoặc chạy subprocess
  • Nhược: Không có màu sắc hay output có định dạng sẵn

Ưu & Nhược Điểm của Click

  • Ưu: Dùng decorator — giao diện nằm ngay cạnh logic
  • Ưu: Subcommand là công dân hạng nhất qua @click.group()
  • Ưu: Có sẵn test utilities (CliRunner)
  • Ưu: Có thể ghép lại — các command có thể tập hợp từ nhiều module riêng
  • Nhược: Phụ thuộc bên ngoài (dù rất nhỏ và ổn định)
  • Nhược: Một số trường hợp parse tham số khác với quy ước argparse

Ưu & Nhược Điểm của Rich

  • Ưu: Table, progress bar, syntax highlighting, panel — tất cả đều có sẵn
  • Ưu: Tự động phát hiện terminal (không rò màu vào output khi pipe)
  • Ưu: Kết hợp tốt với Click — không xung đột
  • Nhược: Thêm ~2MB vào phụ thuộc của bạn
  • Nhược: Quá mức cần thiết cho script chạy một lần rồi quên

Khuyến nghị của tôi: dùng argparse cho script nội bộ nhỏ mà một người chạy thỉnh thoảng. Dùng Click + Rich cho bất cứ thứ gì chia sẻ với nhóm hoặc đóng gói để phân phối. Các tool như httpie, blackpoetry đều được xây trên Click — không phải ngẫu nhiên, chúng cũng là những công cụ mà người dùng thực sự thích dùng. Output tiến trình rõ ràng và thông báo lỗi dễ đọc chính là thứ phân biệt công cụ người ta tin tưởng với công cụ người ta tìm cách né.

Thiết Lập Được Khuyến Nghị

Cài Đặt Phụ Thuộc

pip install click rich

Cấu Trúc Project

Giữ tầng CLI mỏng. Logic nghiệp vụ nên nằm trong module, không phải bên trong decorator Click.

mytool/
├── cli.py          ← Click commands
├── core.py         ← Logic thực sự
├── __init__.py
pyproject.toml       ← Cấu hình entry point

Entry Point trong pyproject.toml

[project.scripts]
mytool = "mytool.cli:main"

Sau khi chạy pip install -e ., người dùng có thể gõ mytool trực tiếp từ bất kỳ thư mục nào — không cần tiền tố python -m.

Hướng Dẫn Triển Khai: Xây Dựng CLI Tool Thực Tế

Bước 1 — Command Group Cơ Bản

Bắt đầu với một group để hỗ trợ nhiều subcommand ngay từ đầu. Thêm vào sau — khi các command đã được ship — đau đầu hơn bạn tưởng nhiều.

import click
from rich.console import Console

console = Console()

@click.group()
@click.version_option(version="1.0.0")
def main():
    """MyTool — tiện ích xử lý file."""
    pass

@main.command()
@click.argument("filepath", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["json", "csv", "table"]), default="table")
@click.option("--verbose", "-v", is_flag=True)
def analyze(filepath, format, verbose):
    """Phân tích một file và hiển thị kết quả."""
    if verbose:
        console.print(f"[dim]Đang đọc: {filepath}[/dim]")
    # ... gọi logic xử lý chính ở đây

Bước 2 — Rich Table cho Output Có Cấu Trúc

print() bình thường cũng được, nhưng table giúp kết quả có thể đọc lướt ngay lập tức. Rich table hỗ trợ màu sắc, căn chỉnh và highlight dòng — không cần boilerplate.

from rich.table import Table

def display_results(records):
    table = Table(title="Kết Quả Phân Tích", show_lines=True)
    table.add_column("File", style="cyan", no_wrap=True)
    table.add_column("Kích thước", justify="right")
    table.add_column("Trạng thái", justify="center")

    for r in records:
        status_color = "green" if r["ok"] else "red"
        table.add_row(
            r["name"],
            f"{r['size']:,} bytes",
            f"[{status_color}]{r['status']}[/{status_color}]"
        )

    console.print(table)

Bước 3 — Progress Bar cho Thao Tác Chạy Lâu

Bất cứ thứ gì mất hơn nửa giây đều cần phản hồi nhìn thấy được. Progress bar của Rich tích hợp gọn với vòng lặp và xử lý nhiều tác vụ đồng thời mà không cần thêm công việc threading.

from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn

def process_files(files):
    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        TextColumn("{task.completed}/{task.total}"),
    ) as progress:
        task = progress.add_task("Đang xử lý các file...", total=len(files))
        for f in files:
            process_single(f)
            progress.advance(task)

Bước 4 — Xử Lý Lỗi Nhất Quán

Raise ClickException sẽ in thông báo lỗi đã được định dạng và thoát với mã khác 0. Kết hợp với markup của Rich để lỗi nổi bật về mặt thị giác.

@main.command()
@click.argument("config", type=click.Path())
def run(config):
    """Chạy với file cấu hình."""
    try:
        settings = load_config(config)
    except FileNotFoundError:
        console.print(f"[bold red]Lỗi:[/bold red] Không tìm thấy file cấu hình: {config}")
        raise click.Abort()
    except ValueError as e:
        raise click.ClickException(str(e))

Bước 5 — Test với CliRunner

Test runner tích hợp của Click cho phép bạn gọi command theo chương trình. Không có overhead subprocess, không mock sys.argv — chỉ cần gọi invoke() và kiểm tra kết quả. Test chạy nhanh và độc lập — cách tiếp cận tương tự TDD mà bạn áp dụng cho bất kỳ module Python nào.

from click.testing import CliRunner
from mytool.cli import main

def test_analyze_command():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open("sample.txt", "w") as f:
            f.write("nội dung test")
        result = runner.invoke(main, ["analyze", "sample.txt"])
        assert result.exit_code == 0
        assert "Kết Quả" in result.output

Những Pattern Nhỏ Tạo Ra Sự Khác Biệt Lớn

  • Dùng click.Path(exists=True) thay vì tham số string thô — Click xác thực đường dẫn trước khi code của bạn chạy và tự động hiển thị lỗi rõ ràng.
  • Thêm --dry-run vào các command có hành động phá hủy ngay từ đầu. Người dùng tin tưởng tool hơn khi họ có thể xem trước điều gì sẽ xảy ra trước khi xác nhận.
  • Dùng console.print() để hiển thị, click.echo() cho output dạng máy đọc có thể được pipe. Console của Rich tự phân tách đúng stderr khỏi stdout.
  • Giữ text --help ngắn gọn và tập trung vào hành động. “Phân tích một file” tốt hơn “Lệnh này dùng để phân tích file.” Click hiển thị docstring trực tiếp — viết cho người dùng, không phải cho trình tạo tài liệu.

Khi Nào Không Nên Dùng Click

Nếu script của bạn chạy một lần bên trong CI pipeline và output luôn được pipe sang tool khác, tính tương tác của Click không mang lại giá trị gì. Một script argparse đơn giản với --output json là lựa chọn đúng trong trường hợp đó. Click xứng đáng được dùng khi có con người ở đầu kia của terminal.

Cần TUI toàn màn hình với điều hướng bàn phím? Hãy xem Textual — được xây dựng bởi cùng nhóm tác giả Rich, thiết kế đặc biệt cho trường hợp đó.

Share: