Bối cảnh & Lý do: Vấn đề Script Lúc 2 Giờ Sáng
Lúc 2:07 sáng, chuông cảnh báo on-call reo lên. Một batch job đã âm thầm thất bại vì ai đó chạy script bảo trì với --mode=delet thay vì --mode=delete. Không có validation, không có lỗi. Chỉ là… không có gì xảy ra. Ba tiếng đồng hồ debug sau đó, chúng tôi tìm ra nguyên nhân: một script argparse viết tay chấp nhận bất kỳ chuỗi nào mà không kiểm tra gì cả.
Đêm đó khiến tôi quyết định ngừng viết các script CLI tạm bợ và bắt đầu xây dựng công cụ đàng hoàng. Từ đó đến nay, tôi đã viết lại bốn công cụ nội bộ bằng Typer cho hai team khác nhau. Lỗi do đối số sai giảm xuống bằng không. Output --help không còn nói dối nữa.
Typer là một thư viện Python được xây dựng trên nền tảng Click. Nó sử dụng Python type hints để định nghĩa các argument, option và validation cho CLI một cách tự động. Chỉ cần viết một hàm Python bình thường với type annotation, Typer sẽ tự tạo ra giao diện — bao gồm text --help, script autocomplete và validation đầu vào.
Đây là điểm khác biệt so với argparse hay Click thuần túy:
- Type hints chính là định nghĩa argument — không cần khai báo trùng lặp
- Validation xảy ra trước khi hàm chạy — đầu vào sai không bao giờ chạm đến logic của bạn
- Autocomplete hoạt động ngay lập tức cho bash, zsh và fish
- Subcommand chỉ đơn giản là các hàm Python được nhóm lại dưới một
app
Cài đặt
Bắt đầu với một virtual environment sạch — trộn lẫn các phiên bản Typer khác nhau giữa các project sẽ gây ra các vấn đề khó phát hiện với Click:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "typer[all]"
Extra [all] sẽ kéo thêm rich để có output màu sắc và shellingham để phát hiện shell (cần thiết khi cài autocomplete). Nếu bạn đang xây dựng thứ gì đó tối giản cho Docker container, pip install typer cũng ổn.
Xác nhận cài đặt:
python -c "import typer; print(typer.__version__)"
Cấu hình: Xây dựng CLI Thực tế Đầu tiên
Hãy quên những ví dụ “hello world” đồ chơi đi. Đây là loại thứ thực sự chạy trên production — một công cụ bảo trì cơ sở dữ liệu với subcommand, enum cho các option được validate, và xử lý lỗi đúng chuẩn.
Cấu trúc App Cơ bản
import typer
from enum import Enum
from pathlib import Path
from typing import Optional
app = typer.Typer(name="dbmaint", help="Bộ công cụ bảo trì cơ sở dữ liệu")
class RunMode(str, Enum):
dry_run = "dry-run"
execute = "execute"
rollback = "rollback"
@app.command()
def cleanup(
database: str = typer.Argument(..., help="Tên cơ sở dữ liệu cần xử lý"),
mode: RunMode = typer.Option(RunMode.dry_run, help="Chế độ thực thi"),
older_than_days: int = typer.Option(30, min=1, max=365, help="Xóa bản ghi cũ hơn N ngày"),
config: Optional[Path] = typer.Option(None, exists=True, help="Đường dẫn đến file cấu hình"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
):
"""Dọn dẹp các bản ghi cũ trong cơ sở dữ liệu mục tiêu."""
if verbose:
typer.echo(f"Chế độ: {mode.value}, DB: {database}, Ngày: {older_than_days}")
if mode == RunMode.dry_run:
typer.secho("[DRY RUN] Không có thay đổi nào được thực hiện", fg=typer.colors.YELLOW)
elif mode == RunMode.execute:
typer.secho(f"Đang xóa bản ghi cũ hơn {older_than_days} ngày...", fg=typer.colors.GREEN)
# logic thực tế ở đây
if __name__ == "__main__":
app()
Cả ba validation đều tự động. RunMode là một Enum, vì vậy khi truyền --mode=delet sẽ thất bại ngay lập tức với thông báo lỗi rõ ràng liệt kê các lựa chọn hợp lệ. Ràng buộc min=1, max=365 trên older_than_days từ chối các số nguyên ngoài phạm vi trước khi hàm của bạn chạy. exists=True trên đường dẫn config kiểm tra xem file có thực sự tồn tại trên đĩa không.
Chạy lệnh không có argument sẽ hiển thị output help sạch đẹp:
python dbmaint.py cleanup --help
Usage: dbmaint cleanup [OPTIONS] DATABASE
Dọn dẹp các bản ghi cũ trong cơ sở dữ liệu mục tiêu.
Arguments:
DATABASE Tên cơ sở dữ liệu cần xử lý [required]
Options:
--mode [dry-run|execute|rollback] Chế độ thực thi [default: dry-run]
--older-than-days INTEGER RANGE Xóa bản ghi cũ hơn N ngày [default: 30]
--config PATH Đường dẫn đến file cấu hình
-v, --verbose / --no-verbose
--help Hiển thị thông báo này và thoát.
Thêm Subcommand
Các công cụ thực tế có nhiều thao tác. Với Typer, mỗi subcommand là một hàm riêng biệt được trang trí bằng @app.command():
@app.command()
def backup(
database: str = typer.Argument(...),
output: Path = typer.Option(Path("./backups"), writable=True, help="Thư mục lưu backup"),
compress: bool = typer.Option(True, help="Nén output bằng gzip"),
):
"""Tạo backup cơ sở dữ liệu."""
typer.echo(f"Đang backup {database} vào {output}")
# logic backup ở đây
@app.command()
def restore(
database: str = typer.Argument(...),
backup_file: Path = typer.Argument(..., exists=True, help="File backup để restore"),
force: bool = typer.Option(False, "--force", "-f", help="Bỏ qua xác nhận"),
):
"""Khôi phục cơ sở dữ liệu từ backup."""
if not force:
confirm = typer.confirm(f"Thao tác này sẽ ghi đè {database}. Tiếp tục?")
if not confirm:
raise typer.Abort()
typer.echo(f"Đang khôi phục {database} từ {backup_file}")
Chạy python dbmaint.py --help và cả ba lệnh xuất hiện tự động. Không ai cần duy trì dispatch table. Không ai cập nhật help text thủ công — điều này tiết kiệm một lượng thời gian đáng kể trong code review.
App Lồng nhau cho Công cụ Phức tạp
Đối với các công cụ có nhiều lệnh, hãy nhóm chúng thành sub-app:
import typer
app = typer.Typer()
db_app = typer.Typer(help="Các thao tác cơ sở dữ liệu")
user_app = typer.Typer(help="Quản lý người dùng")
app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")
@db_app.command("migrate")
def db_migrate(target: str = typer.Argument("head")):
"""Chạy database migrations."""
typer.echo(f"Đang migrate đến: {target}")
@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
"""Tạo tài khoản người dùng mới."""
typer.echo(f"Đang tạo người dùng: {username} ({email})")
Cách dùng trở thành python tool.py db migrate head và python tool.py user create johndoe [email protected]. Cấu trúc phân cấp hiện ra trong --help ở mọi cấp độ.
Validation Tùy chỉnh với Callback
Đôi khi các ràng buộc tích hợp sẵn chưa đủ. Typer hỗ trợ parameter callback để validation tùy chỉnh:
import re
def validate_db_name(value: str) -> str:
if not re.match(r'^[a-z][a-z0-9_]{2,63}$', value):
raise typer.BadParameter(
"Tên cơ sở dữ liệu phải bắt đầu bằng chữ cái, chỉ chứa chữ thường, "
"chữ số hoặc dấu gạch dưới, và có độ dài từ 3-64 ký tự."
)
return value
@app.command()
def create_db(
name: str = typer.Argument(..., callback=validate_db_name, help="Tên cơ sở dữ liệu mới"),
):
"""Tạo cơ sở dữ liệu mới."""
typer.secho(f"Đang tạo cơ sở dữ liệu: {name}", fg=typer.colors.GREEN)
Callback chạy trước phần thân hàm của bạn. Truyền tên không hợp lệ và bạn sẽ nhận được thông báo lỗi rõ ràng với tên parameter, giá trị, và thông điệp của bạn — trước khi bất kỳ kết nối cơ sở dữ liệu nào được thực hiện.
Kiểm tra & Giám sát
Kiểm thử Lệnh CLI
Typer đi kèm với một test client có thể bắt output mà không cần khởi tạo subprocess — điều quan trọng cho unit test nhanh:
from typer.testing import CliRunner
from myapp import app
runner = CliRunner()
def test_cleanup_dry_run():
result = runner.invoke(app, ["cleanup", "mydb", "--mode", "dry-run"])
assert result.exit_code == 0
assert "DRY RUN" in result.output
def test_cleanup_invalid_mode():
result = runner.invoke(app, ["cleanup", "mydb", "--mode", "invalid"])
assert result.exit_code != 0
assert "Invalid value" in result.output
def test_cleanup_days_out_of_range():
result = runner.invoke(app, ["cleanup", "mydb", "--older-than-days", "999"])
assert result.exit_code != 0
Các test này chạy trong ~5ms mỗi cái. Chú ý exit_code — Typer thoát với code 2 cho argument sai, code 1 cho Abort(), và code 0 cho thành công. Trong CI pipeline, những code này là thứ bộ test của bạn bắt được trước khi bất cứ thứ gì được deploy.
Exit Code và Tích hợp Shell
Khi bọc các công cụ Typer trong shell script hoặc cron job, exit code rất quan trọng:
# Trong cron job hoặc CI script của bạn:
python dbmaint.py cleanup production --mode execute
if [ $? -ne 0 ]; then
echo "Dọn dẹp thất bại" | mail -s "DB Alert" [email protected]
fi
Typer truyền exit code chính xác từ các exception được raise. Dùng raise typer.Exit(code=1) để báo hiệu thất bại từ bên trong logic của bạn. Shell wrapper nhận được mà không cần cấu hình thêm gì.
Autocomplete cho Shell
Sau khi công cụ được deploy, hãy cài autocomplete để cả team thực sự sử dụng đúng cách:
# Cài đặt completion cho shell hiện tại:
python dbmaint.py --install-completion
# Hoặc tạo script để xem nội dung:
python dbmaint.py --show-completion
Sau khi source lại shell config, tab-completing python dbmaint.py cleanup --mode <TAB> sẽ hiển thị dry-run execute rollback. Đó là loại khả năng khám phá giúp mọi người không chạy nhầm chế độ lúc 2 giờ sáng.
Đóng gói thành Công cụ CLI Hoàn chỉnh
Kết nối Typer vào entry point pyproject.toml để có thể cài đặt trên toàn hệ thống:
[project]
name = "dbmaint"
version = "1.0.0"
dependencies = ["typer[all]>=0.12"]
[project.scripts]
dbmaint = "dbmaint.cli:app"
pip install -e .
# Giờ có thể dùng như:
dbmaint cleanup production --mode dry-run
Điều này làm cho công cụ có thể sử dụng như một lệnh hệ thống, với autocomplete và validation giống như khi chạy script trực tiếp.
Chuyển từ argparse thuần túy sang Typer đã thay đổi cách team chúng tôi làm việc. Argument sai thất bại ầm ĩ ở tầng CLI — không âm thầm bên trong business logic. Text --help tự động đồng bộ với code. Thành viên mới khám phá các option qua autocomplete thay vì đọc mã nguồn. Sự cố lúc 2 giờ sáng đó là lần cuối cùng một lỗi đánh máy trong CLI argument gây ra vấn đề trên production.

