Python Click 命令列工具開發完整教學:從零打造專業 CLI 應用程式
說實話,我第一次用 Python 寫命令列工具的時候,是用內建的 argparse。那段經歷只能用「折磨」來形容——光是定義幾個參數就要寫一堆樣板程式碼,子指令的設定更是讓人頭痛到想翻桌。直到我遇見了 Click,才終於體會到什麼叫做「優雅地寫 CLI」。
Click 是由 Flask 的作者 Armin Ronacher 開發的命令列框架,名字本身就是 "Command Line Interface Creation Kit" 的縮寫。它用 Python 的裝飾器語法來定義命令列介面,寫起來不只簡潔,而且直覺到你幾乎不需要查文件就能上手。如果你對裝飾器還不太熟悉,建議先看看這篇 Python 裝飾器 Decorator 教學,會幫助你更快理解 Click 的設計理念。
為什麼選擇 Click 而非 argparse
我知道你可能會想:Python 內建就有 argparse 了,幹嘛還要裝第三方套件?這個問題我以前也問過自己,但用過 Click 之後就再也回不去了。讓我用一個簡單的例子來說明差異。
假設你要寫一個接受名字和次數的打招呼工具。用 argparse 大概會長這樣:
import argparse
parser = argparse.ArgumentParser(description='打招呼工具')
parser.add_argument('--name', required=True, help='你的名字')
parser.add_argument('--count', default=1, type=int, help='重複次數')
args = parser.parse_args()
for _ in range(args.count):
print(f'哈囉,{args.name}!')同樣的功能用 Click 來寫:
import click
@click.command()
@click.option('--name', required=True, help='你的名字')
@click.option('--count', default=1, help='重複次數')
def greet(name, count):
"""打招呼工具"""
for _ in range(count):
click.echo(f'哈囉,{name}!')
if __name__ == '__main__':
greet()看起來差不多?但等你要加子指令、參數驗證、互動式輸入的時候,Click 的優勢就會一目了然。argparse 的程式碼會急遽膨脹,而 Click 始終保持簡潔可讀。
Click 還有幾個關鍵優勢:自動生成的 help 訊息更漂亮、原生支援檔案處理、跨平台的 Unicode 支援,以及完善的測試工具。這些在 argparse 裡要嘛不支援,要嘛得自己硬幹。
安裝非常簡單,推薦使用現代化的套件管理工具,可以參考 Python uv 套件管理器教學 來設定你的開發環境:
pip install clickClick 基礎:@click.command 與 @click.option
Click 的核心概念超級簡單:用 @click.command() 裝飾器把一個普通函式變成命令列工具,用 @click.option() 定義選項參數,用 @click.argument() 定義位置參數。
先來搞清楚 option 和 argument 的差別。Option 是帶有旗標的可選參數,像 --name Alice;Argument 則是按位置傳入的必要參數,像 mycommand input.txt。一般來說,option 用在可選或有預設值的東西,argument 用在必須提供的核心輸入。
import click
@click.command()
@click.argument('filename')
@click.option('--output', '-o', default='result.txt', help='輸出檔案名稱')
@click.option('--verbose', '-v', is_flag=True, help='顯示詳細資訊')
def process(filename, output, verbose):
"""處理指定的檔案"""
if verbose:
click.echo(f'正在處理 {filename}...')
click.echo(f'結果將輸出到 {output}')
if __name__ == '__main__':
process()注意幾個重點:option 可以設定短名稱(像 -v),可以用 is_flag=True 做布林旗標,也可以設定 default 預設值。Click 會自動把參數名稱轉成函式參數,所以 --output 就直接對應到 output 參數。
另外一個實用的功能是 multiple=True,讓同一個選項可以傳入多次:
@click.command()
@click.option('--tag', '-t', multiple=True, help='標籤(可多個)')
def tag_file(tag):
for t in tag:
click.echo(f'新增標籤:{t}')執行 tag-file -t python -t cli -t tutorial 就會依序印出三個標籤。這在 argparse 裡要用 nargs 搞半天的功能,Click 一個參數就解決了。
參數類型與驗證
Click 內建了豐富的參數類型系統,遠比 argparse 的 type 參數來得強大。最常用的幾個:
click.Choice 限制選項只能從指定清單中選擇:
@click.option('--format', type=click.Choice(['json', 'csv', 'xml'], case_sensitive=False))
def export(format):
click.echo(f'匯出為 {format} 格式')click.Path 驗證檔案或目錄路徑是否存在:
@click.option('--config', type=click.Path(exists=True, readable=True, resolve_path=True))
def load(config):
click.echo(f'載入設定檔:{config}')click.Path 可以設定 exists=True 確保檔案存在、dir_okay=False 限制只接受檔案、resolve_path=True 自動轉成絕對路徑。這些在處理檔案操作時非常好用,不用再自己寫一堆 os.path.exists() 驗證。
click.IntRange 和 click.FloatRange 限制數值範圍:
@click.option('--port', type=click.IntRange(1024, 65535), default=8080)
def serve(port):
click.echo(f'伺服器啟動在 port {port}')如果你有特殊需求,也可以自訂參數型別。只要繼承 click.ParamType 並實作 convert 方法就好:
class DateType(click.ParamType):
name = 'date'
def convert(self, value, param, ctx):
try:
from datetime import datetime
return datetime.strptime(value, '%Y-%m-%d').date()
except ValueError:
self.fail(f'{value} 不是有效的日期格式,請使用 YYYY-MM-DD', param, ctx)
DATE = DateType()
@click.option('--since', type=DATE, help='開始日期 (YYYY-MM-DD)')
def report(since):
click.echo(f'產生自 {since} 以來的報告')子指令與 Group 設計
當你的 CLI 工具功能變多,就需要子指令來組織結構。想想 git 有 git commit、git push、git pull——這就是子指令的經典範例。Click 用 @click.group() 來實現,簡單到不行。
@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
"""我的超強 CLI 工具"""
ctx.ensure_object(dict)
ctx.obj['DEBUG'] = debug
@cli.command()
@click.argument('name')
@click.pass_context
def create(ctx, name):
"""建立新專案"""
if ctx.obj['DEBUG']:
click.echo('[DEBUG] 建立專案中...')
click.echo(f'專案 {name} 已建立!')
@cli.command()
@click.argument('name')
@click.option('--force', is_flag=True, help='強制刪除')
def delete(name, force):
"""刪除指定專案"""
if force or click.confirm(f'確定要刪除 {name}?'):
click.echo(f'專案 {name} 已刪除')這裡用到了 click.pass_context,它讓你可以在不同的子指令之間共享資料。父指令設定的 debug 旗標,子指令透過 ctx.obj 就能存取。
你也可以用巢狀 Group 做更深層的結構,像 mytool db migrate 這種多層子指令:
@cli.group()
def db():
"""資料庫相關操作"""
pass
@db.command()
def migrate():
"""執行資料庫遷移"""
click.echo('遷移完成')
@db.command()
def seed():
"""填入測試資料"""
click.echo('已填入測試資料')互動式輸入
有時候你不想讓使用者一次把所有參數都打在命令列上,而是引導他們一步步輸入。Click 的互動式功能這時就派上用場了。
click.prompt 是最基本的互動輸入:
name = click.prompt('請輸入你的名字')
age = click.prompt('年齡', type=int)
email = click.prompt('Email', default='[email protected]')click.confirm 用來做是/否確認,超級適合危險操作前的二次確認:
if click.confirm('確定要清空所有資料嗎?', abort=True):
click.echo('資料已清空')
# 設定 abort=True 的話,使用者按 N 會直接中斷程式密碼輸入 用 hide_input=True,輸入時不會顯示在終端機上:
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True)
def login(password):
click.echo('登入成功')這段程式碼會提示使用者輸入密碼兩次做確認,而且輸入過程完全隱藏。如果你需要記錄這些操作的日誌,可以搭配 Python logging 日誌模組教學 裡介紹的技巧。
另一個我超愛的功能是 click.edit(),它會打開系統預設的文字編輯器讓使用者輸入大段文字:
message = click.edit('在這裡輸入你的訊息...')
if message:
click.echo(f'你輸入了:{message}')輸出美化
命令列工具不代表輸出就要醜醜的。Click 提供了幾個好用的輸出工具,讓你的 CLI 看起來更專業。
click.echo 是 Click 版的 print,它處理了跨平台的編碼問題,比原生 print 更可靠:
click.echo('一般訊息')
click.echo('錯誤訊息', err=True) # 輸出到 stderrclick.style 讓你的文字帶有顏色和樣式:
click.echo(click.style('成功!', fg='green', bold=True))
click.echo(click.style('警告', fg='yellow'))
click.echo(click.style('錯誤', fg='red', underline=True))
# 或用快捷方式 click.secho
click.secho('這是綠色粗體', fg='green', bold=True)進度條 處理大量資料時特別有用:
import time
with click.progressbar(range(100), label='處理中') as bar:
for item in bar:
time.sleep(0.05) # 模擬工作Click 的進度條會自動計算剩餘時間和完成百分比,不需要你自己算。對於要處理大量檔案的情境,這個功能簡直是救星。
你也可以用 click.clear() 清除終端機畫面,或用 click.pause() 暫停等待使用者按鍵。這些小工具組合起來,就能做出使用體驗很好的 CLI 應用。
實戰:打造檔案管理 CLI 工具
好,理論講夠了,來動手做一個真正有用的東西。我們要打造一個檔案管理 CLI 工具叫 fmgr,支援以下功能:列出檔案、搜尋檔案、統計目錄大小。
import os
import click
from pathlib import Path
from datetime import datetime
@click.group()
@click.version_option(version='1.0.0')
def fmgr():
"""fmgr - 你的檔案管理好幫手"""
pass
@fmgr.command()
@click.argument('directory', type=click.Path(exists=True, file_okay=False))
@click.option('--sort', type=click.Choice(['name', 'size', 'date']), default='name')
@click.option('--reverse', is_flag=True, help='反向排序')
@click.option('--hidden', is_flag=True, help='顯示隱藏檔案')
def ls(directory, sort, reverse, hidden):
"""列出目錄中的檔案"""
path = Path(directory)
entries = []
for entry in path.iterdir():
if not hidden and entry.name.startswith('.'):
continue
stat = entry.stat()
entries.append({
'name': entry.name,
'size': stat.st_size,
'date': datetime.fromtimestamp(stat.st_mtime),
'is_dir': entry.is_dir()
})
sort_key = {'name': 'name', 'size': 'size', 'date': 'date'}[sort]
entries.sort(key=lambda x: x[sort_key], reverse=reverse)
for entry in entries:
icon = '📁' if entry['is_dir'] else '📄'
size = _format_size(entry['size'])
date = entry['date'].strftime('%Y-%m-%d %H:%M')
name_style = 'blue' if entry['is_dir'] else 'white'
click.echo(f" {icon} {click.style(entry['name'], fg=name_style):<30s} {size:>10s} {date}")
@fmgr.command()
@click.argument('pattern')
@click.option('--path', '-p', type=click.Path(exists=True), default='.', help='搜尋路徑')
@click.option('--ext', '-e', help='篩選副檔名(如 .py .txt)')
def search(pattern, path, ext):
"""搜尋符合條件的檔案"""
search_path = Path(path)
found = 0
with click.progressbar(
list(search_path.rglob('*')),
label='搜尋中'
) as entries:
for entry in entries:
if not entry.is_file():
continue
if ext and entry.suffix != ext:
continue
if pattern.lower() in entry.name.lower():
found += 1
click.echo(f'\n 找到:{entry}')
click.secho(f'\n共找到 {found} 個檔案', fg='green', bold=True)
@fmgr.command()
@click.argument('directory', type=click.Path(exists=True, file_okay=False))
def stats(directory):
"""統計目錄的檔案資訊"""
path = Path(directory)
total_size = 0
file_count = 0
dir_count = 0
ext_stats = {}
for entry in path.rglob('*'):
if entry.is_file():
file_count += 1
size = entry.stat().st_size
total_size += size
ext = entry.suffix or '(無副檔名)'
ext_stats[ext] = ext_stats.get(ext, 0) + 1
elif entry.is_dir():
dir_count += 1
click.secho(f'\n目錄統計:{directory}', fg='cyan', bold=True)
click.echo(f' 檔案數量:{file_count}')
click.echo(f' 資料夾數:{dir_count}')
click.echo(f' 總大小: {_format_size(total_size)}')
click.echo(f'\n 檔案類型分布:')
for ext, count in sorted(ext_stats.items(), key=lambda x: -x[1])[:10]:
bar = '█' * min(count, 30)
click.echo(f' {ext:<12s} {count:>5d} {click.style(bar, fg="cyan")}')
def _format_size(size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f'{size:.1f} {unit}'
size /= 1024
return f'{size:.1f} TB'
if __name__ == '__main__':
fmgr()這個工具雖然簡單,但已經展示了 Click 的多項核心功能:Group 子指令架構、多種參數類型、進度條、彩色輸出。你可以在這個基礎上繼續擴充,加入複製、移動、批次重新命名等功能。
打包與發布到 PyPI
寫好了 CLI 工具,當然要分享給其他人用嘛。Click 工具透過 setuptools 的 entry_points 就能輕鬆打包成系統命令。
首先,建立專案結構:
fmgr/
├── pyproject.toml
├── README.md
├── src/
│ └── fmgr/
│ ├── __init__.py
│ └── cli.py
└── tests/
└── test_cli.py在 pyproject.toml 中設定 entry point:
[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "fmgr"
version = "1.0.0"
description = "一個好用的檔案管理 CLI 工具"
requires-python = ">=3.9"
dependencies = ["click>=8.0"]
[project.scripts]
fmgr = "fmgr.cli:fmgr"最重要的是 [project.scripts] 這段,它告訴 Python 安裝這個套件時,要建立一個叫 fmgr 的系統命令,指向 fmgr.cli 模組裡的 fmgr 函式。
測試也別忘了。Click 提供了 CliRunner 來方便你寫測試:
from click.testing import CliRunner
from fmgr.cli import fmgr
def test_ls_command():
runner = CliRunner()
result = runner.invoke(fmgr, ['ls', '.'])
assert result.exit_code == 0
def test_stats_command():
runner = CliRunner()
result = runner.invoke(fmgr, ['stats', '.'])
assert result.exit_code == 0
assert '檔案數量' in result.output
def test_search_not_found():
runner = CliRunner()
result = runner.invoke(fmgr, ['search', 'xyznonexistent'])
assert '共找到 0 個檔案' in result.output打包和上傳到 PyPI:
# 安裝打包工具
pip install build twine
# 打包
python -m build
# 上傳到 PyPI(需要帳號)
twine upload dist/*上傳之後,全世界的人都能用 pip install fmgr 來安裝你的工具了。是不是很有成就感?
說真的,Click 徹底改變了我開發 CLI 工具的方式。以前一想到要寫命令列介面就煩,現在反而覺得是件樂事。如果你也有類似的痛點,強烈建議你花個下午的時間把 Click 學起來——這大概是投資報酬率最高的 Python 學習之一。整個框架的設計哲學就是讓開發者開心,而它確實做到了。
繼續閱讀
Python PDF 自動化教學:用 PyPDF2 實現合併、分割與批量處理報表的完整指南
用 Python PyPDF2 實現 PDF 合併、分割、加浮水印、提取文字與批量處理,附完整程式碼範例與實戰工作流程。
相關文章
你可能也喜歡
探索其他領域的精選好文