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

Python click:比 argparse 更優雅的 CLI 框架

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

一、前言
#

寫 Python 腳本的時候,你一定碰過這種情境:腳本寫好了,但每次要改參數就得去改程式碼。於是你開始用 argparse 加上命令列參數⋯⋯然後發現自己花在 parser 設定上的時間,比寫邏輯還多 😅

如果你曾經受夠了 argparse 的冗長設定,今天拍拍君要介紹的 click 絕對會讓你眼前一亮。click 用 Python 裝飾器(decorator)的方式定義 CLI,語法直覺、功能強大,而且是 Flask 團隊(Pallets Projects)出品的 — 品質有保障!

💡 之前我們介紹過 Typer,它其實就是建構在 click 之上的!學會 click,你不只多了一個工具,更能理解 Typer 的底層運作。

二、安裝
#

click 已經是很多套件的依賴(包括 Flask),你的環境裡搞不好已經有了:

# 用 pip 安裝
pip install click

# 或用 uv(推薦!)
uv pip install click

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

目前最新穩定版是 8.1.x,支援 Python 3.7+。

三、Hello Click — 第一個 CLI
#

先來看看 click 版的 Hello World:

# hello.py
import click

@click.command()
@click.option("--name", "-n", default="拍拍君", help="你的名字")
@click.option("--count", "-c", default=1, help="打招呼幾次")
def hello(name, count):
    """一個簡單的打招呼程式 🐍"""
    for _ in range(count):
        click.echo(f"哈囉,{name}!")

if __name__ == "__main__":
    hello()

執行看看:

$ python hello.py --name 拍拍醬 --count 3
哈囉,拍拍醬!
哈囉,拍拍醬!
哈囉,拍拍醬!

$ python hello.py --help
Usage: hello.py [OPTIONS]

  一個簡單的打招呼程式 🐍

Options:
  -n, --name TEXT     你的名字
  -c, --count INTEGER 打招呼幾次
  --help              Show this message and exit.

argparse 比一下:

# argparse 版本 — 感受一下差異
import argparse

parser = argparse.ArgumentParser(description="一個簡單的打招呼程式")
parser.add_argument("--name", "-n", default="拍拍君", help="你的名字")
parser.add_argument("--count", "-c", type=int, default=1, help="打招呼幾次")
args = parser.parse_args()

for _ in range(args.count):
    print(f"哈囉,{args.name}!")

click 版本不只更簡潔,型別轉換也是自動的(從 default 推斷),而且 help 文字直接寫在 docstring 裡。

四、參數類型:Option vs Argument
#

click 把命令列參數分成兩種:

Option(選項)— 用 -- 開頭
#

@click.command()
@click.option("--verbose", "-v", is_flag=True, help="顯示詳細資訊")
@click.option("--output", "-o", type=click.Path(), help="輸出檔案路徑")
@click.option("--format", "fmt", type=click.Choice(["json", "csv", "table"]),
              default="table", help="輸出格式")
def process(verbose, output, fmt):
    """處理資料並輸出"""
    if verbose:
        click.echo("🔍 詳細模式已開啟")
    click.echo(f"輸出格式:{fmt}")
    if output:
        click.echo(f"輸出到:{output}")

注意幾個重點:

  • is_flag=True — 布林開關,不需要值
  • type=click.Path() — 檔案路徑型別,可以加 exists=True 驗證
  • type=click.Choice([...]) — 限定選項,輸入錯誤會自動提示
  • 第二個參數 "fmt" — 當 option 名跟 Python 關鍵字衝突時,用這個當變數名

Argument(引數)— 位置參數
#

@click.command()
@click.argument("src", type=click.Path(exists=True))
@click.argument("dst", type=click.Path())
def copy_file(src, dst):
    """複製檔案從 SRC 到 DST"""
    click.echo(f"📋 複製 {src}{dst}")
$ python copy.py myfile.txt backup.txt
📋 複製 myfile.txt → backup.txt

🔑 Option vs Argument 的選擇原則:必填的用 Argument,可選的用 Option。Argument 不會出現在 --help 的 Options 區,但會顯示在 Usage 行。

五、進階 Option 技巧
#

5.1 密碼輸入(隱藏回顯)
#

@click.command()
@click.option("--password", prompt=True, hide_input=True,
              confirmation_prompt=True, help="設定密碼")
def set_password(password):
    """設定新密碼"""
    click.echo(f"✅ 密碼已設定(長度:{len(password)})")
$ python set_pw.py
Password: ****
Repeat for confirmation: ****
✅ 密碼已設定(長度:8)

或者更簡潔地用 @click.password_option()

@click.command()
@click.password_option()
def set_password(password):
    click.echo(f"✅ 密碼已設定(長度:{len(password)})")

5.2 環境變數綁定
#

@click.command()
@click.option("--api-key", envvar="PYPY_API_KEY", help="API 金鑰")
@click.option("--debug", envvar="PYPY_DEBUG", is_flag=True, help="除錯模式")
def connect(api_key, debug):
    """連接到拍拍服務"""
    if debug:
        click.echo(f"🔑 API Key: {api_key[:4]}****")
    click.echo("✅ 已連接!")
$ export PYPY_API_KEY="sk-1234567890"
$ python connect.py
✅ 已連接!

5.3 多值選項
#

@click.command()
@click.option("--include", "-i", multiple=True, help="要包含的檔案模式")
def build(include):
    """建置專案"""
    for pattern in include:
        click.echo(f"📦 包含:{pattern}")
$ python build.py -i "*.py" -i "*.md" -i "*.toml"
📦 包含:*.py
📦 包含:*.md
📦 包含:*.toml

5.4 數值範圍驗證
#

@click.command()
@click.option("--port", type=click.IntRange(1024, 65535), default=8080,
              help="伺服器埠號(1024-65535)")
@click.option("--workers", type=click.IntRange(min=1), default=4,
              help="工作行程數")
def serve(port, workers):
    """啟動伺服器"""
    click.echo(f"🚀 啟動伺服器 port={port}, workers={workers}")
$ python serve.py --port 80
Error: Invalid value for '--port': 80 is not in the range 1024<=x<=65535.

六、子命令群組(Group)
#

這是 click 最強大的功能之一!你可以把多個命令組織成群組,就像 gitgit commitgit push 一樣:

# chatptt.py — 拍拍君的聊天管理工具
import click

@click.group()
@click.version_option(version="1.0.0")
def cli():
    """chatPTT — 拍拍君的聊天管理工具 🐍"""
    pass

@cli.command()
@click.argument("room_name")
@click.option("--private", is_flag=True, help="建立私人聊天室")
def create(room_name, private):
    """建立新聊天室"""
    room_type = "🔒 私人" if private else "🌐 公開"
    click.echo(f"✅ 已建立{room_type}聊天室:{room_name}")

@cli.command()
@click.option("--active", is_flag=True, help="只顯示活躍的聊天室")
def list_rooms(active):
    """列出所有聊天室"""
    rooms = ["general", "random", "python-help", "拍拍大廳"]
    if active:
        rooms = rooms[:2]  # 假設只有前兩個活躍
    for room in rooms:
        click.echo(f"  💬 {room}")

@cli.command()
@click.argument("room_name")
@click.argument("message")
@click.option("--as-user", default="拍拍君", help="以誰的身份發送")
def send(room_name, message, as_user):
    """發送訊息到聊天室"""
    click.echo(f"[{as_user}] → #{room_name}: {message}")

if __name__ == "__main__":
    cli()

使用方式:

$ python chatptt.py --version
chatPTT, version 1.0.0

$ python chatptt.py --help
Usage: chatptt.py [OPTIONS] COMMAND [ARGS]...

  chatPTT — 拍拍君的聊天管理工具 🐍

Options:
  --version  Show the version and exit.
  --help     Show this message and exit.

Commands:
  create      建立新聊天室
  list-rooms  列出所有聊天室
  send        發送訊息到聊天室

$ python chatptt.py create 讀書會 --private
✅ 已建立🔒 私人聊天室:讀書會

$ python chatptt.py send general "大家好!"
[拍拍君]#general: 大家好!

巢狀子命令
#

群組可以巢狀!適合更複雜的 CLI:

@cli.group()
def admin():
    """管理員功能"""
    pass

@admin.command()
@click.argument("username")
def ban(username):
    """封鎖使用者"""
    click.echo(f"🚫 已封鎖:{username}")

@admin.command()
@click.argument("username")
def unban(username):
    """解除封鎖"""
    click.echo(f"✅ 已解封:{username}")
$ python chatptt.py admin ban trollUser123
🚫 已封鎖:trollUser123

七、Context 共享與回呼
#

當你需要在群組和子命令之間共享設定時,click 的 Context 就派上用場了:

@click.group()
@click.option("--config", type=click.Path(exists=True), help="設定檔路徑")
@click.option("--verbose", "-v", is_flag=True, help="詳細輸出")
@click.pass_context
def cli(ctx, config, verbose):
    """我的 CLI 工具"""
    # 把設定存在 context 裡
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose
    ctx.obj["config"] = config

@cli.command()
@click.argument("name")
@click.pass_context
def greet(ctx, name):
    """打招呼"""
    if ctx.obj["verbose"]:
        click.echo(f"(config: {ctx.obj['config']})")
    click.echo(f"哈囉,{name}!")
$ python app.py --verbose greet 拍拍醬
(config: None)
哈囉,拍拍醬!

八、輸出美化與互動
#

8.1 彩色輸出
#

@click.command()
def status():
    """顯示系統狀態"""
    click.secho("✅ 資料庫:正常", fg="green")
    click.secho("⚠️  快取:使用率 87%", fg="yellow", bold=True)
    click.secho("❌ 郵件服務:離線", fg="red")
    click.secho("ℹ️  版本:v2.1.0", fg="blue", underline=True)

8.2 進度條
#

import time

@click.command()
@click.argument("files", nargs=-1, type=click.Path(exists=True))
def upload(files):
    """上傳檔案"""
    with click.progressbar(files, label="上傳中") as bar:
        for f in bar:
            time.sleep(0.5)  # 模擬上傳
    click.echo("✅ 全部上傳完成!")
$ python upload.py file1.txt file2.txt file3.txt
上傳中  [####################################]  100%
✅ 全部上傳完成!

8.3 互動式確認與提示
#

@click.command()
@click.argument("path", type=click.Path(exists=True))
@click.option("--force", is_flag=True, help="跳過確認")
def delete(path, force):
    """刪除檔案"""
    if not force:
        click.confirm(f"⚠️  確定要刪除 {path} 嗎?", abort=True)
    click.echo(f"🗑️  已刪除:{path}")
$ python delete.py important.txt
⚠️  確定要刪除 important.txt 嗎? [y/N]: n
Aborted!

九、實戰範例:迷你任務管理器
#

把前面學的全部串起來,做一個完整的 CLI 小工具:

# tasks.py — 拍拍君的任務管理器
import json
import click
from pathlib import Path

TASKS_FILE = Path.home() / ".pypy_tasks.json"


def load_tasks():
    if TASKS_FILE.exists():
        return json.loads(TASKS_FILE.read_text())
    return []


def save_tasks(tasks):
    TASKS_FILE.write_text(json.dumps(tasks, ensure_ascii=False, indent=2))


@click.group()
@click.version_option(version="0.1.0")
def cli():
    """🐍 拍拍任務管理器 — 用命令列掌控你的 TODO"""
    pass


@cli.command()
@click.argument("title")
@click.option("--priority", "-p",
              type=click.Choice(["low", "medium", "high"]),
              default="medium", help="優先級")
def add(title, priority):
    """新增任務"""
    tasks = load_tasks()
    task = {
        "id": len(tasks) + 1,
        "title": title,
        "priority": priority,
        "done": False,
    }
    tasks.append(task)
    save_tasks(tasks)
    emoji = {"low": "🟢", "medium": "🟡", "high": "🔴"}
    click.secho(f"✅ 已新增:{emoji[priority]} {title}", fg="green")


@cli.command("list")
@click.option("--all", "show_all", is_flag=True, help="顯示已完成的任務")
@click.option("--priority", "-p",
              type=click.Choice(["low", "medium", "high"]),
              help="依優先級篩選")
def list_tasks(show_all, priority):
    """列出任務"""
    tasks = load_tasks()
    if not show_all:
        tasks = [t for t in tasks if not t["done"]]
    if priority:
        tasks = [t for t in tasks if t["priority"] == priority]

    if not tasks:
        click.echo("📭 沒有任務!去加一些吧~")
        return

    emoji = {"low": "🟢", "medium": "🟡", "high": "🔴"}
    for t in tasks:
        status = "✅" if t["done"] else "⬜"
        click.echo(f"  {status} [{t['id']}] {emoji[t['priority']]} {t['title']}")


@cli.command()
@click.argument("task_id", type=int)
def done(task_id):
    """標記任務完成"""
    tasks = load_tasks()
    for t in tasks:
        if t["id"] == task_id:
            t["done"] = True
            save_tasks(tasks)
            click.secho(f"🎉 完成:{t['title']}", fg="green")
            return
    click.secho(f"❌ 找不到 ID {task_id} 的任務", fg="red")


@cli.command()
@click.argument("task_id", type=int)
@click.option("--force", is_flag=True, help="跳過確認")
def remove(task_id, force):
    """刪除任務"""
    tasks = load_tasks()
    task = next((t for t in tasks if t["id"] == task_id), None)
    if not task:
        click.secho(f"❌ 找不到 ID {task_id} 的任務", fg="red")
        return
    if not force:
        click.confirm(f"確定要刪除「{task['title']}」嗎?", abort=True)
    tasks = [t for t in tasks if t["id"] != task_id]
    save_tasks(tasks)
    click.secho(f"🗑️  已刪除:{task['title']}", fg="yellow")


if __name__ == "__main__":
    cli()

試跑:

$ python tasks.py add "寫 click 教學文" -p high
✅ 已新增:🔴 寫 click 教學文

$ python tasks.py add "買咖啡" -p low
✅ 已新增:🟢 買咖啡

$ python tasks.py list
[1] 🔴 寫 click 教學文
[2] 🟢 買咖啡

$ python tasks.py done 1
🎉 完成:寫 click 教學文

$ python tasks.py list
[2] 🟢 買咖啡

$ python tasks.py list --all
[1] 🔴 寫 click 教學文
[2] 🟢 買咖啡

十、click vs argparse vs Typer 比較
#

特性 argparse click Typer
內建 ✅ 標準庫 ❌ 需安裝 ❌ 需安裝
語法風格 命令式 裝飾器 型別提示
子命令 手動設定 @group 自動
自動補全 外掛支援 ✅ 內建
彩色輸出 secho ✅(用 Rich)
型別驗證 手動 半自動 全自動
底層 Werkzeug click
學習曲線 最低

🤔 怎麼選?

  • 不想裝額外套件 → argparse
  • 要彈性、要完全控制 → click
  • 追求最少程式碼、現代 Python → Typer(底層就是 click)

結語
#

click 是 Python CLI 生態系的中流砥柱 — 它夠成熟、夠穩定、文件完整,而且設計理念「可組合」(composable)讓你的 CLI 工具可以從簡單腳本一路長成複雜應用。

今天我們學了:

  • 🎯 用 @click.command()@click.option() 快速定義 CLI
  • 📋 Option vs Argument 的差異與使用時機
  • 🔧 進階功能:密碼、環境變數、多值、範圍驗證
  • 🗂️ 子命令群組讓 CLI 結構化
  • 🎨 彩色輸出、進度條、互動提示
  • 🛠️ 實戰任務管理器完整範例

如果你之前用過 Typer,現在回頭看 click 會有「啊,原來 Typer 底下是這樣做的」的恍然大悟感。兩個都學起來,以後不管什麼場景都能搞定!

Happy clicking! 🖱️

延伸閱讀
#

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

相關文章

用 Typer 打造專業 CLI 工具:Python 命令列框架教學
·10 分鐘· loading · loading
Python Typer Cli 開發工具
Python subprocess:外部命令執行與管道串接完全指南
·8 分鐘· loading · loading
Python Subprocess Shell Automation Cli
讓你的終端機華麗變身:Rich 套件教學
·2 分鐘· loading · loading
Python Rich Cli
Rust CLI 實戰:用 clap 打造命令列工具(Python Typer 對照版)
·5 分鐘· loading · loading
Rust Cli Clap Typer Python
Python Profiling:cProfile + line_profiler 效能分析完全指南
·8 分鐘· loading · loading
Python Profiling CProfile Line_profiler Performance Optimization
MLX 入門教學:在 Apple Silicon 上跑機器學習
·4 分鐘· loading · loading
Python Mlx Apple-Silicon Machine-Learning Deep-Learning