目錄
- 什麼是 Typer?
- 為什麼選 Typer?
- 安裝
- 第一個 Typer 程式
- Arguments vs Options
- 型別系統
- 子命令(Sub-commands)
- 進階功能
- Rich 整合
- 測試
- 打包與部署
- 最佳實踐
- 常見問題
- 總結
什麼是 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 三大優勢
- DRY:型別只寫一次(function signature),不必在裝飾器再寫一遍
- 編輯器支援:因為用標準 type hints,IDE 自動完成、mypy 檢查全自動可用
- 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)
總結
核心要點
- type hints 自動推斷:函式簽名 = CLI 介面定義
- Argument vs Option:「沒有預設值 = Argument」「有預設值 = Option」
- Click 的繼承:所有 Click 功能可用,但寫法更現代
- Rich 整合:彩色、表格、進度條開箱即用
- 編輯器友善: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 寫法 + 測試 ← 進階品質
延伸閱讀
- Typer 官方文件
- Click 官方文件(底層)
- Rich 官方文件(終端美化)
建立日期:2026-04-30 最後更新:2026-04-30