使用 Python - Click Module 將功能齊全的 CLI 添加到您的 Python 腳本/應用程式。

使用 Python - Click Module 將功能齊全的 CLI 添加到您的 Python 腳本/應用程式。

1. 本視頻的重點摘要

本文章的來源視頻取自於 Dr. Fred 於其 MathByte Academy 油管頻道中於 2022 年 8 月所發佈的影片Add a full-featured CLI to your Python scripts/apps using the click library.,視頻中使用的範例實作則發表於Github - Using click to build a Python command line tool,Fred 老師在影片中提供了一個關於如何使用 Python 的 Click 模組來創建命令行界面(CLI)的詳細介紹。視頻從安裝 Click 開始,然後逐步介紹如何定義命令、處理參數和選項,並組織這些命令成一個結構化的 CLI 應用。老師強調了 Click 相對於標準庫中的 argparse 的優勢,特別是在易用性和功能性方面。此外,視頻還展示了如何使用 Click 的裝飾器來簡化命令行應用的開發過程。

由於老師已經非常細節的介紹了使用 Click 模組的方法,因此本文將不再重複介紹,而是對視頻中的內容進行摘要和導讀,以便讓大家能夠快速了解 Click 模組的基本用法。

2. Click 模組的精要介紹

Click 是一個 Python 模組,用於創建命令行界面(CLI)。它提供了一個簡單而強大的方式來定義命令、處理參數和選項,並組織這些命令成一個結構化的 CLI 應用。

Click 的設計目標是易用性和功能性,它提供了許多方便的功能,例如自動生成幫助文檔、自動解析命令行參數、自動檢查參數的類型和範圍等。

Click 通過裝飾器將目標函式轉為一個 Click 命令列工具 click.command()。然後許多的函式可透過 click.group 再組成群組,使其成為命令列下一組統一介面的指令集。

在整理 Click 模組的相關知識過程中,有找到一個不錯的關於 click 模組的學習網站,也在這裡分享給大家,而這篇筆記有部分的觀點也是出自該網站,連結如下:Click 中文文檔

3. 為什麼用 Click?

除了 Click 之外沒有一個包含以下特性的單獨為 Python 服務的命令列工具:

  • 沒有限制可以簡單組合
  • 完全遵循 Unix 命令列約定
  • 支援從環境變數中載入值
  • 支援特定值的提示
  • 充分地可巢狀可組合
  • 相容python 2 和 3
  • 支援外部檔案處理
  • 與一些常用的工具幫手結合 (獲得 terminal 大小, ANSI 字元顏色, 直接輸入按鍵, 螢幕清理, 獲取 config 路徑, 啟動 apps 和 editors, 等.)

有很多 Click 的替代選擇,如果你更喜歡它們,可以去看一看。最常見的是 Python 標準庫中的 optparseargparse

Click 實際上是通過對 optparse 的一個分支進行封裝而實現的,它本身並不進行解析。不基於 argparse 的原因是 argparse 從設計上不允許多個命令的巢狀, 同時在相容處理 POSIX 參數時有一些不足。

Click 的設計原則是幫助你在使用過程中更有趣,但是並不是以你的角度出發。它也不太靈活。比如說目前情況下,它不允許你定製太多幫助頁面的內容。這樣做事故意的,因為 Click 的設計允許你去巢狀命令列程序。然而過多的定製會影響這個功能。

4. 為什麼不用 Argparse?

Click 基於 optparse 而不是 argparse。這是因為一個使用者不需要關心的實現細節。Click 不使用 argparse 的原因是在處理任意的命令列介面時有一些問題:

  • argparse有內建的魔術行為來猜測什麼是參數或選項。這使得在處理不完整命令列命令時就成了一個問題,因為沒有一個完整的命令列命令就無法得知解析器將如何運行。

這和 Click 所需的解析器背道相馳。 * argparse 目前還不支援停用 interspersed 參數。沒有這個特性將無法保證安全地運行 Click 的巢狀解析特性。

5. Click 的特點

Click 致力於通過以下幾點完全支援可組合的命令列使用者介面:

  • Click 不只是解析,它還將分配合適的程式碼。
  • Click 有一個強大的呼叫上下文概念,這使得子命令響應來自父命令的資料。
  • Click 具有可用於所有參數和命令的強大資訊。這使得它可以對所有的 CLI 生成完整統一的幫助頁面,在必要的時候去幫助使用者轉換輸入資料。
  • Click 能夠很好的識別參數類型,如果出錯可以為使用者提供統一的錯誤資訊。一個由不同的開發者寫的子命令在出現問題時不會因為人工處理錯誤而輸出不同的錯誤資訊。
  • Click 可以提供足夠的元資訊給整個程序。它可以隨著時間的推移而進化,以改善使用者體驗不需要逼迫開發者去適應他們的程序。例如:如果 Click 決定去改變幫助頁面

的格式化方式,所有的 Click 程序都會自動從中受益。

Click 的目標是創造統一的系統,而 docopt的目標是建構最漂亮的手工製作的命令列介面。這兩個目標以微妙的方式相互衝突。 Click 主動防止使用者實現特定的樣式,目的是創造一個統一的命令列介面。例如在重新格式化你的幫助頁面上你基本上不需要投入任何精力。

6. Click 實作的步驟摘要

Fred 老師通過一個逐步的過程展示了如何使用 Click 創建一個 CLI 應用。這個過程包括:

  • 安裝 Click:首先需要安裝 Click 模組。
    pip install click
  • 定義命令:使用 @click.command() 裝飾器來定義命令。
  • 添加參數和選項:使用 @click.argument() 和 @click.option() 裝飾器來添加命令的參數和選項。
    • 其中 @click.argument() 裝飾器用於要添加命令的目標函式的必要參數,而 Argument 格式語法中可允許的參數包含參數名(name)、參數縮寫、類型(type)、默認值(default)、幫助(help)等。
    • @click.option() 裝飾器用於要添加命令的目標函式的選擇性選項參數,而 option 式語法中可允許的參數包括選項名稱(name)、參數縮寫、類型(type)、默認值(default)、幫助(help)等。

如以下範例所示:

import click

from viewers.csv_viewer import preview_csv
from viewers.enums import TableFormat
from viewers.json_viewer import preview_json


@click.group(name="viewers")
def viewers_group():
    """CLI commands for viewing CSV and JSON files"""


@click.command(name="json")
@click.argument("file_name", type=click.Path(exists=True, dir_okay=False))
@click.option(
    "--numlines",  # this will be the argument name used in the function being decorated
    "-n",
    default=None,
    type=click.IntRange(1),
    help="Number of lines of the JSON object to display, or omit to view the entire file",
)
@click.option(
    "-i",
    "--indent",
    default=4,
    type=click.IntRange(0),
    help="Specifies the indentation level for viewing the JSON object",
)
def view_json(file_name, numlines, indent):
    """Use FILE_NAME to Specify a path to the JSON file you wish to preview"""
    result = preview_json(file_name=file_name, first_n=numlines, indent=indent)
    click.echo(result)


@click.command(name="csv")
@click.argument("file_name", type=click.Path(exists=True, dir_okay=False))
@click.option(
    "--has-header",
    "has_headers",
    is_flag=True,
    default=False,
    help="Specify this flag is the CSV file has a header row",
)
@click.option(
    "--numlines",
    "-n",
    "line_count",
    default=None,
    type=click.IntRange(1),
    help="Number of rows to display (excluding header row, if any)",
)
@click.option(
    "--format",
    "-f",
    "format_",  # actually need this format is a Python built-in function
    default=TableFormat.fancy_outline.name,
    type=click.Choice([e.name for e in TableFormat], case_sensitive=True),
    help="Specify the formatting style",
)
def view_csv(file_name, line_count, has_headers, format_):
    """View CSV files"""
    format_ = TableFormat[format_]
    result = preview_csv(
        file_name=file_name, first_n=line_count, has_header_row=has_headers, table_format=format_
    )
    click.echo(result)


viewers_group.add_command(view_json)
viewers_group.add_command(view_csv)
  • 組織命令:使用 @click.group() 裝飾器來組織相關的命令。
    • 當不同的命令樹需要組織時,可以使用 @click.group() 裝飾器來定義一個命令組。
    • 例如以 Fred 老師的範例,我們有一個主程式 main 包含了三個命令組,第一個是 DateTime 這個命令組,DateTime 下有兩個子命令,分別是 Date 和 Time。而除了 DateTime 之外,還加入了 Viewer 與 Converter 這兩個命令組。其中 Viewer 包含了 csv 與 json 兩種格式的檢視子命令,而 Converter 則包含 csv to json 的檔案轉換子命令,如以 Mermaid Flow 示意圖所示:
    graph TD
    A[click.group<br/>main] -->|click.command| B[click.group<br/>DateTime]
    A -->|click.command| C[click.group<br/>Viewer]
    A -->|click.command| D[click.group<br/>Converter]
    B -->|click.command| E[Date]
    B -->|click.command| F[Time]
    C -->|click.command| G[csv]
    C -->|click.command| H[json]
    D -->|click.command| I[csv to json]
  • 實現功能:藉由 click 在各個命令組類別中做為裝飾器來穿插裝飾,將所有命令依照樹狀架構串接成一個命令列統一有層次的指令集。

如以下範例所示:

from datetime import datetime, timezone

import click

@click.command
def date():
    click.echo(datetime.now(timezone.utc).date().isoformat())
    

@click.command    
def time():
    click.echo(datetime.now(timezone.utc).time().isoformat())


@click.command(name='click-docs')     
def view_click_docs():
    """Open the click documentation in the web browser.
    
    More information here......
    """
    click.launch('https://click.palletsprojects.com/en/8.0.x/')
    
    
@click.group
def main_cli():
    pass


main_cli.add_command(date)
main_cli.add_command(time)
main_cli.add_command(view_click_docs)

在上面範例中,為何要使用 click.echo 代替 print 函數?
原因很簡單, Click 支援不同版本的 Python,並且即使在環境組態錯誤的情況下也是非常易用的。即使一切都完全被破壞,Click 在基礎層面上起作用。

這意味著 echo() 函數應用了一些錯誤修正,以防終端組態錯誤而不是因為 UnicodeError 而退出程序。

另一個好處是,從 Click 2.0開始,echo 函數也對 ANSI 字型顏色有很好的支援。如果輸出流是一個檔案,它會自動去除 ANSI 程式碼空格,並且支援色彩,ANSI 色彩也可以在Windows上使用。

當然如果你不需要這個,你仍然可以使用 print() 來顯示文本.然而因為 click.echo 會自動處理不同平台的換行符號,並且能將輸出重定向到文件或管道。這使得 click.echo 相較 print 更適合用於 CLI 應用中。

7. Click 搭配 Setuptools 打包工具

就目前我們寫的程式碼,通常會有一個主程式 main.py,而 main.py 檔案的末尾,也常有一個類似這種的程式碼區塊: if name == ‘main’:。這是一個傳統獨立的 Python 檔案格式。即便使用 Click 你仍然可以繼續這樣做, 但使用 setuptools 則可將程式打包成程式集,更方便於在更多 Click 應用程式產生時,能更方便使用。

為何當更多 Click 應用程式產生時,會需要使用 setuptools 呢?有以下兩個主要原因:

  • 傳統方法的問題之一是Python直譯器載入的第一個模組名稱不正確。這可能像一個小問題,但具有相當重要的意義。

    • 第一個模組沒有被其實際名稱呼叫,但直譯器將其重新命名為 __main__ 雖然這是一個完全有 效的名字,但這意味著如果另一段程式碼想要從該模組匯入,它將在其真實名稱下第二次觸發匯入, 並且突然間您的程式碼將被匯入兩次。
  • 不是在所有的平台上,程序都是易於執行的。在Linux和OS X上,您可以將註釋新增到檔案的開頭 (開頭這麼寫 #!/usr/bin/env python) ,這樣您的指令碼就像一個可執行檔案(假設它具有可執行位設定)。 但是,這不適用於Windows系統。在Windows上,您可以將直譯器與副檔名關聯起來() (就像任何以 .py 結尾的檔案,通過Python直譯器執行) ,如果在要在virtualenv中使用該指令碼, 則會遇到問題。

    • 實際上,在 virtualenv 中運行指令碼也是 OS X 和 Linux 一個問題。使用傳統方法,您需要啟動整個 virtualenv, 以便使用正確的Python直譯器。這對使用者不是非常友好。

要將指令碼與 setuptools 捆綁在一起,您只需要Python程序包加上一個 setup.py 檔案,如以下範例:

from setuptools import setup

setup(
    name='my-click-cli',
    version='0.1.0',
    py_modules=['main'],
    install_requires=['click','tabulate'],
    entry_points={
        'console_scripts': [
            'cli = main:main_cli',
        ],
    },
)

8. 補充 - 另一個很值得介紹異軍突起的 Cli 工具 - Google Fire

Fire是一個相較目前能將程式轉為 Cli 命令模式的 argparse 或是 click 模組更簡單易用的 Cli 模組。是由 Google 所發佈的。其原始專案網址在這裡,說明文件則可參考這裡,其實 fire 的功能及目的和前面提到的 argparse 與 click 模組大同小異,因此這裡就不再贅述,有興趣的讀者可以自行搜尋網路上的介紹文章。

剛剛前面才介紹過 click,大家對 click 應該映象深刻,覺得 click 實在是比 argparse 的設定簡單容易很多,但是如果你覺得 click 還是有點複雜,那麼你可以試試看 fire,它更簡單,更容易上手。同樣的範例我改寫成 fire 的版本如下:

from datetime import datetime, timezone
import webbrowser   

import fire

def date():
    return datetime.now(timezone.utc).date().isoformat()
    

def time():
    return datetime.now(timezone.utc).time().isoformat()

def view_fire_docs():
    """Open the fire documentation in the default web browser.
    
    More information here......
    """
    urL='https://google.github.io/python-fire/'
    # 使用預設瀏覽器
    # webbrowser.get('windows-default').open_new(urL)
    # 指定特定瀏覽器 - chrome
    chrome_path = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
    webbrowser.register('chrome', None,webbrowser.BackgroundBrowser(chrome_path))
    webbrowser.get('chrome').open_new(urL)
    

if __name__ == '__main__':
    # 全域綁定
    fire.Fire()
    # 字典綁定
    # fire.Fire({
    #     'date': date,
    #     'time': time,
    #     'view_fire_docs': view_fire_docs
    # })
    # 指定函式綁定
    # fire.Fire(date)

在上面這個範例,我其實做的是一樣的事情,但只需有在一開頭,做了 import fire 以及在最後面使用 fire.Fire() 就完成了 cli 的構建,非常簡單。
而它同樣支援匿名、參數及幫助等功能,重點是不須額外設定,fire 會自動幫你處理並抓到。
而在綁定 fire 之後,和 click 類似,你只需要在終端機輸入 python 檔案名稱.py 加上原先寫在這個檔案的函數及方法,就能在 cli 上執行。
如下面範例所示:

python main.py date
python main.py time
python main.py view_fire_docs
# 詢問功能的幫助
python main.py --help

使用上幾乎是非常的傻瓜,fire 一共支援以下幾種綁定模式,分別是:

  1. fire.Fire() - 這是最簡單的綁定方式,它會將程式下全域環境中所有的函數及方法都綁定到 cli 上。
  2. fire.Fire(函數名稱) - 這是指定綁定的方式,只會綁定指定的函數或方法到 cli 上。
  3. fire.Fire(類別名稱) - 這是綁定類別的方式,會將類別中的所有函數及方法都綁定到 cli 上。
  4. fire.Fire(字典) - 這是綁定字典的方式,會將字典中的所有函數及方法都綁定到 cli 上。
  5. fire.Fire(物件名稱) - 這是綁定物件(object)的方式,會將物件中的所有函數及方法都綁定到 cli 上。
  • Note: 上面範例我有展示一些綁定的方式,其他更詳細的綁定方式可以參考這裡的範例演示。
1個讚