Xây dựng CLI hiện đại với Typer Python: Type Hints, Auto-completion và Validation tự động

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Tại sao argparse trở thành gánh nặng khi project lớn lên

Dự án automation của mình ban đầu chỉ 200 dòng, chủ yếu là vài lệnh cơ bản với argparse. Nhưng sau vài tháng, khi code phình lên gần 2000 dòng, phần quản lý CLI một mình chiếm gần 300 dòng — toàn là add_argument, type=int, help=... lặp đi lặp lại. Mỗi lần thêm lệnh mới là phải kéo xuống tìm đúng chỗ, sợ làm hỏng những lệnh cũ.

argparse không sai, nhưng nó sinh ra từ thời Python chưa có type hints. click thì tốt hơn, nhưng vẫn phải khai báo decorator riêng cho từng tham số. Rồi Typer ra đời — và thay đổi hoàn toàn cách mình viết CLI.

Typer là gì và điểm khác biệt so với argparse/click

Typer là thư viện CLI xây dựng trên nền Click, nhưng thay vì dùng decorator để khai báo tham số, nó đọc thẳng từ type hints của function Python. Điều này có nghĩa là:

  • Bạn viết function Python bình thường với type hints
  • Typer tự động tạo interface CLI, validate dữ liệu, và generate help text
  • Auto-completion hoạt động out-of-the-box chỉ với một lệnh duy nhất
  • Code ngắn hơn, dễ đọc hơn, và type-safe ngay từ đầu

Nói đơn giản: type hints vừa là documentation cho code, vừa là “schema” để Typer build CLI — không cần khai báo hai lần ở hai nơi khác nhau.

Cài đặt và lệnh đầu tiên

Cài Typer với đầy đủ tính năng bao gồm rich output và auto-completion:

pip install "typer[all]"

Tạo file main.py với lệnh đầu tiên:

import typer

app = typer.Typer()

@app.command()
def greet(name: str, count: int = 1):
    """Chào hỏi người dùng."""
    for _ in range(count):
        typer.echo(f"Xin chào, {name}!")

if __name__ == "__main__":
    app()

Chạy thử ngay:

python main.py --help
python main.py World
python main.py "Nguyễn Văn A" --count 3

Output --help được generate tự động từ type hints — name là required argument vì không có default value, count là optional với default là 1. Không cần viết thêm gì cả.

Validation tự động với Type Hints

Đây là điểm mạnh nhất. Khi bạn khai báo count: int, Typer tự validate và trả về lỗi rõ ràng nếu user nhập sai:

python main.py World --count abc
# Error: Invalid value for '--count': 'abc' is not a valid integer.

Bạn có thể dùng các kiểu dữ liệu phức tạp hơn như Path:

from pathlib import Path
from typing import Optional
import typer

app = typer.Typer()

@app.command()
def process_file(
    input_file: Path = typer.Argument(..., help="File đầu vào cần xử lý"),
    output_dir: Path = typer.Option(Path("."), help="Thư mục lưu kết quả"),
    verbose: bool = False,
    max_lines: Optional[int] = None,
):
    if not input_file.exists():
        typer.echo(f"Lỗi: File {input_file} không tồn tại", err=True)
        raise typer.Exit(1)

    typer.echo(f"Xử lý: {input_file} → {output_dir}")
    if verbose:
        typer.echo(f"Giới hạn: {max_lines or 'không giới hạn'} dòng")

Path type tự validate đường dẫn hợp lệ. Optional[int] chấp nhận int hoặc None. Dấu ... trong typer.Argument(...) nghĩa là required — không có default.

Enum — Giới hạn tập giá trị hợp lệ

Thay vì validate thủ công bằng if format not in ["json", "csv"], dùng Enum để Typer tự lo:

from enum import Enum
import typer

app = typer.Typer()

class OutputFormat(str, Enum):
    json = "json"
    csv = "csv"
    text = "text"

@app.command()
def export(
    data: str,
    format: OutputFormat = OutputFormat.json,
):
    typer.echo(f"Xuất dữ liệu định dạng: {format.value}")
python main.py export "data" --format csv
python main.py export "data" --format xml
# Error: Invalid value for '--format': 'xml' is not one of 'json', 'csv', 'text'.

Bonus: auto-completion cũng sẽ suggest đúng 3 giá trị này khi user nhấn Tab trong terminal.

Subcommands — Tổ chức lệnh theo nhóm

Khi project lớn, một lệnh duy nhất không đủ. Typer hỗ trợ nested subcommands rất gọn bằng add_typer():

import typer
from pathlib import Path

app = typer.Typer()
db_app = typer.Typer()
user_app = typer.Typer()

app.add_typer(db_app, name="db", help="Quản lý database")
app.add_typer(user_app, name="user", help="Quản lý người dùng")

@db_app.command("migrate")
def db_migrate(version: str = "latest"):
    typer.echo(f"Chạy migration đến version: {version}")

@db_app.command("backup")
def db_backup(output: Path = Path("backup.sql")):
    typer.echo(f"Backup database → {output}")

@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
    role = "admin" if admin else "user"
    typer.echo(f"Tạo {role}: {username} ({email})")

if __name__ == "__main__":
    app()

Cấu trúc lệnh trở nên rõ ràng và dễ mở rộng:

python main.py db migrate --version 1.5
python main.py db backup --output /tmp/backup.sql
python main.py user create john [email protected] --admin
python main.py --help  # hiện tất cả nhóm lệnh

Bật Auto-completion trong một lệnh

Đây là tính năng mình thích nhất — thứ mà argparse không có, và click cần cấu hình phức tạp:

# Bash
python main.py --install-completion bash

# Zsh
python main.py --install-completion zsh

# Fish
python main.py --install-completion fish

# Reload shell để activate
source ~/.bashrc

Sau đó nhấn Tab khi gõ lệnh — Typer suggest subcommands, options, và Enum values tự động. Với công cụ có nhiều lệnh, tính năng này tiết kiệm rất nhiều thời gian cho người dùng.

Rich Output — Bảng và màu sắc trong terminal

Typer cài kèm rich nên bạn có thể in bảng, progress bar, và màu sắc mà không cần cài thêm:

from rich.console import Console
from rich.table import Table
import typer

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

@app.command()
def list_tasks():
    """Hiển thị danh sách tác vụ."""
    table = Table(title="Danh sách tác vụ")
    table.add_column("ID", style="cyan", width=6)
    table.add_column("Tên", style="white")
    table.add_column("Trạng thái", style="green")

    table.add_row("1", "Backup database", "✓ Hoàn thành")
    table.add_row("2", "Deploy staging", "⟳ Đang chạy")
    table.add_row("3", "Send report", "✗ Lỗi")

    console.print(table)

Tổ chức code khi project lớn lên

Bài học đắt giá từ dự án 2000 dòng của mình: đừng nhét tất cả lệnh vào một file. Pattern mình dùng là tách mỗi nhóm lệnh thành module riêng:

mycli/
├── __init__.py      # app chính + add_typer()
├── commands/
│   ├── __init__.py
│   ├── db.py        # db_app = typer.Typer()
│   ├── user.py      # user_app = typer.Typer()
│   └── report.py    # report_app = typer.Typer()
main.py              # entry point

File mycli/__init__.py:

import typer
from mycli.commands.db import db_app
from mycli.commands.user import user_app
from mycli.commands.report import report_app

app = typer.Typer(name="mycli", help="Công cụ quản lý dự án")
app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")
app.add_typer(report_app, name="report")

Mỗi file export một typer.Typer() instance độc lập. Thêm nhóm lệnh mới chỉ cần tạo file mới và thêm 2 dòng vào __init__.py — không đụng vào code cũ.

So với khi dùng argparse, phần CLI của mình giảm từ ~300 xuống ~150 dòng sau khi chuyển sang Typer, đồng thời loại bỏ hàng chục bug validation nhỏ lẻ mà trước đây hay quên xử lý.

Kết luận

Typer không phải magic — nó chỉ tận dụng những thứ bạn đã quen viết (function + type hints) để sinh ra một CLI hoàn chỉnh với validation, help text, auto-completion, và rich output. Nếu đang dùng argparse và thấy code CLI bắt đầu lộn xộn, đây là thời điểm tốt để thử.

Cách tiếp cận thực tế: convert một lệnh nhỏ trước, chạy được và quen với pattern, rồi mới migrate dần. Đừng cố refactor toàn bộ trong một lần.

Share: