Following system colour scheme - Python 增強提案 Selected dark colour scheme - Python 增強提案 Selected light colour scheme - Python 增強提案

Python 增強提案 (Python Enhancement Proposals)

PEP 302 – 新匯入掛鉤 (New Import Hooks)

作者:
Just van Rossum <just at letterror.com>, Paul Moore <p.f.moore at gmail.com>
狀態:
最終 (Final)
類型:
標準軌跡 (Standards Track)
建立日期:
2002年12月19日
Python 版本:
2.3
公告歷史:
2002年12月19日

目錄

Warning

關於 import 的語言參考 [10] 與 importlib 文件 [11] 現已取代此 PEP。本文件不再更新,僅供歷史參考。

摘要

本 PEP 提議新增一組匯入掛鉤,提供更好的 Python 匯入機制自訂功能。與現有的 __import__ 掛鉤不同,新式掛鉤可以注入到現有的架構中,從而對模組的尋找與載入方式進行更細緻的控制。

動機

目前自訂匯入機制的唯一方式是覆寫內建的 __import__ 函式。然而,覆寫 __import__ 有許多問題。首先:

  • __import__ 的替代實作需要完整重寫整個匯入機制,或者在自訂程式碼前後呼叫原始的 __import__
  • 它有非常複雜的語意與責任。
  • 即使對於已經存在於 sys.modules 中的模組,__import__ 也會被呼叫,這幾乎不是你想要的,除非你正在編寫某種監控工具。

當你需要從 C 語言擴充匯入機制時,情況會變得更糟:目前除了修改 Python 的 import.c 或從頭重寫 import.c 的大部分內容外,是不可能做到的。

以 Python 編寫並允許透過各種方式擴充匯入機制的工具由來已久,它們多半基於 __import__ 掛鉤。標準函式庫包含兩個這類工具:ihooks.py (由 GvR 編寫) 與 imputil.py [1] (Greg Stein),但其中最著名的或許是 Gordon McMillan 的 iu.py,作為他 Installer 套件的一部分提供。由於它們是用 Python 編寫的,實用性受到一定限制;必須解決引導 (bootstrapping) 問題,因為你無法使用掛鉤本身來載入包含該掛鉤的模組。因此,如果你希望整個標準函式庫都能從匯入掛鉤載入,該掛鉤必須使用 C 語言編寫。

使用案例

本節列出了幾個依賴匯入掛鉤的現有應用。其中,如果當時有更靈活的匯入掛鉤,很多重複的工作本來是可以避免的。此 PEP 應該會讓未來類似專案的開發變得輕鬆許多。

當你想載入以非標準方式儲存的模組時,就需要擴充匯入機制。例如:打包在壓縮檔中的模組;未儲存在 pyc 格式檔案中的位元組碼;或是透過網路從資料庫載入的模組。

這項 PEP 的工作部分源於對 PEP 273 的實作,該 PEP 將從 Zip 壓縮檔匯入模組作為 Python 的一項內建功能。雖然該 PEP 本身被廣泛接受為一項必要功能,但其實作卻有待改進。首先,它為了與 import.c 整合而大費周章,增加了許多程式碼,這些程式碼要麼是 Zip 檔案匯入所特有的,要麼不是特有的,但又不具備通用價值(甚至不被需要)。然而,這很難責怪 PEP 273 的實作:鑑於 import.c 的現狀,要做到這一點確實極其困難。

為終端使用者打包應用程式是匯入掛鉤的典型用例,如果不是典型的用例的話。分發大量的原始碼或 pyc 檔案並不總是合適的(更不用說單獨安裝一個 Python 環境),因此經常有人希望能將所有需要的模組打包成單一檔案。事實上,這種需求非常頻繁,多年來已經實作了多種解決方案。

最古老的一個包含在 Python 原始碼中:Freeze [2]。它將序列化後的位元組碼放入 C 原始碼的靜態物件中。Freeze 的「匯入掛鉤」是硬編碼在 import.c 中的,並且存在一些問題。後來的解決方案包括 Fredrik Lundh 的 Squeeze、Gordon McMillan 的 Installer 以及 Thomas Heller 的 py2exe [3]。MacPython 則隨附一個名為 BuildApplication 的工具。

Squeeze、Installer 和 py2exe 使用基於 __import__ 的架構(py2exe 目前使用 Installer 的 iu.py,Squeeze 使用 ihooks.py),MacPython 有兩個 Mac 特有的匯入掛鉤硬編碼在 import.c 中,這與 Freeze 掛鉤類似。本 PEP 提出的掛鉤使我們(至少在理論上;這不是短期目標)能夠擺脫 import.c 中的硬編碼掛鉤,並允許基於 __import__ 的工具擺脫大部分 import.c 模擬程式碼。

在開始本 PEP 的設計與實作工作之前,一個針對 Mac OS X 的新 BuildApplication 風格工具促使本 PEP 的作者之一 (JvR) 將凍結模組 (frozen modules) 的表格暴露給 imp 模組中的 Python。主要原因是為了能夠使用 freeze 匯入掛鉤(避免複雜的 __import__ 支援),同時也能在執行時期提供一組模組。這產生了問題編號 #642578 [4],該問題被神祕地接受了(主要是因為似乎沒人在意 :-)。然而,一旦本 PEP 被接受,這個問題就完全多餘了,因為它提供了一種更好、更通用的方法來實現同樣的事情。

原理

在嘗試實現內建 Zip 匯入的替代方案時,發現只需對 import.c 進行相當少的修改即可實現。這使得我們可以將 Zip 特定的內容分離到一個新的原始檔中,同時建立一個通用的新匯入掛鉤架構:也就是你現在正在閱讀的這個。

早期的設計允許 sys.path 上存在非字串物件。這樣的物件擁有處理匯入所需的必要方法。這有兩個缺點:1) 它會破壞那些假設 sys.path 上的所有項目都是字串的程式碼;2) 它與 PYTHONPATH 環境變數不相容。後者對於 Zip 匯入是直接需要的。一個折衷方案來自 Jython:允許 sys.path 上存在字串的子類別,它們可以作為匯入者物件。這避免了一些破壞,並且在 Jython 中運作良好(用於從 .jar 檔案載入模組),但它被認為是一個「醜陋的 hack」。

這導致了一個更精細的架構(主要複製自 McMillan 的 iu.py),即在候選清單中,詢問每個候選者是否能處理該 sys.path 項目,直到找到能處理的為止。這個候選清單是 sys 模組中的一個新物件:sys.path_hooks

對於每個新的匯入,遍歷 sys.path_hooks 處理每個路徑項目可能很昂貴,因此結果會被快取到 sys 模組中的另一個新物件:sys.path_importer_cache。它將 sys.path 項目對應到匯入者物件。

為了將對 import.c 的影響降至最低,並避免增加額外的開銷,我們選擇不為現有的檔案系統匯入邏輯增加顯式的掛鉤與匯入者物件(如 iu.py 那樣),而是如果 sys.path_hooks 上沒有掛鉤能處理該路徑項目,就直接回退到內建邏輯。如果是這種情況,None 值會被儲存在 sys.path_importer_cache 中,同樣是為了避免重複查找。(之後我們可以進一步為內建機制增加一個真正的匯入者物件,就目前而言,None 回退機制應該足夠了。)

有人提出了一個問題:那些不需要 sys.path任何項目的匯入者怎麼辦?(內建與凍結模組屬於這一類。)同樣是 Gordon McMillan 解決了這個問題:iu.py 包含了一個他稱為中繼路徑 (metapath) 的東西。在本 PEP 的實作中,它是一個在 sys.path 之前遍歷的匯入者物件清單。這個清單是 sys 模組中的另一個新物件:sys.meta_path。目前,這個清單預設為空,凍結模組與內建模組的匯入會在遍歷 sys.meta_path 之後進行,但仍然在 sys.path 之前。

規範第一部分:匯入者協定 (The Importer Protocol)

本 PEP 引入了一個新的協定:「匯入者協定」。理解該協定運作的上下文非常重要,因此這裡簡單概述一下匯入機制的外部架構。

當遇到 import 陳述式時,直譯器會在內建命名空間中查找 __import__ 函式。然後以四個參數呼叫 __import__,其中包含正在匯入的模組名稱(可能是帶點的名稱)以及對當前全域命名空間的參考。

內建的 __import__ 函式(在 import.c 中稱為 PyImport_ImportModuleEx())接著會檢查執行匯入的模組是否為套件或套件的子模組。如果它確實是(子模組的)套件,它會先嘗試相對於該套件進行匯入(對於子模組則是父套件)。例如,如果名為 "spam" 的套件執行 "import eggs",它會先尋找名為 "spam.eggs" 的模組。如果失敗,匯入將作為絕對匯入繼續:它會尋找名為 "eggs" 的模組。帶點名稱的匯入運作方式幾乎相同:如果套件 "spam" 執行 "import eggs.bacon"(且 "spam.eggs" 存在且本身是一個套件),則會嘗試 "spam.eggs.bacon"。如果失敗,則嘗試 "eggs.bacon"。(還有一些細節未在此描述,但這些對匯入者協定的實作人員來說並不重要。)

在機制更底層的地方,帶點名稱的匯入會按其組件拆分。對於 "import spam.ham",會先執行 "import spam",只有當該動作成功後,才會將 "ham" 匯入為 "spam" 的子模組。

匯入者協定運作在這個單獨匯入的層級。當匯入者收到 "spam.ham" 的請求時,模組 "spam" 已經被匯入了。

該協定涉及兩個物件:尋找者 (finder)載入者 (loader)。尋找者物件只有一個方法:

finder.find_module(fullname, path=None)

此方法將被呼叫,參數為模組的完整限定名稱。如果尋找者安裝在 sys.meta_path 上,它將接收第二個參數,對於頂層模組該值為 None,對於子模組或子套件則為 package.__path__ [5]。如果找到了模組,它應回傳一個載入者物件;如果沒找到,則回傳 None。如果 find_module() 拋出例外,它將傳播給呼叫者,從而中止匯入。

載入者物件也只有一個方法:

loader.load_module(fullname)

此方法回傳已載入的模組或拋出一個例外(如果沒有現有的例外正在傳播,最好是 ImportError)。如果 load_module() 被要求載入一個它無法載入的模組,應拋出 ImportError

在許多情況下,尋找者與載入者可以是同一個物件:finder.find_module() 只需回傳 self

這兩個方法的 fullname 參數都是完整限定模組名稱,例如 "spam.eggs.ham"。如上所述,當呼叫 finder.find_module("spam.eggs.ham") 時,"spam.eggs" 已經被匯入並新增至 sys.modules 中。然而,find_module() 方法並不一定總是在實際匯入期間被呼叫:分析匯入依賴的中繼工具(如 freeze、Installer 或 py2exe)實際上並不會載入模組,因此尋找者不應依賴父套件在 sys.modules 中可用。

load_module() 方法在執行任何程式碼之前必須履行一些責任:

  • 如果 sys.modules 中已經存在一個名為 'fullname' 的模組物件,載入者必須使用該現有的模組。(否則,reload() 內建函式將無法正確運作。)如果 sys.modules 中不存在名為 'fullname' 的模組,載入者必須建立一個新的模組物件並將其新增至 sys.modules

    請注意,在載入者執行模組程式碼之前,模組物件必須存在於 sys.modules 中。這至關重要,因為模組程式碼可能會(直接或間接)匯入其自身;事先將其新增至 sys.modules 可防止最壞情況下的無限遞迴與最好情況下的重複載入。

    如果載入失敗,載入者需要移除它可能已插入 sys.modules 中的任何模組。如果該模組原本就已存在於 sys.modules 中,則載入者應保持不變。

  • 必須設定 __file__ 屬性。這必須是一個字串,但可以是虛構值,例如 "<frozen>"。內建模組保留了完全沒有 __file__ 屬性的特權。
  • 必須設定 __name__ 屬性。如果使用 imp.new_module(),則該屬性會自動設定。
  • 如果是套件,必須設定 __path__ 變數。這必須是一個清單,如果 __path__ 對匯入者沒有進一步意義(稍後會詳細說明),則可以是空的。
  • 必須將 __loader__ 屬性設定為載入者物件。這主要是為了內省 (introspection) 與重新載入,但也可以用於匯入者特定的額外資訊,例如獲取與匯入者相關的資料。
  • 必須設定 __package__ 屬性 (PEP 366)。

    如果是 Python 模組(相對於內建模組或動態載入的擴充),它應該在模組的全域命名空間 (module.__dict__) 中執行模組的程式碼。

    這是一個 load_module() 方法的最小模式:

    # Consider using importlib.util.module_for_loader() to handle
    # most of these details for you.
    def load_module(self, fullname):
        code = self.get_code(fullname)
        ispkg = self.is_package(fullname)
        mod = sys.modules.setdefault(fullname, imp.new_module(fullname))
        mod.__file__ = "<%s>" % self.__class__.__name__
        mod.__loader__ = self
        if ispkg:
            mod.__path__ = []
            mod.__package__ = fullname
        else:
            mod.__package__ = fullname.rpartition('.')[0]
        exec(code, mod.__dict__)
        return mod
    

規範第二部分:註冊掛鉤

匯入掛鉤有兩種類型:中繼掛鉤 (Meta hooks)路徑掛鉤 (Path hooks)。中繼掛鉤在匯入處理開始時被呼叫,在任何其他匯入處理之前(這樣中繼掛鉤可以覆寫 sys.path 處理、凍結模組,甚至是內建模組)。要註冊中繼掛鉤,只需將尋找者物件新增至 sys.meta_path(已註冊中繼掛鉤的清單)即可。

路徑掛鉤作為 sys.path(或 package.__path__)處理的一部分,在遇到其相關路徑項目時被呼叫。透過將匯入者工廠 (importer factory) 新增至 sys.path_hooks 來註冊路徑掛鉤。

sys.path_hooks 是一個可呼叫物件的清單,這些物件將被依次檢查,以確定它們是否能處理給定的路徑項目。該可呼叫物件以路徑項目作為唯一參數被呼叫。如果可呼叫物件無法處理該路徑項目,它必須拋出 ImportError;如果能處理,則回傳一個匯入者物件。請注意,如果可呼叫物件為特定的 sys.path 條目回傳了匯入者物件,內建匯入機制將不再被呼叫來處理該條目,即使該匯入者物件隨後無法找到特定模組。該可呼叫物件通常是匯入掛鉤的類別,因此會呼叫類別的 __init__() 方法。(這也是為什麼它應該拋出 ImportError 的原因:__init__() 方法不能回傳任何東西。如果在新式類別中使用 __new__() 方法是可以做到的,但我們不想對掛鉤的實作方式提出任何要求。)

路徑掛鉤檢查的結果被快取在 sys.path_importer_cache 中,這是一個將路徑條目對應到匯入者物件的字典。在掃描 sys.path_hooks 之前會先檢查快取。如果有必要強制重新掃描 sys.path_hooks,可以手動清除 sys.path_importer_cache 的全部或部分內容。

就像 sys.path 本身一樣,新的 sys 變數必須具有特定類型:

  • sys.meta_pathsys.path_hooks 必須是 Python 清單。
  • sys.path_importer_cache 必須是 Python 字典。

允許原地修改這些變數,也允許用新物件替換它們。

套件與 __path__ 的角色

如果一個模組具有 __path__ 屬性,匯入機制將把它視為一個套件。在匯入該套件的子模組時,會使用 __path__ 變數而不是 sys.pathsys.path 的規則因此也適用於 pkg.__path__。所以當遍歷 pkg.__path__ 時,也會諮詢 sys.path_hooks。中繼匯入者不一定完全使用 sys.path 來執行其工作,因此可能會忽略 pkg.__path__ 的值。這種情況下,仍建議將其設定為清單,該清單可以是空的。

匯入者協定的選用擴充功能

匯入者協定定義了三個選用擴充功能。一個是檢索資料檔案,第二個是支援模組打包工具與/或分析模組依賴的工具(例如 Freeze),最後一個是支援將模組作為指令碼執行。後兩類工具通常不會實際載入模組,它們只需要知道模組是否可用以及在哪裡可用。所有三個擴充功能都強烈推薦用於通用目的的匯入者,但如果不需要這些功能,可以安全地省略它們。

為了從底層儲存後端檢索任意「檔案」的資料,載入者物件可以提供一個名為 get_data() 的方法:

loader.get_data(path)

此方法回傳字串格式的資料,如果找不到該「檔案」則拋出 IOError。資料總是像使用「二進位」模式一樣回傳——例如,文字檔案不會進行 CRLF 轉換。它適用於具有類似檔案系統屬性的匯入者。'path' 參數是可以透過使用 os.path.* 函式對 module.__file__(或 pkg.__path__ 項目)進行處理而構建的路徑,例如:

d = os.path.dirname(__file__)
data = __loader__.get_data(os.path.join(d, "logo.gif"))

如果希望支援類似 Freeze 的工具,可以實作以下方法集。它由三個額外的方法組成,為了方便呼叫者,這些方法應該全部實作,或者一個都不實作:

loader.is_package(fullname)
loader.get_code(fullname)
loader.get_source(fullname)

如果找不到模組,這三個方法都應拋出 ImportError

loader.is_package(fullname) 方法應在 'fullname' 指定的模組為套件時回傳 True,否則回傳 False

loader.get_code(fullname) 方法應回傳與該模組相關聯的程式碼物件;如果該模組是內建模組或擴充模組,則回傳 None。如果載入者沒有程式碼物件但確實有原始程式碼,它應該回傳編譯後的原始程式碼。(這是為了讓我們的呼叫者在只需要程式碼物件時,不需要額外檢查 get_source()。)

loader.get_source(fullname) 方法應以字串形式回傳模組的原始程式碼(使用換行字元作為行結尾);如果無法獲得原始碼,則回傳 None(然而,如果匯入者完全找不到模組,仍應拋出 ImportError)。

為了支援將模組作為指令碼執行 (PEP 338),必須實作上述用於尋找與模組相關聯程式碼的三個方法。除了這些方法外,還可以提供以下方法,以便 runpy 模組能夠正確設定 __file__ 屬性:

loader.get_filename(fullname)

此方法應回傳如果載入指定模組時 __file__ 將被設定為的值。如果找不到模組,應拋出 ImportError

與 ‘imp’ 模組的整合

新的匯入掛鉤不容易整合到現有的 imp.find_module()imp.load_module() 呼叫中。這是否可以在不破壞程式碼的情況下實現是有疑問的;最好是在 imp 模組中增加一個新函式。現有的 imp.find_module()imp.load_module() 呼叫的含義會發生變化:從「它們暴露了內建的匯入機制」變為「它們暴露了基本的未掛鉤 (unhooked) 內建匯入機制」。它們將根本不會呼叫任何匯入掛鉤。提議(尚未實作)在 imp 模組中增加一個名為 get_loader() 的新函式,如下例模式所示:

loader = imp.get_loader(fullname, path)
if loader is not None:
    loader.load_module(fullname)

在「基本」匯入的情況下,即 imp.find_module() 函式會處理的情況,載入者物件將成為 imp.find_module() 當前輸出的包裝器,而 loader.load_module() 將使用該輸出呼叫 imp.load_module()

請注意,這個包裝器目前尚未實作,儘管補丁中包含的 test_importhooks.py 指令碼中存在一個 Python 原型(ImpWrapper 類別)。

向前相容性

現有的 __import__ 掛鉤不會自動神奇地呼叫新式掛鉤,除非它們將原始的 __import__ 函式作為回退呼叫。例如,ihooks.pyiu.pyimputil.py 在這方面與此 PEP 不具備向前相容性。

待決問題

模組通常需要支援資料檔案來完成工作,特別是在複雜套件或完整應用程式的情況下。目前的做法通常是透過 sys.path(或 package.__path__ 屬性)來定位這些檔案。這種方法對於透過匯入掛鉤載入的模組通常無法運作。

解決這個問題有幾種可能的方法:

  • 「不要那樣做」。如果套件需要透過其 __path__ 定位資料檔案,則它不適合透過匯入掛鉤載入。該套件仍然可以像目前一樣位於 sys.path 的目錄中,因此這不應被視為主要問題。
  • 從標準位置定位資料檔案,而不是相對於模組檔案。一種相對簡單的方法(distutils 支援)是根據 sys.prefix(或 sys.exec_prefix)定位資料檔案。例如,查找 os.path.join(sys.prefix, "data", package_name)
  • 匯入掛鉤可以提供一種獲取相對於模組檔案之資料檔案的標準方式。標準 zipimport 物件提供了一個 get_data(name) 方法,該方法回傳名為 name 的「檔案」內容作為字串。為了允許模組獲取匯入者物件,zipimport 還向模組新增了一個 __loader__ 屬性,其中包含用於載入模組的 zipimport 物件。如果使用這種方法,重要的是客戶端程式碼要注意如果 get_data() 方法不可用時不要崩潰,因此不清楚這種方法是否為該問題提供了通用答案。

在 python-dev 上有人建議,能夠從匯入者接收可用模組清單和/或用於 get_data() 方法的可用資料檔案清單會很有用。該協定可以增加兩個額外的擴充功能,例如 list_modules()list_files()。後者在具有 get_data() 方法的載入者物件上很有意義。然而,不清楚哪個物件應該實作 list_modules():匯入者還是載入者,或者是兩者都實作?

本 PEP 偏向於從替代位置載入模組:它目前沒有為從替代檔案格式或使用替代編譯器載入模組提供專門的解決方案。相比之下,標準函式庫中的 ihooks 模組確實有一種相當直接的方法來做到這一點。Quixote 專案 [7] 使用此技術將 PTL 檔案匯入為普通 Python 模組。要使用新的掛鉤執行同樣的操作,要么增加一個新的模組,將 ihooks 的子集實作為新式匯入者,要么增加一個可掛鉤的內建路徑匯入者物件。

本 PEP 對掛鉤的「堆疊 (stacking)」沒有特定支援。例如,如何透過組合分別載入 .tar.gz 檔案的掛鉤來編寫一個從 tar.gz 檔案載入模組的掛鉤,這一點並不顯而易見。然而,現有的掛鉤機制(無論是基本的「替換 __import__」方法,還是任何現有的匯入掛鉤模組)對這種堆疊都不支援,因此此功能不是新機制顯而易見的要求。不過,值得作為未來的增強功能考慮。

(透過 sys.meta_path)可以在 sys.path 處理之前新增掛鉤。但是,沒有在 sys.path 處理之後新增掛鉤的對等方式。目前,如果需要在 sys.path 處理之後執行掛鉤,可以透過在 sys.path 的末尾新增一個任意的「Cookie」字串,並透過正常的 sys.path_hooks 處理將所需的掛鉤與此 Cookie 關聯來模擬。長期來看,路徑處理程式碼將成為 sys.meta_path 上的一個「真正」掛鉤,屆時將可以在其之前或之後插入使用者定義的掛鉤。

實作

PEP 302 實作已於 2.3a1 版本整合到 Python 中。較早的版本可在補丁 #652586 [9] 中找到,更有趣的是,該議題包含了相當詳細的開發與設計歷史。

PEP 273 已使用 PEP 302 的匯入掛鉤實作。

參考與註腳


來源:https://github.com/python/peps/blob/main/peps/pep-0302.rst

最後修改:2025-02-01 08:59:27 GMT