Python - 利用 pkgutil & importlib 打造可擴充式模組

Posted on  Apr 27, 2018  in  Python 程式設計 - 高階  by  Amo Chen  ‐ 3 min read

想要建立好的 Python 專案結構,模組化是免不了的過程。此外,程式 / 系統如果還需要良好的擴充性,例如可以彈性增加 Plugin ,可以考慮利用 pkgutilimportlib 模組自動載入模組的做法。

Python 知名的 Open Source 專案 Cuckoo Sandbox 就是利用類似的做法達到可擴充性,讓人可以實作一個外掛模組,然後丟到特定資料夾後,它就能夠被執行。

本文將實作一個最小的可擴充式模組架構,所使用之 Python 版本為 3.5.2 。

首先,我們的 Python 專案資料夾可能會有類似以下的結構:

    .
    ├── libs/
    │   ├── base.py
    │   └── __init__.py
    ├── modules/
    │   ├── moduleB.py
    │   ├── moduleA.py
    │   └── __init__.py
    └── main.py

上述檔案與資料夾的用途說明如下:

  1. libs/ 重要函式存放的資料夾
  2. libs/base.py 定義了所有 modules/ 資料夾下所有模組的父類別基本樣貌
  3. modules/ 可彈性擴充模組的資料夾
  4. modules/modulesA.py 模組的範例
  5. main.py Python 主程式

接下來,說明自動載入模組的主要關鍵做法,以下分點陳述:

  1. base.py 中定義我們模組主要的方法與樣貌,例如以下 Python 程式碼是我們所定義的 Base 類別,用來讓 modules/ 底下的模組繼承。
#! -*- coding: utf-8 -*-

class Base(object):
    def __init__(self):
        self.name = 'Base'

    def run(self):
        print(self.name, 'is running')
  1. modules/ 底下建立類別模組,並使之繼承我們第1步所建立的 Base 類別,例如以下程式碼:
#! -*- coding: utf-8 -*-
from libs.base import Base


class ModuleA(Base):
    def __init__(self):
        self.name = 'ModuleA'
  1. main.py 利用 pkgutil.iter_modules 取得 modules/ 底下的所有模組名稱。

  2. main.py 使用 importlib.import_module 方法將第 3 步所取得的模組名稱一個一個 import 進來。

  3. main.py 利用 __subclass__ 方法取得我們第 1 步所建立的 Base 類別的所有子類別,即第 2 步所建立的子類別,也就是 Plugin 。

  4. main.py 一一執行這些類別共同定義的方法,例如範例中的 run() 方法。

上述的 3-6 步驟 main.py 範例程式碼,如下所示:

#! -*- coding: utf-8 -*-
import pkgutil
import importlib

from libs.base import Base


def main():
    for finder, name, _ in pkgutil.iter_modules(['modules']):
        try:
            importlib.import_module('{}.{}'.format(finder.path, name))
        except Exception as e:
            print('Can not import {}'.format(name))

    for cls in Base.__subclasses__():
        instance = cls()
        instance.run()

if __name__ == '__main__':
    main()

主要透過以上 6 個主要步驟,就能夠達成自動載入模組的目標。

其中 importlib.import_module 其實是包裝過的 importlib.__import__ ,如果沒什麼特殊需求,用 importlib.import_module 來 import 模組即可。

p.s. importlib.import_module('{}.{}'.format(finder.path, name)) 可以寫成 importlib.__import__(, globals(), locals(), ['dummy'], -1) ,而我們此處所使用的參數主要是將程式執行時的全域與區域變數一併傳入給 __import__ 方法參考,並且為了確保 __import__ 不會回傳最上層的 modules 模組(例如 modules.moduleA,最上層的模組即為 modules ),因此我們傳入 ['dummy'] 確保這個參數不為空值,以令其回傳 modules/ 下的模組,引用官方說法如下:

When the name variable is of the form package.module, normally, the top-level package (the name up till the first dot) is returned, not the module named by name. However, when a non-empty fromlist argument is given, the module named by name is returned.

最後可以執行 main.py ,就可以看到 ModuleA 被自動載入並且執行 run 方法,執行結果:

$ python main.py
ModuleA is running

以上就是利用 pkgutil & importlib 自動載入模組的方法。

後記

編者後來將動態載入的部分變成一個函式,整個流程就更加簡潔許多。同樣用以下的檔案資料夾結構為例。

    .
    ├── libs/
    │   ├── base.py
    │   └── __init__.py
    ├── modules/
    │   ├── moduleB.py
    │   ├── moduleA.py
    │   └── __init__.py
    └── main.py

將 main.py 改為以下程式,也能夠達到同樣的功能:

#! -*- coding: utf-8 -*-
import pkgutil
import importlib

from libs.base import Base


def load_modules():
    """
    Import all classes under the folder 'modules'.
    >>> load_modules()
    """
    for finder, name, _ in pkgutil.iter_modules(['modules']):
        try:
            importlib.import_module('{}.{}'.format(finder.path, name))
        except ImportError as e:
            logger.debug(e)

    return Base.__subclasses__()


def main():

    for cls in load_modules():
        instance = cls()
        instance.run()


if __name__ == '__main__':
    main()

上述的 load_modules() 就是自動載入 modules 資料夾底下所有模組的函式。這個部分還可以寫得更靈活些,可以依照不同路徑自動載入等等。

只是這並非本文的目標,因此就不多花些篇幅解釋如何改進了。

References

https://docs.python.org/3/library/pkgutil.html

https://docs.python.org/3/library/importlib.html

https://docs.python.org/3/library/functions.html

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!