快轉到主要內容
  1. 教學文章/

Rich + Typer:打造漂亮又好用的 Python CLI 體驗

·10 分鐘· loading · loading · ·
Python Rich Typer Cli Command-Line Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 55: 本文

featured

一. 前言:CLI 不只是「能跑」而已
#

很多 Python 工具一開始都只是小腳本:python clean.pypython 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 listpypy task addpypy 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 就很值得放進工具箱。 拍拍君今天就先把終端機擦亮到這裡。🛠️

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 55: 本文

相關文章

Python Typer 進階:巢狀 subcommands、callback 與 CLI 架構
·9 分鐘· loading · loading
Python Typer Cli Command-Line Developer-Tools Testing
Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools
用 Typer 打造專業 CLI 工具:Python 命令列框架教學
·10 分鐘· loading · loading
Python Typer Cli 開發工具
讓你的終端機華麗變身:Rich 套件教學
·2 分鐘· loading · loading
Python Rich Cli
Rust CLI 實戰:用 clap 打造命令列工具(Python Typer 對照版)
·5 分鐘· loading · loading
Rust Cli Clap Typer Python
Python click:比 argparse 更優雅的 CLI 框架
·10 分鐘· loading · loading
Python Click Cli 命令列 開發工具