Python Typer 完全指南

基於 type hints 的現代 Python CLI 框架,從基礎到進階的完整功能解析與實戰應用


目錄


什麼是 Typer?

Typer 是 Sebastián Ramírez(tiangolo) 開發的 Python CLI 框架——他同時也是 FastAPI 的作者。Typer 的設計哲學與 FastAPI 高度一致:用 Python type hints 自動產生功能。

import typer

def main(name: str, age: int = 18):
    print(f"Hello {name}, you are {age} years old")

if __name__ == "__main__":
    typer.run(main)

只有這幾行,Typer 就會自動:

  • 從 name: str 推斷出「必填的位置參數」
  • 從 age: int = 18 推斷出「選填的選項,預設 18」
  • 產生 --help 說明文件
  • 自動做型別轉換與驗證
  • 錯誤輸入時印出友善訊息

與其他 CLI 框架的關係

        argparse(標準函式庫)
           │
           ▼  增加裝飾器 + 更多功能
         Click
           │
           ▼  改用 type hints + 簡化 API
         Typer

Typer 建構在 Click 之上——所有 Click 功能都能用,但寫法更現代、更少樣板。


為什麼選 Typer?

與其他 Python CLI 框架對照

框架 風格 學習曲線 適合場景
argparse 標準庫,需手動描述每個參數 中(樣板多) 簡單腳本、不想引入依賴
Click 裝飾器風格,重複定義型別 中 中大型 CLI、需高自訂性
Typer type hints 自動推斷 低 現代 Python(≥ 3.8)首選
fire 自動把任意函式變 CLI 極低 快速原型,不在意精準度

Typer 三大優勢

  1. DRY:型別只寫一次(function signature),不必在裝飾器再寫一遍
  2. 編輯器支援:因為用標準 type hints,IDE 自動完成、mypy 檢查全自動可用
  3. Rich 整合:終端輸出色彩、表格、進度條開箱即用

同樣功能的程式碼對比

argparse:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("name", type=str)
parser.add_argument("--age", type=int, default=18)
args = parser.parse_args()
print(f"Hello {args.name}, age {args.age}")

Click:

import click

@click.command()
@click.argument("name")
@click.option("--age", default=18, type=int)
def main(name, age):
    click.echo(f"Hello {name}, age {age}")

Typer:

import typer

def main(name: str, age: int = 18):
    print(f"Hello {name}, age {age}")

typer.run(main)

★ Insight:Typer 的核心洞見是「Python 3.8+ 已經有 type hints,何必在裝飾器再寫一次?」。函式簽名本身就描述了所有 CLI 行為——必填/選填、型別、預設值。這也是為什麼 Typer 跟 FastAPI 的 API 高度相似:兩者都把「型別系統」當成「設計語言」。


安裝

# 基本安裝(含 Click 依賴)
pip install typer

# 推薦:包含 Rich(彩色終端輸出)
pip install "typer[all]"

# 確認版本
python -c "import typer; print(typer.__version__)"

typer[all] 會額外安裝:

  • rich:彩色文字、表格、進度條
  • shellingham:自動偵測使用者的 shell(產生補全用)

第一個 Typer 程式

單命令(最簡單)

# main.py
import typer

def hello(name: str):
    print(f"Hello {name}")

if __name__ == "__main__":
    typer.run(hello)

執行:

$ python main.py World
Hello World

$ python main.py --help
Usage: main.py [OPTIONS] NAME
Arguments:
  NAME  [required]
Options:
  --help  Show this message and exit.

多命令(使用 Typer 應用程式)

# main.py
import typer

app = typer.Typer()

@app.command()
def hello(name: str):
    print(f"Hello {name}")

@app.command()
def goodbye(name: str):
    print(f"Goodbye {name}")

if __name__ == "__main__":
    app()

執行:

$ python main.py hello World
Hello World

$ python main.py goodbye World
Goodbye World

$ python main.py --help
Commands:
  goodbye
  hello

何時用 typer.run() 何時用 Typer():只有一個函式時用 typer.run();有多個子命令時用 app = typer.Typer() + @app.command()。


Arguments vs Options

這是 Typer 最重要的核心觀念——從函式簽名自動分辨。

規則

函式簽名寫法 CLI 行為 範例
name: str Argument(位置參數,必填) myapp World
name: str = "default" Option(選項,選填,有預設值) myapp --name World
name: Optional[str] = None Option(選項,可不傳) myapp 或 myapp --name X

實際範例

import typer

def create(
    title: str,                    # Argument:必填位置參數
    body: str = "",                # Option:--body
    public: bool = False,          # Option:--public / --no-public
):
    print(f"Title: {title}")
    print(f"Body: {body}")
    print(f"Public: {public}")

typer.run(create)
$ python main.py "My Post" --body "Hello" --public
Title: My Post
Body: Hello
Public: True

$ python main.py "My Post" --no-public
Title: My Post
Body:
Public: False

顯式控制:typer.Argument 與 typer.Option

需要更精細控制時(help 文字、別名、驗證),用 typer.Argument() / typer.Option():

import typer

def create(
    title: str = typer.Argument(..., help="文章標題"),
    body: str = typer.Option("", "--body", "-b", help="文章內容"),
    tags: list[str] = typer.Option([], "--tag", "-t", help="標籤(可多個)"),
):
    ...
  • ... (Ellipsis):表示「沒有預設值,必填」
  • "-b":短選項(-b "Hello" 等同 --body "Hello")
  • list[str]:可以多次傳入,例如 --tag a --tag b

型別系統

Typer 會根據 type hint 自動轉換 + 驗證 CLI 輸入。

基本型別

Python 型別 CLI 行為
str 接受任意字串
int 自動轉 int,非數字會報錯
float 自動轉 float
bool 產生 --flag / --no-flag 對

進階型別

Python 型別 CLI 行為
pathlib.Path 自動轉 Path 物件,可加 exists=True 檢查
datetime.datetime 自動解析 ISO 8601 字串
Enum 子類 限制只能輸入列舉值(自動 --help 顯示選項)
list[T] / List[T] 同型別可重複,--item a --item b
Optional[T] 可選,預設為 None

Path 範例(檔案存在驗證)

from pathlib import Path
import typer

def process(
    input_file: Path = typer.Argument(
        ...,
        exists=True,        # 檔案必須存在
        file_okay=True,
        dir_okay=False,
        readable=True,
    ),
):
    content = input_file.read_text()
    print(f"Read {len(content)} chars from {input_file}")

typer.run(process)

Enum 範例(限制選項值)

from enum import Enum
import typer

class LogLevel(str, Enum):
    debug = "debug"
    info = "info"
    warning = "warning"
    error = "error"

def run(level: LogLevel = LogLevel.info):
    print(f"Running with log level: {level.value}")

typer.run(run)
$ python main.py --level debug
Running with log level: debug

$ python main.py --level invalid
Error: Invalid value for '--level': 'invalid' is not one of 'debug', 'info', 'warning', 'error'.

小技巧:讓 Enum 繼承 str 是慣例,這樣 level.value 與 str(level) 行為一致,序列化也方便。


子命令(Sub-commands)

複雜 CLI 通常有多層命令,例如 git commit、git push。Typer 用「巢狀 Typer 應用」實現。

兩層架構

import typer

app = typer.Typer()
users_app = typer.Typer()
items_app = typer.Typer()

app.add_typer(users_app, name="users")
app.add_typer(items_app, name="items")

@users_app.command("create")
def users_create(name: str):
    print(f"Creating user {name}")

@users_app.command("delete")
def users_delete(id: int):
    print(f"Deleting user {id}")

@items_app.command("list")
def items_list():
    print("Listing items")

if __name__ == "__main__":
    app()

執行:

$ python main.py users create Alice
Creating user Alice

$ python main.py users delete 42
Deleting user 42

$ python main.py items list
Listing items

$ python main.py --help
Commands:
  items
  users

命令結構視覺化

myapp
├── users
│   ├── create
│   └── delete
└── items
    └── list

進階功能

互動式輸入(Prompt)

import typer

def login(
    username: str = typer.Option(..., prompt=True),
    password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
):
    print(f"Logging in as {username}")

typer.run(login)

執行時,若沒傳參數會互動式詢問:

Username: alice
Password:
Repeat for confirmation:
Logging in as alice

確認動作(Confirmation)

import typer

def delete(
    name: str,
    force: bool = typer.Option(False, "--force", "-f"),
):
    if not force:
        typer.confirm(f"確定要刪除 {name}?", abort=True)
    print(f"Deleted {name}")

typer.run(delete)

Callback(前置處理)

每次執行任何子命令前都會觸發,常用於設定 logging、版本顯示:

import typer

app = typer.Typer()

def version_callback(value: bool):
    if value:
        print("MyApp v1.0.0")
        raise typer.Exit()

@app.callback()
def main(
    verbose: bool = typer.Option(False, "--verbose", "-v"),
    version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True),
):
    if verbose:
        print("Verbose mode on")

@app.command()
def hello(name: str):
    print(f"Hello {name}")

if __name__ == "__main__":
    app()
  • is_eager=True:優先處理(即使有錯也先跑 version)
  • raise typer.Exit():正常結束 CLI

Context(在子命令間共享資料)

import typer

app = typer.Typer()

@app.callback()
def main(ctx: typer.Context, debug: bool = False):
    ctx.ensure_object(dict)
    ctx.obj["debug"] = debug

@app.command()
def run(ctx: typer.Context):
    if ctx.obj["debug"]:
        print("Debug mode")
    print("Running")

if __name__ == "__main__":
    app()

退出碼控制

import typer

def check(value: int):
    if value < 0:
        typer.echo("負數不被允許", err=True)
        raise typer.Exit(code=1)
    print("OK")

typer.run(check)
操作 退出碼
typer.Exit() 0(正常結束)
typer.Exit(code=N) N
typer.Abort() 1(中止,會印 "Aborted!")

Rich 整合

裝了 typer[all] 後,Typer 自動使用 Rich 美化輸出。

彩色文字

import typer

def main(name: str):
    typer.secho(f"Hello {name}", fg=typer.colors.GREEN, bold=True)
    typer.secho("錯誤訊息", fg=typer.colors.RED, err=True)

typer.run(main)

進度條

import time
import typer

def process(items: int = 100):
    with typer.progressbar(range(items), label="處理中") as progress:
        for i in progress:
            time.sleep(0.01)
    print("完成")

typer.run(process)

直接用 Rich 庫

from rich.console import Console
from rich.table import Table
import typer

console = Console()

def list_users():
    table = Table(title="使用者清單")
    table.add_column("ID", style="cyan")
    table.add_column("Name", style="magenta")
    table.add_row("1", "Alice")
    table.add_row("2", "Bob")
    console.print(table)

typer.run(list_users)

測試

Typer 提供 CliRunner(其實是 Click 的)來測試 CLI:

# test_app.py
from typer.testing import CliRunner
from main import app

runner = CliRunner()

def test_hello():
    result = runner.invoke(app, ["hello", "World"])
    assert result.exit_code == 0
    assert "Hello World" in result.stdout

def test_invalid_input():
    result = runner.invoke(app, ["hello"])  # 缺少 name
    assert result.exit_code != 0
    assert "Missing argument" in result.stdout

執行:

pytest test_app.py -v

測試互動式輸入

def test_interactive():
    result = runner.invoke(app, ["login"], input="alice\npass\npass\n")
    assert "Logging in as alice" in result.stdout

打包與部署

用 entry points 安裝為命令

pyproject.toml:

[project]
name = "myapp"
version = "0.1.0"
dependencies = ["typer[all]"]

[project.scripts]
myapp = "myapp.main:app"

安裝後即可全域呼叫:

pip install -e .
myapp hello World    # 不需要 python main.py

打包成單檔執行檔

用 PyInstaller 或 shiv:

pip install pyinstaller
pyinstaller --onefile main.py
./dist/main hello World    # 不需要 Python 環境

自動產生 shell 補全

# 安裝補全(一次性)
myapp --install-completion

# 重新載入 shell 即可享有 Tab 補全

最佳實踐

1. 用 Annotated(Python 3.9+)寫法更現代

# 舊寫法(仍可用)
def main(name: str = typer.Argument(..., help="使用者名稱")):
    ...

# 新寫法(推薦,Python 3.9+ 含 typing_extensions)
from typing import Annotated

def main(name: Annotated[str, typer.Argument(help="使用者名稱")]):
    ...

新寫法把 Typer 元資料與預設值分離,更符合 PEP 593 標準,IDE 與 mypy 支援更好。

2. 把 CLI 邏輯與商業邏輯分離

# ❌ 把所有邏輯塞進 CLI 函式
def deploy(env: str, force: bool = False):
    # 一堆部署邏輯...
    ...

# ✅ CLI 只負責解析輸入,呼叫純函式
from myapp.deploy import deploy as deploy_logic

def deploy(env: str, force: bool = False):
    deploy_logic(env, force=force)

好處:純函式可獨立測試、可被其他程式碼重用。

3. 善用 Enum 限制選項

# ❌ 字串容易打錯
def deploy(env: str = "prod"):
    if env not in ("dev", "staging", "prod"):
        raise ValueError(...)

# ✅ Enum 自動驗證 + 自動 --help
class Env(str, Enum):
    dev = "dev"
    staging = "staging"
    prod = "prod"

def deploy(env: Env = Env.prod):
    ...

4. 錯誤訊息送 stderr

typer.echo("錯誤訊息", err=True)        # → stderr
typer.secho("錯誤", fg="red", err=True) # → stderr
print("正常輸出")                        # → stdout

讓 shell pipe 行為正確:myapp 2> error.log。

5. 重要操作要 confirm

def delete_all():
    typer.confirm("這會刪除所有資料!確定?", abort=True)
    # ...

常見問題

Q1:Typer 跟 Click 該選哪個?

新專案用 Typer——除非你已經有大量 Click 程式碼或需要 Click 特有功能(如複雜的 plugin 系統)。Typer 寫法更精簡、編輯器支援更好,且底層就是 Click,未來想轉換成本低。

Q2:可以混用 Typer 和 argparse 嗎?

不建議。兩者解析模型不同,混用會讓 --help 不一致。如果只是想保留舊腳本,建議分階段重寫成 Typer。

Q3:怎麼處理「不固定數量的位置參數」?

用 list[str]:

def deploy(envs: list[str]):
    for env in envs:
        print(f"Deploying {env}")
$ python main.py dev staging prod
Deploying dev
Deploying staging
Deploying prod

Q4:怎麼讀取環境變數?

用 envvar 參數:

def main(api_key: str = typer.Option(..., envvar="MY_API_KEY")):
    ...
$ MY_API_KEY=xxx python main.py
$ python main.py --api-key xxx    # 兩種都行

Q5:怎麼讓 Typer 不在錯誤時顯示完整 traceback?

預設行為已經是「發生 typer.Exit() 或 typer.Abort() 時不顯示 traceback」。若你想連 Python exception 也壓下:

app = typer.Typer(pretty_exceptions_enable=False)

Q6:如何讓某些命令不出現在 --help?

@app.command(hidden=True)
def debug_internal():
    ...

或單一選項:

debug: bool = typer.Option(False, "--debug", hidden=True)

總結

核心要點

  1. type hints 自動推斷:函式簽名 = CLI 介面定義
  2. Argument vs Option:「沒有預設值 = Argument」「有預設值 = Option」
  3. Click 的繼承:所有 Click 功能可用,但寫法更現代
  4. Rich 整合:彩色、表格、進度條開箱即用
  5. 編輯器友善:mypy / IDE 自動完成全部可用

速查表

需求 寫法
必填位置參數 name: str
選項(含預設值) name: str = "default"
短選項別名 typer.Option(..., "-n")
多次傳入 tags: list[str]
限制可選值 Enum 子類
檔案路徑驗證 Path = typer.Argument(..., exists=True)
互動式輸入 typer.Option(..., prompt=True)
隱藏輸入 prompt=True, hide_input=True
退出 raise typer.Exit(code=N)
中止 raise typer.Abort()
確認對話 typer.confirm(..., abort=True)
環境變數 typer.Option(..., envvar="VAR")

學習路徑建議

1. typer.run(func)         ← 5 分鐘上手
2. @app.command()          ← 多命令
3. typer.Argument/Option   ← 自訂 help、別名
4. Enum + Path             ← 型別驗證
5. Sub-typer 巢狀          ← 大型 CLI
6. Callback + Context      ← 全域選項與狀態共享
7. Annotated 寫法 + 測試   ← 進階品質

延伸閱讀


建立日期:2026-04-30 最後更新:2026-04-30

🔗相關文章