一. 前言 #
拍拍君在開發 Python 專案的時候,常常需要寫一些小工具腳本——像是批次處理檔案、呼叫 API、轉換資料格式等等。一開始都用 sys.argv 直接抓參數,結果程式碼越寫越亂,每次都要自己處理參數解析、錯誤訊息、--help 說明⋯⋯超累的 😩
後來試過 argparse(Python 標準庫),雖然功能完整,但寫起來就是一堆 add_argument,又長又難讀。
直到遇見了 Typer——一個基於 Python type hints 的 CLI 框架,讓你用最少的程式碼寫出專業級的命令列工具。今天就來帶大家從零開始學 Typer ✨
二. 安裝 Typer #
pip install typer
💡 Typer 底層基於 Click,但 API 設計更現代、更 Pythonic。如果你之前用過 Click,會覺得 Typer 簡直是它的進化版!
三. 第一個 Typer 程式 #
3.1 最簡單的 Hello World #
建立一個 hello.py:
import typer
def main(name: str):
"""跟你打招呼的小工具 👋"""
print(f"你好,{name}!歡迎來到拍拍君的世界 🎉")
if __name__ == "__main__":
typer.run(main)
執行看看:
python hello.py 拍拍君
# 你好,拍拍君!歡迎來到拍拍君的世界 🎉
自動幫你生成 --help:
python hello.py --help
# Usage: hello.py [OPTIONS] NAME
#
# 跟你打招呼的小工具 👋
#
# Arguments:
# NAME [required]
#
# Options:
# --help Show this message and exit.
只要一個函式加上 type hints,Typer 就自動幫你搞定參數解析和幫助文件。零設定!
3.2 加入選擇性參數 #
import typer
def main(name: str, greeting: str = "你好", times: int = 1):
"""客製化打招呼工具"""
for _ in range(times):
print(f"{greeting},{name}!")
if __name__ == "__main__":
typer.run(main)
python hello.py 拍拍君 --greeting 哈囉 --times 3
# 哈囉,拍拍君!
# 哈囉,拍拍君!
# 哈囉,拍拍君!
有預設值的參數自動變成 --option,沒有預設值的就是必填的位置參數(argument)。完全靠 type hints 推斷,不用寫任何設定!
四. 多個子命令:typer.Typer() #
真實的 CLI 工具通常有多個子命令(像 git add、git commit)。用 Typer 超簡單:
import typer
app = typer.Typer(help="拍拍君的檔案管理工具 📁")
@app.command()
def list(directory: str = "."):
"""列出目錄中的檔案"""
from pathlib import Path
for f in Path(directory).iterdir():
icon = "📁" if f.is_dir() else "📄"
print(f" {icon} {f.name}")
@app.command()
def count(directory: str = ".", extension: str = ""):
"""計算目錄中的檔案數量"""
from pathlib import Path
files = list(Path(directory).glob(f"*{extension}")) if extension else list(Path(directory).iterdir())
print(f"共有 {len(files)} 個項目")
@app.command()
def search(directory: str = ".", pattern: str = "*"):
"""搜尋符合 pattern 的檔案"""
from pathlib import Path
for f in Path(directory).rglob(pattern):
print(f" 🔍 {f}")
if __name__ == "__main__":
app()
python filetools.py --help
# Usage: filetools.py [OPTIONS] COMMAND [ARGS]...
#
# 拍拍君的檔案管理工具 📁
#
# Commands:
# list 列出目錄中的檔案
# count 計算目錄中的檔案數量
# search 搜尋符合 pattern 的檔案
python filetools.py list ~/Documents
python filetools.py count . --extension .py
python filetools.py search . --pattern "*.md"
每個用 @app.command() 裝飾的函式就是一個子命令,Typer 自動把函式名稱當作子命令名稱。
五. 參數的進階用法 #
5.1 Argument 與 Option 的差異 #
import typer
from typing import Optional
def main(
# Argument:位置參數(必填)
filename: str = typer.Argument(..., help="要處理的檔案名稱"),
# Option:選項參數(可選)
output: str = typer.Option("output.txt", "--output", "-o", help="輸出檔案名稱"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="顯示詳細資訊"),
):
"""處理檔案的工具"""
if verbose:
print(f"正在處理 {filename}...")
print(f"輸出到 {output}")
print("完成!✅")
if __name__ == "__main__":
typer.run(main)
python process.py data.csv -o result.txt -v
# 正在處理 data.csv...
# 輸出到 result.txt
# 完成!✅
重點整理:
| 類型 | 語法 | 說明 |
|---|---|---|
typer.Argument(...) |
位置參數 | ... 表示必填 |
typer.Option(default) |
選項參數 | 可指定短名稱如 -o |
bool + Option(False) |
旗標 | 自動變成 --verbose / --no-verbose |
5.2 列舉型參數 #
import typer
from enum import Enum
class Color(str, Enum):
red = "red"
green = "green"
blue = "blue"
def main(name: str, color: Color = Color.green):
"""產生彩色問候"""
colors = {"red": "\033[91m", "green": "\033[92m", "blue": "\033[94m"}
reset = "\033[0m"
print(f"{colors[color.value]}你好,{name}!{reset}")
if __name__ == "__main__":
typer.run(main)
python greet.py 拍拍君 --color red
# (紅色的)你好,拍拍君!
python greet.py 拍拍君 --color purple
# Error: Invalid value for '--color': 'purple' is not one of 'red', 'green', 'blue'.
Typer 自動驗證列舉值,輸入錯誤會顯示友善的錯誤訊息!
5.3 Prompt 互動式輸入 #
import typer
def main(
name: str = typer.Option(..., prompt="請輸入你的名字"),
age: int = typer.Option(..., prompt="請輸入你的年齡"),
):
"""互動式問卷"""
print(f"歡迎 {name}({age} 歲)!🎉")
if __name__ == "__main__":
typer.run(main)
python survey.py
# 請輸入你的名字: 拍拍君
# 請輸入你的年齡: 25
# 歡迎 拍拍君(25 歲)!🎉
加上 prompt=True 或指定提示文字,使用者沒給參數時就會互動式詢問。
六. 搭配 Rich 做漂亮輸出 #
Typer 跟 Rich 是天生一對!安裝 rich 後,Typer 會自動用 Rich 來渲染幫助文件和錯誤訊息。
import typer
from rich import print
from rich.table import Table
from rich.console import Console
app = typer.Typer()
console = Console()
@app.command()
def status():
"""顯示系統狀態"""
table = Table(title="🖥️ 系統狀態")
table.add_column("項目", style="cyan")
table.add_column("狀態", style="green")
table.add_row("CPU", "正常 ✅")
table.add_row("記憶體", "使用 62% ⚠️")
table.add_row("磁碟", "剩餘 128 GB ✅")
console.print(table)
@app.command()
def greet(name: str):
"""用漂亮的方式打招呼"""
print(f"[bold magenta]你好,{name}![/bold magenta] [green]歡迎來到拍拍的世界[/green] :sparkles:")
if __name__ == "__main__":
app()
這樣你的 CLI 工具不只功能強大,還有漂亮的彩色輸出!
七. 回呼函式與全域選項 #
有時候你需要所有子命令共用的選項(像 --verbose):
import typer
app = typer.Typer()
state = {"verbose": False}
@app.callback()
def common(verbose: bool = typer.Option(False, "--verbose", "-v", help="顯示詳細資訊")):
"""拍拍君的超級工具 🛠️"""
state["verbose"] = verbose
@app.command()
def create(name: str):
"""建立新項目"""
if state["verbose"]:
print(f"[DEBUG] 正在建立 {name}...")
print(f"已建立 {name} ✅")
@app.command()
def delete(name: str, force: bool = typer.Option(False, "--force", "-f")):
"""刪除項目"""
if not force:
confirm = typer.confirm(f"確定要刪除 {name} 嗎?")
if not confirm:
print("已取消 ❌")
raise typer.Exit()
if state["verbose"]:
print(f"[DEBUG] 正在刪除 {name}...")
print(f"已刪除 {name} 🗑️")
if __name__ == "__main__":
app()
python tool.py -v create my-project
# [DEBUG] 正在建立 my-project...
# 已建立 my-project ✅
python tool.py delete old-stuff
# 確定要刪除 old-stuff 嗎? [y/N]: n
# 已取消 ❌
@app.callback() 的參數會在任何子命令之前執行,適合放全域設定。
八. 錯誤處理與退出碼 #
import typer
from pathlib import Path
def main(filepath: str = typer.Argument(..., help="要讀取的檔案")):
"""安全地讀取檔案內容"""
path = Path(filepath)
if not path.exists():
print(f"❌ 錯誤:找不到檔案 {filepath}")
raise typer.Exit(code=1)
if not path.is_file():
print(f"❌ 錯誤:{filepath} 不是檔案")
raise typer.Exit(code=1)
content = path.read_text()
print(f"📄 檔案內容({len(content)} 字元):")
print(content[:500])
if __name__ == "__main__":
typer.run(main)
用 typer.Exit(code=N) 可以設定退出碼,讓你的工具能夠跟 shell 腳本正確配合。
九. 實戰:打造一個 TODO 管理工具 #
來寫一個完整的例子!一個用 JSON 儲存的 TODO 管理器:
import json
import typer
from pathlib import Path
from rich.console import Console
from rich.table import Table
from datetime import datetime
app = typer.Typer(help="拍拍君的 TODO 管理器 ✅")
console = Console()
TODO_FILE = Path.home() / ".pypy-todos.json"
def load_todos() -> list[dict]:
if TODO_FILE.exists():
return json.loads(TODO_FILE.read_text())
return []
def save_todos(todos: list[dict]):
TODO_FILE.write_text(json.dumps(todos, ensure_ascii=False, indent=2))
@app.command()
def add(task: str, priority: int = typer.Option(3, "--priority", "-p", min=1, max=5)):
"""新增一個待辦事項"""
todos = load_todos()
todo = {
"id": len(todos) + 1,
"task": task,
"priority": priority,
"done": False,
"created": datetime.now().isoformat(),
}
todos.append(todo)
save_todos(todos)
console.print(f"[green]✅ 已新增:[bold]{task}[/bold](優先度 {priority})[/green]")
@app.command(name="list")
def list_todos(show_done: bool = typer.Option(False, "--done", "-d")):
"""列出所有待辦事項"""
todos = load_todos()
if not todos:
console.print("[yellow]📭 目前沒有待辦事項[/yellow]")
return
table = Table(title="📋 拍拍君的 TODO List")
table.add_column("ID", style="dim", width=4)
table.add_column("狀態", width=4)
table.add_column("優先度", width=6)
table.add_column("待辦事項")
table.add_column("建立時間", style="dim")
for todo in sorted(todos, key=lambda t: t["priority"]):
if todo["done"] and not show_done:
continue
status = "✅" if todo["done"] else "⬜"
stars = "⭐" * todo["priority"]
created = todo["created"][:10]
style = "strike dim" if todo["done"] else ""
table.add_row(str(todo["id"]), status, stars, todo["task"], created, style=style)
console.print(table)
@app.command()
def done(todo_id: int = typer.Argument(..., help="要完成的 TODO ID")):
"""標記待辦事項為完成"""
todos = load_todos()
for todo in todos:
if todo["id"] == todo_id:
todo["done"] = True
save_todos(todos)
console.print(f"[green]🎉 已完成:[bold]{todo['task']}[/bold][/green]")
return
console.print(f"[red]❌ 找不到 ID {todo_id} 的待辦事項[/red]")
raise typer.Exit(code=1)
@app.command()
def clear(force: bool = typer.Option(False, "--force", "-f")):
"""清除所有已完成的待辦事項"""
todos = load_todos()
done_count = sum(1 for t in todos if t["done"])
if done_count == 0:
console.print("[yellow]沒有已完成的事項可以清除[/yellow]")
return
if not force:
typer.confirm(f"確定要清除 {done_count} 個已完成事項嗎?", abort=True)
todos = [t for t in todos if not t["done"]]
save_todos(todos)
console.print(f"[green]🗑️ 已清除 {done_count} 個已完成事項[/green]")
if __name__ == "__main__":
app()
用法:
python todo.py add "寫 Typer 教學文章" -p 5
python todo.py add "買咖啡豆" -p 2
python todo.py list
python todo.py done 1
python todo.py list --done
python todo.py clear
這個小工具示範了 Typer 的多數功能:子命令、參數驗證、Rich 表格、互動確認、退出碼處理。
十. 打包成可執行命令 #
寫好的 CLI 工具可以打包成系統命令。在 pyproject.toml 中設定:
[project.scripts]
todo = "todo:app"
然後用 pip install -e . 安裝,就能直接在終端機輸入 todo 來使用了!
如果你用 uv(拍拍君之前介紹過的超快套件管理工具),也可以用:
uv run todo.py add "學 Typer"
十一. Typer vs argparse vs Click #
| 特性 | argparse | Click | Typer |
|---|---|---|---|
| 內建 | ✅ 標準庫 | ❌ 需安裝 | ❌ 需安裝 |
| 學習曲線 | 🟡 中等 | 🟡 中等 | 🟢 簡單 |
| Type Hints | ❌ 不支援 | ❌ 不支援 | ✅ 原生支援 |
| 自動文件 | 🟡 基本 | ✅ 不錯 | ✅ 最漂亮 |
| 程式碼量 | 🔴 很多 | 🟡 中等 | 🟢 最少 |
| 子命令 | 🟡 可以但麻煩 | ✅ 原生支援 | ✅ 原生支援 |
| Rich 整合 | ❌ | ❌ | ✅ 原生支援 |
簡單來說:
- 只是小腳本、不想裝套件 → 用
argparse - 大型專案、需要高度客製化 → 用
Click - 想用最少程式碼寫出漂亮 CLI → 用
Typer✨
十二. 常見陷阱 #
⚠️ 1. bool 參數的行為 #
# ❌ 這樣不會如預期運作
def main(verbose: bool = False):
...
# Typer 會生成 --verbose / --no-verbose 旗標
# ✅ 如果你想要 --verbose 就好(沒有 --no-verbose)
def main(verbose: bool = typer.Option(False, "--verbose", "-v", is_flag=True)):
...
⚠️ 2. list 內建函式被覆蓋 #
如果你的子命令叫 list,記得用 name 參數避免覆蓋 Python 內建的 list:
@app.command(name="list") # 子命令名稱是 list
def list_items(): # 但函式名稱不要用 list
...
⚠️ 3. 必填參數要用 ...
#
# ✅ 用 ... 表示必填
name: str = typer.Argument(..., help="你的名字")
# ❌ 沒有預設值直接用 type hint 也可以
# 但加上 typer.Argument(...) 可以補上 help 說明
十三. 總結 #
今天學了 Typer 這個超讚的 CLI 框架:
- ✅ 零設定:靠 type hints 自動推斷參數
- ✅ 自動文件:
--help內容自動生成 - ✅ 子命令:用
@app.command()輕鬆定義 - ✅ 參數驗證:支援列舉、數值範圍、互動式輸入
- ✅ Rich 整合:漂亮的終端機輸出
- ✅ 好打包:一行設定就能變成系統命令
下次要寫 CLI 工具的時候,試試 Typer 吧!拍拍君保證你會愛上它 😍