一. 前言:CLI 長大以後 #
前一篇 Typer 入門 裡,我們用很少的程式碼做出一個可執行、可產生 --help、又能吃 Python type hints 的 CLI。
那種感覺很舒服:一個函式加上 typer.run(),普通腳本就變成命令列工具。
但真實工具很少永遠停在 hello.py。
它通常會慢慢長出 task add、task list、config set、config 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:把命令分群 #
如果只有 version 和 greet,不用分群。
但工具一長大,分群很重要。
例如 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 是命令群組,add 和 list 是它底下的子命令。
這種拆法最大的好處不是看起來漂亮,而是每個模組可以只管自己的命令。
五. 巢狀 subcommands:config set 與 config 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。
常見的 min、max、exists、file_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 .
第五,破壞性命令沒有防呆。
delete、clear、reset 這類命令,至少要支援確認、--dry-run 或 --yes。
命令列工具很有效率,所以出事也很有效率。小心一點,不虧。
結語:把 CLI 當成一個小產品 #
Typer 最迷人的地方,是它讓你用 Python 型別註解就能快速做出好用 CLI。
但進階使用的重點,不只是多會幾個 decorator。
真正重要的是:用 Typer() 管理 app,用 add_typer() 拆 subcommands,用 callback() 放全域 option,用 ctx.obj 傳遞共用狀態,並把命令層和業務邏輯分開。
一支好的 CLI 不只是「可以跑」。
它應該讓使用者在凌晨兩點也知道下一步該輸入什麼,而且不會因為一個手滑把資料炸掉。
拍拍君覺得,這就是開發者工具最可愛的地方:它們小小的,但每天都在幫你省心。