Click 提供了豐富的引數型別和選項,讓開發者可以輕鬆處理命令列輸入。除了內建型別,還可以自訂引數型別,例如解析 Python 類別路徑。為提升使用者經驗,Click 支援自訂自動完成,方便使用者快速輸入引數。此外,Click 也內建了 --help--version 等常見選項,簡化開發流程。更進一步,可以利用 Entry Points 機制開發外掛系統,讓其他開發者擴充套件程式功能,無需修改核心程式碼。本文將探討這些技術,並提供實用的程式碼範例。

使用 Click 提升命令列工具的解析能力

Click 提供了一系列內建的引數型別,可以自動解析命令列輸入的值,簡化命令列工具的開發流程。這些內建型別包括 click.STRINGclick.INTclick.FLOATclick.BOOL,能夠直接將命令列的輸入轉換為 Python 的基本資料型別。例如,使用 click.FLOAT 可以自動呼叫 float(...) 對輸入值進行解析,而 click.BOOL 則會檢查輸入是否符合特定的真假值表示法,如 y/nt/f1/0 等。

自訂 Click 引數型別

除了 Click 提供的內建型別外,開發者還可以自訂引數型別,以滿足特定的解析需求。自訂型別需要繼承 ParamType 並實作必要的解析邏輯。例如,下面的程式碼定義了一個用於解析 Python 類別路徑的自訂型別 PythonClassParameterType

from click.types import ParamType
import importlib
import functools

class PythonClassParameterType(ParamType):
    name = "pythonclass"

    def __init__(self, superclass=type):
        self.superclass = superclass

    def get_sensor_by_path(self, sensor_path: str, fail: Callable[[str], None]) -> Any:
        try:
            module_name, sensor_name = sensor_path.split(":")
        except ValueError:
            return fail("類別路徑必須符合 dotted.path.to.module:ClassName 的格式")
        
        try:
            module = importlib.import_module(module_name)
        except ImportError:
            return fail(f"無法匯入模組 {module_name}")
        
        try:
            sensor_class = getattr(module, sensor_name)
        except AttributeError:
            return fail(f"在 {module_name} 中找不到屬性 {sensor_name}")
        
        if isinstance(sensor_class, type) and issubclass(sensor_class, self.superclass) and sensor_class != self.superclass:
            return sensor_class
        else:
            return fail(f"檢測到的物件 {sensor_class!r} 無法識別為 {self.superclass} 型別")
    
    def convert(self, value, param, ctx):
        fail = functools.partial(self.fail, param=param, ctx=ctx)
        return self.get_sensor_by_path(value, fail)

    def __repr__(self):
        return "PythonClass"

# 建立一個只接受 Sensor 類別的 PythonClassParameterType 例項
SensorClassParameter = PythonClassParameterType(Sensor)

內容解密:

  1. PythonClassParameterType 類別定義:該類別繼承自 ParamType,用於解析 Python 類別的路徑。
  2. __init__ 方法:初始化時可指定一個父類別 (superclass),預設為 type
  3. get_sensor_by_path 方法:根據提供的路徑字串(如 module_name:ClassName),嘗試匯入模組並取得指定的類別。如果過程中出現錯誤(如格式不正確、模組無法匯入、類別不存在),則會呼叫 fail 回呼函式報告錯誤。
  4. convert 方法:Click 在解析命令列引數時會呼叫此方法,將輸入的字串轉換為指定的 Python 類別。
  5. SensorClassParameter 例項:建立了一個 PythonClassParameterType 的例項,並指定 Sensor 為其接受的類別型別,用於後續的命令列引數解析。

使用自訂引數型別

定義好自訂的引數型別後,可以在 Click 的選項或引數定義中直接使用:

@click.option(
    "--develop",
    required=False,
    metavar="path",
    help="透過 Python 路徑載入感測器",
    type=SensorClassParameter,
)

內容解密:

  1. --develop 選項:定義了一個名為 --develop 的命令列選項,允許使用者透過 Python 路徑指定要載入的感測器類別。
  2. type=SensorClassParameter:指定該選項的值應該使用 SensorClassParameter 進行解析,確保輸入的是有效的 Sensor 類別路徑。

自動完成支援與常見選項實作

在命令列工具的開發過程中,為使用者提供便利的自動完成(Autocomplete)功能是一項重要的提升使用者經驗的措施。Click 函式庫提供了豐富的自訂自動完成支援,讓開發者能夠針對特定需求實作自訂的自動完成邏輯。

自訂自動完成實作

對於某些選項引數,例如 --develop 旗標,完成 Python 模組名稱的自動實作可能較為困難,因為這需要掃描環境中的所有可能性。然而,一旦模組名稱被輸入,完成類別名稱相對較容易。開發者可以參考以下方法簽名來實作自訂的自動完成:

def AutocompleteSensorPath(
    ctx: click.core.Context, args: list, incomplete: str
) -> t.List[t.Tuple[str, str]]:
    # 自動完成邏輯實作
    pass

內容解密:

  1. ctx: click.core.Context:代表目前 Click 命令的執行上下文,包含命令相關資訊。
  2. args: list:目前已輸入的命令列引數列表。
  3. incomplete: str:目前使用者正在輸入且尚未完成的引數。
  4. 傳回值 t.List[t.Tuple[str, str]]:包含自動完成建議的列表,每個建議由顯示值和實際值組成。

要啟用自訂的自動完成,需要在選項中加入 autocompletion=AutocompleteSensorPath 引數:

@click.option("--develop", autocompletion=AutocompleteSensorPath)
def show_sensors(develop: str) -> int:
    # 命令邏輯實作
    pass

測試自動完成功能

要在本地測試自動完成功能,需要手動在虛擬環境中啟用該功能。以 Bash shell 為例,可以使用以下命令:

> pipenv shell
> eval "$(_SENSORS_COMPLETE=source_bash sensors)"

內容解密:

  1. pipenv shell:啟動虛擬環境。
  2. eval "$(_SENSORS_COMPLETE=source_bash sensors)":告訴 Click 生成 Bash 自動完成組態並立即執行。

常見選項實作

除了自訂自動完成,Click 還內建支援一些常見的選項,例如 --help--version

--help 選項

Click 自動為所有命令新增 --help 選項,除非在 @click.command() 中指定 add_help_option=False。開發者也可以手動新增自訂的幫助選項:

@click.command(help="顯示感測器的值")
@click.help_option("--hilfe")
def show_sensors(develop: str) -> int:
    # 命令邏輯實作
    pass

內容解密:

  1. @click.help_option("--hilfe"):新增一個名為 --hilfe 的幫助選項。
  2. 這允許開發者支援多語言的幫助選項。

--version 選項

可以使用 @click.version_option() 裝飾器來新增 --version 選項,以顯示目前安裝的命令版本:

@click.version_option()
def show_sensors(develop: str) -> int:
    # 命令邏輯實作
    pass

內容解密:

  1. @click.version_option() 自動從 Python 環境中查詢目前安裝的版本。
  2. 這使得維護版本資訊變得更加簡單。

第三方感測器外掛支援

為了讓使用者無需每次都指定 Python 路徑,我們需要動態生成可用的感測器列表。這可以透過兩種方法實作:自動檢測和組態檔案。

自動檢測 vs 組態檔案

方法說明優點缺點
自動檢測感測器在執行時註冊無需額外組態,動態載入可能需要額外的註冊邏輯
組態檔案使用者維護一個指向感測器的檔案明確控制載入哪些感測器需要使用者手動維護組態

開發者應根據具體的使用場景選擇合適的方法。

從指令碼到框架:外掛架構的設計考量

在開發一個可擴充套件的系統時,外掛架構的設計是一個重要的考慮因素。一個好的外掛架構可以讓開發者輕鬆地新增新功能,而不需要修改核心程式碼。在本章中,我們將探討兩種常見的外掛架構設計方法:根據組態的系統和自動檢測。

根據組態的系統

根據組態的系統允許開發者對外掛系統進行細粒度的控制。這種方法非常適合於需要高度自定義的外掛架構,例如 Django 的應用系統。在 Django 中,應用程式被安裝到本地環境中,但直到它們被新增到 settings.py 檔案中才會生效。這種方法允許開發者對外掛進行組態,並將組態儲存在版本控制中。

這種方法的優點是它提供了高度的靈活性,開發者可以根據需要組態外掛。然而,這種方法也需要開發者具備一定的技術背景,並且需要手動編輯組態檔案。

自動檢測

自動檢測是一種更加使用者友好的方法,它不需要開發者手動編輯組態檔案。在 Python 中,可以使用 entrypoints 來實作自動檢測。entrypoints 是一種機制,允許 Python 包宣告它們提供的入口點。這些入口點可以被其他程式碼使用,以實作外掛的自動檢測。

使用固定名稱的外掛檢測

另一種方法是使用固定名稱的外掛檢測。這種方法涉及建立一個自定義的 Python 檔案,例如 custom_sensors.py,並在其中定義感測器。然後,可以使用 vars() 函式來檢測該檔案中定義的感測器。

def get_sensors() -> t.Iterable[Sensor[t.Any]]:
    try:
        import custom_sensors
    except ImportError:
        discovered = []
    else:
        discovered = [
            attribute
            for attribute in vars(custom_sensors).values()
            if isinstance(attribute, type)
            and issubclass(attribute, Sensor)
        ]
    return discovered

使用 entrypoints 的外掛檢測

使用 entrypoints 可以實作更加靈活和使用者友好的外掛檢測。要使用 entrypoints,需要在 Python 包的後設資料中宣告入口點。然後,可以使用 entrypoints 來檢測已安裝的外掛。

內容解密:

  • get_sensors() 函式嘗試匯入 custom_sensors 模組,如果匯入失敗,則傳回空列表。
  • 如果匯入成功,則使用 vars() 函式取得 custom_sensors 模組中定義的所有屬性。
  • 然後,使用列表推導式篩選出那些是 Sensor 子類別的屬性,並將它們新增到 discovered 列表中。
  • 最後,傳回 discovered 列表,其中包含所有檢測到的感測器。

對比與選擇

特性根據組態的系統自動檢測
安裝難度需要編輯組態檔案只需安裝包
重新排序外掛可能不可能
覆寫內建外掛可能不可能
排除已安裝外掛可能不可能
外掛引陣列態可能不可能
使用者友好度需要使用者編輯組態檔案無需額外步驟

對於我們的使用案例來說,自動檢測是一種更加合適的方法,因為它提供了更好的使用者經驗。然而,根據組態的系統提供了更多的靈活性,可以根據需要組態外掛。

使用 Entry Points 實作外掛系統

在開發 Python 套件時,entry points 提供了一種有效的機制,讓程式碼能夠在安裝後自動被發現,從而實作外掛系統的功能。這使得不同的套件可以透過 entry points 提供擴充套件點,而無需修改核心程式碼。

Entry Points 的名稱空間

Entry points 使用一個雙層名稱空間。外層是 entry point group,它是一個簡單的字串識別符號。例如,console_scriptsgui_scripts 是用於自動生成命令列工具的 entry point group。內層則是個別 entry point 的名稱,這些名稱在同一個 group 內必須是唯一的。

發現 Entry Points

你可以使用 pkg_resources 模組來查詢 Python 環境中已使用的 entry point groups。以下是一個列出所有 entry point groups 的範例程式:

>>> functools.reduce(
...     set.union,
...     [
...         set(package.get_entry_map(group=None).keys())
...         for package in pkg_resources.working_set
...     ],
...     set()
... )
{'nbconvert.exporters', 'egg_info.writers', 'gui_scripts', 'pygments.lexers', 
 'console_scripts', 'babel.extractors', 'setuptools.installation', 
 'distutils.setup_keywords', 'distutils.commands'}

內容解密:

  1. 使用 functools.reduce 合併集合:這段程式碼使用 functools.reduce 將多個集合合併成一個,展示瞭如何扁平化列表(或集合)。
  2. pkg_resources.working_set:這裡遍歷了當前 Python 環境中所有已安裝的套件。
  3. package.get_entry_map(group=None).keys():取得每個套件提供的 entry point groups。
  4. set.union:用於合併集合的操作,確保結果不重複。

定義和使用 Entry Points

要在你的套件中使用 entry points,首先需要決定一個 entry point group 名稱。在這個例子中,我們使用 apd.sensors.sensor。然後,在 setup.cfg 檔案中定義 entry points:

[options.entry_points]
apd.sensors.sensor =
    PythonVersion = apd.sensors.sensors:PythonVersion
    IPAddresses = apd.sensors.sensors:IPAddresses
    CPULoad = apd.sensors.sensors:CPULoad
    RAMAvailable = apd.sensors.sensors:RAMAvailable
    ACStatus = apd.sensors.sensors:ACStatus
    Temperature = apd.sensors.sensors:Temperature
    RelativeHumidity = apd.sensors.sensors:RelativeHumidity

其他套件可以透過相同的 entry point group 名稱新增新的外掛:

[options.entry_points]
apd.sensors.sensor =
    SolarCumulativeOutput = apd.sunnyboy_solar.sensor:SolarCumulativeOutput

載入 Entry Points

要使用 entry points 載入外掛,需要修改核心程式碼中的 get_sensors 方法:

def get_sensors() -> t.Iterable[Sensor[t.Any]]:
    sensors = []
    for sensor_class in pkg_resources.iter_entry_points("apd.sensors.sensor"):
        class_ = sensor_class.load()
        sensors.append(t.cast(Sensor[t.Any], class_()))
    return sensors

內容解密:

  1. pkg_resources.iter_entry_points("apd.sensors.sensor"):遍歷指定 entry point group 中的所有 entry points。
  2. sensor_class.load():載入 entry point 對應的類別。
  3. t.cast(Sensor[t.Any], class_()):將載入的類別例項轉換為 Sensor 型別,這裡假設外掛作者提供的 entry points 都是有效的感測器類別。