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

Python Typer 進階:巢狀 subcommands、callback 與 CLI 架構

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

featured

一. 前言:CLI 長大以後
#

前一篇 Typer 入門 裡,我們用很少的程式碼做出一個可執行、可產生 --help、又能吃 Python type hints 的 CLI。 那種感覺很舒服:一個函式加上 typer.run(),普通腳本就變成命令列工具。 但真實工具很少永遠停在 hello.py。 它通常會慢慢長出 task addtask listconfig setconfig show--verbose--dry-run、測試、設定檔與專案結構。 這時候問題就不只是「怎麼解析參數」。 真正的問題是:CLI 架構要怎麼長大,才不會變成一坨會跑的義大利麵? 今天拍拍君要整理 Typer 的進階用法:巢狀 subcommands、callback、context、共用 option、測試與分層架構。 如果你已經會基本的 Typer,這篇就是下一步。

二. 建立範例專案
#

這篇會做一個叫 pypy 的開發者小工具。 它可以管理任務,也可以管理設定。 先建立專案:

uv init typer-advanced-demo
cd typer-advanced-demo
uv add "typer[all]" rich pydantic-settings
uv add --dev pytest

不用 uv 的話,也可以這樣:

python -m venv .venv
source .venv/bin/activate
pip install "typer[all]" rich pydantic-settings pytest

建議先規劃成這樣:

typer-advanced-demo/
├── pyproject.toml
├── src/pypy_cli/
│   ├── app.py
│   ├── config.py
│   ├── console.py
│   ├── commands/
│   │   ├── tasks.py
│   │   └── config_cmd.py
│   └── services/
│       └── task_store.py
└── tests/test_cli.py

小工具可以從單檔開始。 但只要命令超過三四個,拍拍君會建議早點拆。 CLI 會爛掉,通常不是因為功能太少,而是所有功能都塞在同一個檔案裡。

三. 用 Typer() 建立主 app
#

入門時常看到 typer.run(main)。 進階 CLI 建議改用 typer.Typer()

# src/pypy_cli/app.py
from typing import Annotated
import typer
from rich.console import Console
app = typer.Typer(name="pypy", help="拍拍君的開發者小工具。", no_args_is_help=True)
console = Console()
@app.command()
def version():
    """顯示版本。"""
    console.print("pypy 0.1.0")
@app.command()
def greet(
    name: Annotated[str, typer.Argument(help="要打招呼的名字")],
    excited: Annotated[bool, typer.Option("--excited", "-e", help="熱情一點")] = False,
):
    """跟使用者打招呼。"""
    suffix = "!!!" if excited else "。"
    console.print(f"哈囉,{name}{suffix}")
if __name__ == "__main__":
    app()

執行看看:

uv run python -m pypy_cli.app --help
uv run python -m pypy_cli.app greet 拍拍君 --excited

這裡有三個重點:Typer() 是 CLI 的根節點,@app.command() 會把函式註冊成命令,Annotated 可以把 Python 型別和 Typer metadata 放在一起。 拍拍君現在寫新專案,通常會優先用 Annotated,因為它比較容易看出「型別」和「CLI 說明」各自在哪裡。

四. Subcommands:把命令分群
#

如果只有 versiongreet,不用分群。 但工具一長大,分群很重要。 例如 Git 的命令就很有層次:

git remote add origin ...
git remote remove origin
git worktree list

Typer 可以用 add_typer() 做類似結構。 先建立 tasks.py

# src/pypy_cli/commands/tasks.py
from typing import Annotated
import typer
from rich.console import Console
app = typer.Typer(help="管理任務。", no_args_is_help=True)
console = Console()
@app.command("add")
def add_task(
    title: Annotated[str, typer.Argument(help="任務標題")],
    priority: Annotated[int, typer.Option("--priority", "-p", min=1, max=5)] = 3,
):
    """新增任務。"""
    console.print(f"新增任務:{title},優先度 {priority}")
@app.command("list")
def list_tasks(done: Annotated[bool, typer.Option("--done", help="只顯示已完成任務")] = False):
    """列出任務。"""
    status = "已完成" if done else "未完成"
    console.print(f"列出{status}任務")

再回到主 app 掛上去:

from pypy_cli.commands import tasks
app.add_typer(tasks.app, name="task")

現在可以這樣用:

uv run python -m pypy_cli.app task add "整理 README" --priority 2
uv run python -m pypy_cli.app task list

task 是命令群組,addlist 是它底下的子命令。 這種拆法最大的好處不是看起來漂亮,而是每個模組可以只管自己的命令。

五. 巢狀 subcommands:config setconfig show
#

巢狀命令就是 subcommand 裡面再掛 subcommand。 建立 config_cmd.py

# src/pypy_cli/commands/config_cmd.py
from typing import Annotated
import typer
from rich.console import Console
app = typer.Typer(help="管理設定。", no_args_is_help=True)
console = Console()
@app.command("show")
def show_config():
    """顯示目前設定。"""
    console.print("output = table")
    console.print("api_url = https://example.invalid")
@app.command("set")
def set_config(key: Annotated[str, typer.Argument(help="設定鍵")], value: Annotated[str, typer.Argument(help="設定值")]):
    """更新設定。"""
    console.print(f"設定 {key} = {value}")

掛到主 app:

from pypy_cli.commands import config_cmd, tasks
app.add_typer(tasks.app, name="task")
app.add_typer(config_cmd.app, name="config")

使用方式:

uv run python -m pypy_cli.app config show
uv run python -m pypy_cli.app config set output json

檔名用 config_cmd.py,不要直接叫 config.py,是為了避免和設定模型模組混在一起。 命令層負責讀 CLI 參數、印訊息、轉成 exit code;真正做事的邏輯放在 services/ 或其他模組。

六. Callback:處理全域 option
#

很多 CLI 都有全域 option。 例如:

pypy --verbose task list
pypy --config ~/.config/pypy.toml task add "寫文件"
pypy --dry-run task add "危險操作"

這些 option 不屬於某一個 command,而是整個 app 都需要。 Typer 用 @app.callback() 處理。

# src/pypy_cli/app.py
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated
import typer
from rich.console import Console
console = Console()
app = typer.Typer(name="pypy", no_args_is_help=True)
@dataclass
class AppState:
    verbose: bool
    config_path: Path
    dry_run: bool
@app.callback()
def main(
    ctx: typer.Context,
    verbose: Annotated[bool, typer.Option("--verbose", "-v", help="顯示更多細節")] = False,
    config_path: Annotated[Path, typer.Option("--config", help="設定檔路徑")] = Path("~/.config/pypy/config.toml"),
    dry_run: Annotated[bool, typer.Option("--dry-run", help="只預覽,不修改")] = False,
):
    """拍拍君 CLI。"""
    ctx.obj = AppState(verbose, config_path.expanduser(), dry_run)
    if verbose:
        console.print(f"[dim]config = {ctx.obj.config_path}[/dim]")

ctx.obj 是 Click/Typer 很常用的共用狀態位置。 你可以把設定、logger、database connection、dry-run 狀態放進去。 但不要塞太多;如果 ctx.obj 變成大型全域垃圾桶,之後也會很痛。

七. 子命令讀取 ctx.obj
#

子命令可以接收 ctx: typer.Context

# src/pypy_cli/commands/tasks.py
import typer
from rich.console import Console
console = Console()
app = typer.Typer(help="管理任務。", no_args_is_help=True)
@app.command("add")
def add_task(ctx: typer.Context, title: str):
    """新增任務。"""
    state = ctx.obj
    if state.dry_run:
        console.print(f"[yellow]DRY RUN[/yellow] 會新增任務:{title}")
        return
    if state.verbose:
        console.print("[dim]準備寫入 task store[/dim]")
    console.print(f"新增任務:{title}")

使用:

uv run python -m pypy_cli.app --dry-run task add "重構 CLI"
uv run python -m pypy_cli.app --verbose task add "補測試"

注意:全域 option 的位置通常在 command group 前面。 也就是 pypy --verbose task list,不是 pypy task list --verbose。 後者會被視為 task list 這個 command 的 option,這是很多人第一次做 multi-command CLI 時會卡住的地方。

八. 共用 option:重複三次就抽出來
#

有些 option 不是全域,但會被多個 command 共用。 例如輸出格式:

pypy task list --format table
pypy task list --format json

可以先定義型別與 option helper。

# src/pypy_cli/console.py
from typing import Annotated, Literal
import typer
OutputFormat = Literal["table", "json", "yaml"]
FormatOption = Annotated[OutputFormat, typer.Option("--format", "-f", help="輸出格式。")]

使用時:

from pypy_cli.console import FormatOption
@app.command("list")
def list_tasks(format: FormatOption = "table"):
    if format == "json":
        print('[{"id": 1, "title": "寫文件"}]')
    else:
        print("#1 寫文件")

這樣每個 command 的 option 說明會一致。 將來要新增 csv,只要改一個地方。 不用過度抽象,但重複三次以上就值得整理。

九. 參數驗證與錯誤處理
#

Typer 的參數驗證很多來自 Click。 常見的 minmaxexistsfile_okay 都很實用。

from pathlib import Path
from typing import Annotated
import typer
@app.command()
def import_tasks(
    path: Annotated[Path, typer.Argument(exists=True, file_okay=True, dir_okay=False, readable=True)],
    limit: Annotated[int, typer.Option("--limit", min=1, max=1000)] = 100,
):
    print(f"從 {path} 匯入,最多 {limit} 筆")

錯誤處理則要記得 exit code。

import typer
from rich.console import Console
err = Console(stderr=True)
@app.command()
def publish(dry_run: bool = False):
    if dry_run:
        err.print("[yellow]只預覽,不發布[/yellow]")
        return
    config_exists = False
    if not config_exists:
        err.print("[red]發布失敗:找不到設定檔[/red]")
        raise typer.Exit(code=2)

拍拍君的習慣是:使用者輸入錯就給清楚提示,外部服務失敗就說明原因,dry-run 成功就回 0。 重點不是背哪個 code 最正統,而是保持一致。

十. 設定檔:不要把所有東西都塞成 option
#

有些東西適合 option,例如 --verbose--dry-run--limit。 有些東西適合設定檔,例如 API endpoint、預設輸出格式、資料目錄。 可以用 pydantic-settings 做簡單設定模型。

# src/pypy_cli/config.py
from pathlib import Path
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    api_url: str = "https://example.invalid"
    data_dir: Path = Path("~/.local/share/pypy")
    output: Literal["table", "json"] = "table"
    model_config = SettingsConfigDict(env_prefix="PYPY_", env_file=".env")

使用者可以用環境變數覆蓋:

PYPY_OUTPUT=json pypy task list

如果你想支援 TOML 設定檔,也可以自己讀 tomllib,再餵給設定模型。 拍拍君之前寫過 Python tomllib 實戰,可以搭配看。

十一. 命令層與邏輯層要分開
#

很多 CLI 專案壞掉,是因為 command function 裡面什麼都做。 比較好的做法是:command function 只接 CLI 參數、呼叫 service、把結果印給使用者。

# src/pypy_cli/services/task_store.py
from dataclasses import dataclass
@dataclass
class Task:
    id: int
    title: str
    done: bool = False
class TaskStore:
    def __init__(self):
        self._tasks: list[Task] = []
    def add(self, title: str) -> Task:
        task = Task(id=len(self._tasks) + 1, title=title)
        self._tasks.append(task)
        return task

命令層只負責接線:

from pypy_cli.services.task_store import TaskStore
store = TaskStore()
@app.command("add")
def add_task(title: str):
    task = store.add(title)
    console.print(f"新增 #{task.id}: {task.title}")

真實專案裡 TaskStore 可能是 SQLite、JSON 檔或 API client。 但 command function 不需要知道太多。 這樣測試也會簡單很多。

十二. 測試 Typer CLI
#

Typer 提供 CliRunner,可以直接測 CLI。

# tests/test_cli.py
from typer.testing import CliRunner
from pypy_cli.app import app
runner = CliRunner()
def test_version():
    result = runner.invoke(app, ["version"])
    assert result.exit_code == 0
    assert "0.1.0" in result.output
def test_task_add_dry_run():
    result = runner.invoke(app, ["--dry-run", "task", "add", "寫測試"])
    assert result.exit_code == 0
    assert "DRY RUN" in result.output

CLI 測試特別適合檢查 exit code、help 訊息、錯誤訊息、dry-run、以及 --json 輸出能不能被 json.loads() 解析。 不要只測內部函式。 CLI 的介面本身就是產品。

十三. 常見坑
#

第一,全域 option 放錯位置。 如果 --verbose 定義在 callback,通常要寫 pypy --verbose task list。 第二,ctx.obj 沒有初始化。 如果子命令需要共用狀態,callback 一定要設定它。 第三,command function 太胖。 看到一個命令超過五十行,就該考慮搬邏輯到 service。 第四,JSON 輸出混入漂亮訊息。 如果提供 --format json,標準輸出最好只放 JSON,log 請丟 stderr。 這樣使用者才能安心接管線:

pypy task list --format json | jq .

第五,破壞性命令沒有防呆。 deleteclearreset 這類命令,至少要支援確認、--dry-run--yes。 命令列工具很有效率,所以出事也很有效率。小心一點,不虧。

結語:把 CLI 當成一個小產品
#

Typer 最迷人的地方,是它讓你用 Python 型別註解就能快速做出好用 CLI。 但進階使用的重點,不只是多會幾個 decorator。 真正重要的是:用 Typer() 管理 app,用 add_typer() 拆 subcommands,用 callback() 放全域 option,用 ctx.obj 傳遞共用狀態,並把命令層和業務邏輯分開。 一支好的 CLI 不只是「可以跑」。 它應該讓使用者在凌晨兩點也知道下一步該輸入什麼,而且不會因為一個手滑把資料炸掉。 拍拍君覺得,這就是開發者工具最可愛的地方:它們小小的,但每天都在幫你省心。

延伸閱讀
#

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

相關文章

Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools
用 Typer 打造專業 CLI 工具:Python 命令列框架教學
·10 分鐘· loading · loading
Python Typer Cli 開發工具
Rust CLI 實戰:用 clap 打造命令列工具(Python Typer 對照版)
·5 分鐘· loading · loading
Rust Cli Clap Typer Python
Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
Python click:比 argparse 更優雅的 CLI 框架
·10 分鐘· loading · loading
Python Click Cli 命令列 開發工具
Python pytest:fixture + parametrize + mock 完整指南
·8 分鐘· loading · loading
Python Pytest Testing Fixture Mock Parametrize TDD