一. 前言:CLI 不只是「能跑」而已 #
很多 Python 工具一開始都只是小腳本:python clean.py、python sync.py --days 7、或某個每天都會跑的資料處理指令。
一開始只要能跑就好;但只要工具開始被自己、同事、CI、或未來的自己反覆使用,CLI 的「使用體驗」就會變成真正的生產力問題。
參數不清楚、錯誤訊息像事故現場、輸出一坨文字、進度不知道卡在哪裡,這些都會讓一個原本有用的工具變得很難信任。
今天拍拍君要介紹一組很好用的搭配:Typer + Rich。Typer 負責命令、參數、型別、help 與 subcommand;Rich 負責漂亮輸出、表格、panel、progress bar、traceback 與顏色。
兩個合在一起,就能把普通 Python 腳本升級成一個清楚、好用、願意每天打開的 CLI 工具。如果你已經看過 Python Rich 實戰 與 Python Typer 入門,這篇就是把兩者接起來的實戰篇。
二. 安裝與專案初始化 #
先建立一個範例專案。這篇會做一個叫 pypy 的任務小工具,用來示範清單、狀態、進度、錯誤處理與 JSON 輸出。
用 uv 的話可以這樣開始:
uv init rich-typer-demo
cd rich-typer-demo
uv add "typer[all]" rich
uv add --dev pytest
如果你還沒用 uv,也可以用傳統 venv:
python -m venv .venv
source .venv/bin/activate
pip install "typer[all]" rich pytest
建議目錄先整理成這樣:
rich-typer-demo/
├── pyproject.toml
├── src/
│ └── pypy_cli/
│ ├── __init__.py
│ ├── app.py
│ ├── console.py
│ └── tasks.py
└── tests/
└── test_cli.py
app.py 放 Typer app,console.py 放共用的 Rich console,tasks.py 放跟任務資料有關的邏輯。拍拍君很推薦一開始就這樣拆,因為 CLI 很容易長大,早點把輸出、命令、核心邏輯分開,後面會輕鬆很多。
三. 建立第一個 Typer App #
先從最小的 Typer app 開始。Typer 的好處是很直覺:函式參數就是 CLI 參數,type hints 會變成解析規則,docstring 會出現在 help 裡。
# src/pypy_cli/app.py
import typer
app = typer.Typer(
name="pypy",
help="拍拍君的任務小工具。",
no_args_is_help=True,
)
@app.command()
def hello(name: str = "拍拍醬") -> None:
"""跟使用者打招呼。"""
print(f"Hello, {name}!")
if __name__ == "__main__":
app()
執行看看:
python -m pypy_cli.app hello
python -m pypy_cli.app hello 拍拍君
python -m pypy_cli.app --help
這已經比手寫 sys.argv 好太多,但現在輸出還是普通文字。接下來讓 Rich 進場。
四. 建立共用 Console #
Rich 的核心物件是 Console。你可以在每個檔案都建立一個,但拍拍君更喜歡集中管理,方便之後切換顏色、錄製輸出、或在測試裡替換。
# src/pypy_cli/console.py
from rich.console import Console
console = Console()
error_console = Console(stderr=True, style="bold red")
然後回到 app.py:
# src/pypy_cli/app.py
from typing import Annotated
import typer
from pypy_cli.console import console
app = typer.Typer(
name="pypy",
help="拍拍君的任務小工具。",
no_args_is_help=True,
)
@app.command()
def hello(
name: Annotated[str, typer.Argument(help="要打招呼的名字")] = "拍拍醬",
excited: Annotated[
bool,
typer.Option("--excited", "-e", help="熱情一點"),
] = False,
) -> None:
"""跟使用者打招呼。"""
message = f"Hello, {name}"
if excited:
message += "!!!"
console.print(f"[bold green]{message}[/]")
Rich 的標記語法很像簡化版 BBCode。[bold green] 是粗體綠色,[cyan] 是青色,[/] 代表結束目前樣式。顏色是拿來標重點,不是拿來開演唱會;CLI 最怕每一行都在閃。
五. 用 Rich Table 顯示清單 #
為了示範表格,我們先準備一些假資料。真實專案裡,這裡可能會接 SQLite、JSON、API、或設定檔,但 CLI 命令本身不要知道太多儲存細節。
# src/pypy_cli/tasks.py
from dataclasses import dataclass
@dataclass
class Task:
id: int
title: str
priority: str
done: bool = False
TASKS = [
Task(1, "整理 CLI 參數", "high"),
Task(2, "補上 README 範例", "medium"),
Task(3, "把錯誤訊息變可愛一點", "low", done=True),
]
def list_tasks() -> list[Task]:
return TASKS
普通輸出能看,但不舒服。用 Rich table 會清楚很多:
# src/pypy_cli/app.py
from rich.table import Table
from pypy_cli.tasks import list_tasks
@app.command("list")
def list_command() -> None:
"""列出任務。"""
table = Table(title="拍拍君任務清單")
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
table.add_column("狀態", justify="center")
table.add_column("優先度", style="magenta")
table.add_column("標題", style="white")
for task in list_tasks():
status = "[green]完成[/]" if task.done else "[yellow]進行中[/]"
table.add_row(str(task.id), status, task.priority, task.title)
console.print(table)
表格很適合呈現 list、status、summary、report。但不要濫用:如果資料只有一兩行,普通文字可能更好;如果欄位太多,使用者會左右捲到懷疑人生。
六. 用 Panel 做重點摘要 #
Rich 的 Panel 很適合放摘要、提示、或成功訊息。例如顯示目前任務狀態:
from rich.panel import Panel
@app.command()
def status() -> None:
"""顯示目前狀態。"""
tasks = list_tasks()
done = sum(task.done for task in tasks)
total = len(tasks)
console.print(
Panel.fit(
f"[bold]任務總數:[/] {total}\n"
f"[bold]已完成:[/] [green]{done}[/]\n"
f"[bold]未完成:[/] [yellow]{total - done}[/]",
title="pypy status",
border_style="blue",
)
)
拍拍君通常會把 panel 用在指令成功完成後的摘要、初始化專案後的下一步、重要警告、或 dry-run 結果。不要每個輸出都包 panel,不然使用者會覺得自己在看一堆框框。
七. 進度條:不要讓使用者猜 CLI 死了沒 #
只要工作超過兩三秒,拍拍君就會想加進度提示。Rich 的 track() 很適合簡單迴圈:
import time
from rich.progress import track
@app.command()
def sync() -> None:
"""假裝同步任務。"""
for _ in track(range(5), description="同步中..."):
time.sleep(0.4)
console.print("[green]同步完成![/]")
如果需要多個 task、下載速度、剩餘時間,可以用 Progress 類別做更細的控制:
from rich.progress import Progress
@app.command()
def build() -> None:
"""假裝建置專案。"""
with Progress() as progress:
lint_task = progress.add_task("lint", total=100)
test_task = progress.add_task("tests", total=100)
while not progress.finished:
progress.update(lint_task, advance=20)
progress.update(test_task, advance=12)
time.sleep(0.2)
console.print("[bold green]build ok[/]")
進度條不是裝飾。它是在跟使用者說:「我還活著,而且我知道自己做到哪裡了。」這對需要下載、掃描、轉檔、跑測試的 CLI 特別重要。
八. 錯誤訊息要像路標,不要像爆炸 #
很多工具一失敗就噴完整 traceback。對開發者來說 traceback 有用,但對一般使用者來說,它比較像事故現場。Typer 提供 typer.Exit,Rich 則可以讓錯誤訊息更清楚。
from pathlib import Path
import typer
from pypy_cli.console import error_console
@app.command()
def load(path: Path) -> None:
"""讀取任務檔。"""
if not path.exists():
error_console.print(f"找不到檔案:{path}")
error_console.print("請確認路徑是否正確,或先執行 [bold]pypy init[/]。")
raise typer.Exit(code=1)
console.print(f"[green]讀取成功:[/]{path}")
重點是:錯誤訊息要告訴使用者下一步。不要只說 FileNotFoundError;要說找不到哪個檔案,以及可以怎麼修。如果是程式 bug,再開 traceback;如果是使用者輸入錯誤,就給清楚的路標。
九. Subcommands 與全域選項 #
當命令變多時,建議用 subcommands 分組。例如 pypy task list、pypy task add、pypy config show。Typer 可以用子 app 組起來:
# src/pypy_cli/app.py
import typer
app = typer.Typer(no_args_is_help=True)
task_app = typer.Typer(help="管理任務。", no_args_is_help=True)
config_app = typer.Typer(help="管理設定。", no_args_is_help=True)
app.add_typer(task_app, name="task")
app.add_typer(config_app, name="config")
@task_app.command("list")
def task_list() -> None:
"""列出任務。"""
console.print("task list")
@config_app.command("show")
def config_show() -> None:
"""顯示設定。"""
console.print("config show")
好用的 CLI 也常常會有全域選項,例如 --verbose、--quiet、--json、--no-color。Typer 可以用 callback 處理:
from dataclasses import dataclass
@dataclass
class AppState:
verbose: bool = False
json_output: bool = False
state = AppState()
@app.callback()
def main(
verbose: Annotated[
bool,
typer.Option("--verbose", "-v", help="顯示詳細資訊"),
] = False,
json_output: Annotated[
bool,
typer.Option("--json", help="輸出 JSON,方便腳本串接"),
] = False,
) -> None:
"""拍拍君的任務小工具。"""
state.verbose = verbose
state.json_output = json_output
漂亮輸出給人看,JSON 給機器看。不要讓 shell script 去 parse 彩色表格,那會很痛。如果 CLI 可能被自動化串接,就提供 --json。
十. 互動提示與自動化環境 #
Typer 也可以做互動式 prompt:
@app.command()
def init(
force: Annotated[
bool,
typer.Option("--force", help="覆蓋既有設定"),
] = False,
) -> None:
"""初始化設定。"""
if not force:
ok = typer.confirm("要建立預設設定檔嗎?")
if not ok:
console.print("[yellow]已取消。[/]")
raise typer.Exit()
console.print("[green]設定檔建立完成。[/]")
互動提示很友善,但要小心 CI、cron、Docker 裡沒有互動環境。所以重要 CLI 最好同時提供非互動 option,例如 --yes、--force、--no-input。使用者手動跑時可以問,自動化環境裡就不要卡住。
十一. 測試 CLI:不要只靠手打 #
Typer 基於 Click,所以可以用 CliRunner 測試。CLI 測試不需要把每個框線都比對到完美;通常只要檢查 exit code、關鍵文字、錯誤 code、以及 JSON 是否能解析。
# tests/test_cli.py
import json
from typer.testing import CliRunner
from pypy_cli.app import app
runner = CliRunner()
def test_hello_default():
result = runner.invoke(app, ["hello"])
assert result.exit_code == 0
assert "Hello" in result.stdout
def test_list_command():
result = runner.invoke(app, ["list"])
assert result.exit_code == 0
assert "拍拍君任務清單" in result.stdout
def test_json_output():
result = runner.invoke(app, ["--json", "task", "list"])
assert result.exit_code == 0
assert json.loads(result.stdout)
如果遇到 ANSI color code 造成 assert 不穩,可以讓測試環境關掉顏色。例如在 console.py 裡讀 NO_COLOR:
# src/pypy_cli/console.py
import os
from rich.console import Console
no_color = os.getenv("NO_COLOR") == "1"
console = Console(no_color=no_color)
error_console = Console(stderr=True, style="bold red", no_color=no_color)
然後測試時:
NO_COLOR=1 pytest
測試 CLI 的重點不是截圖式比對,而是確認行為穩定。指令成功時 code 是 0,失敗時 code 不該是 0,重要訊息要在輸出裡,機器輸出要能被機器讀。
十二. 包成真正可執行命令 #
現在我們都用 python -m pypy_cli.app 執行。正式一點可以在 pyproject.toml 裡加 entry point:
[project]
name = "rich-typer-demo"
version = "0.1.0"
dependencies = [
"typer[all]",
"rich",
]
[project.scripts]
pypy = "pypy_cli.app:app"
安裝成 editable:
uv pip install -e .
# 或
pip install -e .
之後就可以直接跑:
pypy --help
pypy hello 拍拍君 --excited
pypy list
pypy status
這一步很重要,因為 CLI 的體驗從命令名稱就開始了。如果每次都要打一長串 python -m ...,使用者很快就懶了。
十三. 設計漂亮 CLI 的幾個原則 #
第一,預設輸出要適合人看。表格、顏色、摘要、進度條都可以用,但重點要清楚,不要把顏色當煙火。
第二,提供機器可讀輸出。只要有人可能在 shell script、CI、或其他程式裡呼叫你的 CLI,就加 --json。
第三,錯誤訊息要有下一步。「錯了」不夠;「哪裡錯、為什麼錯、怎麼修」才是好錯誤訊息。
第四,互動提示要能關掉。confirm() 很友善,但自動化環境需要 --yes 或 --no-input。
第五,核心邏輯不要綁死 CLI。命令函式越薄越好,真正的邏輯放到 service、model、或普通函式裡,測試會簡單很多。
第六,help 就是第一份文件。每個 command、argument、option 都值得寫清楚,未來的你會少罵現在的你很多次。
結語 #
Rich + Typer 是拍拍君很喜歡的一組 Python CLI 組合。Typer 讓你用 type hints 寫出自然的命令列介面,Rich 讓輸出變得清楚、漂亮、帶有層次。 兩者合起來,不只是讓工具「比較好看」。更重要的是讓工具更容易理解、更容易除錯、更容易被每天使用。 CLI 是開發者和自動化系統之間的接口。好的 CLI 會讓人信任它;壞的 CLI 會讓人每次執行前都先深呼吸。 所以下次你寫 Python 腳本時,可以多想一步:這個工具如果明天還要用,能不能讓未來的自己少皺一點眉?如果答案是可以,那 Rich + Typer 就很值得放進工具箱。 拍拍君今天就先把終端機擦亮到這裡。🛠️