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

用 Typer 打造專業 CLI 工具:Python 命令列框架教學

·10 分鐘· loading · loading · ·
Python Typer Cli 開發工具
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 18: 本文

一. 前言
#

拍拍君在開發 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 addgit 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 吧!拍拍君保證你會愛上它 😍

參考資料
#

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

相關文章

讓你的終端機華麗變身:Rich 套件教學
·2 分鐘· loading · loading
Python Rich Cli
少寫一半程式碼:dataclasses 讓你的 Python 類別煥然一新
·6 分鐘· loading · loading
Python Dataclasses Oop 標準庫
Ruff:用 Rust 寫的 Python Linter,快到你會懷疑人生
·4 分鐘· loading · loading
Python Ruff Linter Formatter Code-Quality
超快速 Python 套件管理:uv 完全教學
·6 分鐘· loading · loading
Python Uv Package Manager Rust
Python Typing:讓你的程式碼更安全、更好維護
·4 分鐘· loading · loading
Python Typing Type Hints
Python 資料驗證小幫手:Pydantic
·4 分鐘· loading · loading
Python Pydantic Data Validation