PyInstaller 系列 - Hook 機制


【原文地址:https://shuhari.dev/blog/2018/6/pyinstaller-hook】

在本系列前面的文章中,我們已經提到過 PyInstaller 的 Hook,不過尚未詳細說明它是怎么回事。本文就將介紹關於 Hook 的知識。

注意,本文講述的內容屬於比較高級的部分,一般用戶可以如果沒有問題的話,可以不用特意去關心它。但是在如果發生下列情況之一,你可能還是需要對 Hook 有點基本的了解:

  • 你使用 PyInstaller 的時候遇到 Hook 相關的錯誤;
  • 你在閱讀 PyInstaller 相關文章或手冊時遇到了有關 Hook 的內容;
  • 你需要為自己的模塊提供 Hook;
  • 你想要為 PyInstaller 貢獻代碼;

如果不是上述情況的話,你也可以略過本文,等以后有需要的時候回頭再來看。

什么是 PyInstaller Hook?

要回答這個內容,我們還要從頭說起。

我們知道,除了最基本的測試程序之外,絕大多數實際的程序都是有多個模塊甚至是多個包構成的。為了從它們生成可執行的文件,PyInstaller 當然需要知道程序究竟知道哪些模塊/包,並將它們包含在發布的文件中。但我們並沒有明確告知 PyInstaller 我們使用了哪些文件,那么 PyInstaller 又是如何知道這些信息的呢?

簡單的說,PyInstaller 使用了遞歸方法,從入口的腳本文件逐個分析,看它們到底使用了哪些模塊。像下面這些引用形式就是 PyInstaller 可以明確識別的:

import xx from xx import yy 

此外,PyInstaller 也能識別 ctypes、SWIG、Cython 等形式的模塊調用,但前提是文件名必須為字面值。但是,PyInstaller 無法識別動態和調用,例如 import、exec、eval, 以及以變量為參數的調用(因為不到運行時無法知道實際值)。

當 PyInstaller 識別完所有模塊后,會在內部構成一個樹形結構表示調用關系圖,該關系在生成目標時也會一並輸出(也就是前面提到過的 xref-xxxx.html 文件)。PYZ 步驟會將所有識別到的模塊匯集起來,如果有必要的話編譯成 .pyd,然后將這些文件打包。這就是大致的模塊處理過程。

但這個過程還有一些潛在的問題沒有解決。一個就是我們前面提到的,有些動態模塊調用未必可以自動識別到,這樣它們就不會打包到文件中,追鍾執行時肯定會出現問題。另一個是:有些模塊並非是以模塊的形式,而是通過文件系統去訪問 .py 的,這些代碼在運行時同樣會出現問題。對這樣的程序該如何處理呢?

PyInstaller 對上述問題的解決辦法是 Hooks。實際上,有兩種類型的 Hook, 大致對應於上面兩種類型的問題。但更嚴格地說,這兩種 Hook 主要是按照加載時間區分的。第一種在 PyInstaller 文檔中沒有明確的命名,不過它是在生成過程中,導入特定模塊時調用的,因此可以稱為 Import Hook;另一種是 PyInstaller 稱為運行時 Hook(Runtime Hook),它是在執行文件啟動期間、加載特定模塊時調用的。

Import Hooks

PyInstaller 定義的所有 Hook 是很容易找到的。首先我們定位到 PyInstaller 安裝目錄。一個你應該知道的小技巧:如果你不清楚安裝位置的話,可以使用 Python 中定義的特殊變量 __file__:

PyInstaller 安裝位置

在 PyInstaller 的 hooks 子目錄下,我們可以看到許多腳本文件。這些文件的命名很有規律,均為 hook-[模塊名].py 的形式。這些文件就是所謂的 Import Hook。

Import Hook

當 PyInstaller 生成過程中找到特定的導入模塊,就會到該目錄下查找是否存在對應的 Hook,如果存在,則執行之。

我們可以找一個具體的例子來看。我個人比較熟悉 Django(本站就是用 Django 開發的),那么找一個 Django 相關的 Hook 來看,比如 hook-django.core.cache.py:

from PyInstaller.utils.hooks import collect_submodules hiddenimports = collect_submodules('django.core.cache.backends') 

代碼很簡單,不過這里出現了一個名詞 Hidden Imports。PyInstaller 用 Hidden Imports (隱式導入) 來描述那些並非通過 import 明確導入,而是通過其他動態機制加載的模塊。由於 PyInstaller 無法自動識別到它們,所以需要有這樣的輔助方法來幫助它找到必要的引用。熟悉 Django 的同學看到這里應該明白了,Django 的各種 Backends 大多都是動態加載而非直接 import 的,所以我們確實需要這樣的 Import Hook。

我們還能看到,該目錄下還有幾個子目錄,比如 pre_find_module_pathpre_safe_import_module。這些目錄的目的是為了更精細地調整模塊導入行為(你從目錄名字應該大致能猜到它們各自地作用)。由於這些內容更加高級,這里就不再展開了。PyInstaller 官方文檔中有一些詳細的說明。

Runtime Hooks

運行時鈎子(Runtime Hooks)和上面的 Import Hooks 有一些差別。首先,它們均位於 PyInstaller 的 loader\rthooks 子目錄下,並且它們的命名方式是 pyi_rth_[模塊名稱].py(顯而易見,rth 代表 run time hook):

Runtime Hook 目錄

此外,還有一個重要的文件 loader\rthooks.dat。 它的內容基本上就是一個字典,記錄了系統中所有支持的 Runtime Hooks:

{ 'django': ['pyi_rth_django.py'], 'enchant': ['pyi_rth_enchant.py'], 'gi': ['pyi_rth_gi.py'], ... } 

Runtime Hooks 是在執行文件運行期間執行的。在前面的文章中我們說過,PyInstaller 修改了模塊加載機制,當運行期間加載任何模塊時,PyInstaller 會檢查是否有對應的 Runtime Hook,如果有,則運行該 Hook。為此,Runtime Hooks 是和腳本一起編譯到可執行文件中的。

我們也可以找一個具體的例子來看看,比如 pyi_rth_django.py:

import django.core.management import django.utils.autoreload def _get_commands(): # Django groupss commands by app. # This returns static dict() as it is for django 1.8 and the default project. commands = { 'changepassword': 'django.contrib.auth', 'check': 'django.core', ... } return commands def _restart_with_reloader(*args): ... # Override get_commands() function otherwise the app will complain that # there are no commands. django.core.management.get_commands = _get_commands # Override restart_with_reloader() function otherwise the app might # complain that some commands do not exist. e.g. runserver. django.utils.autoreload.restart_with_reloader = _restart_with_reloader 

簡潔起見,我略去了一些比較冗長的內容。這里的邏輯還是很清楚的:通過 Runtime Hook, PyInstaller 直接替換掉了 Django 的一些內置命令。可以想象,如果不這么做的話,那么通過執行文件運行 Django 的這些命令是會出現問題的。我們也可以自己驗證一下。首先自己手工執行如下命令(當然你要首先安裝了 Django):

from django.core.management import get_commands get_commands() >>> {'check': 'django.core', 'compilemessages': 'django.core', ...} 

沒有問題。現在,我們故意屏蔽掉針對 Django 的 Runtime Hook。為此,修改 loader\rthooks.dat:

{ # 'django': ['pyi_rth_django.py'], 'enchant': ['pyi_rth_enchant.py'], ... } 

接下來,把主腳本文件修改為如下內容,然后用 PyInstaller 編譯:

from django.core.management import get_commands print('django get commands:', get_commands()) 

運行執行文件,結果:

dist\main\main.exe > django get commands: {} 

果然,所有命令都找不到了。

我們還可以繼續深入研究,為什么從執行文件調用 get_commands() 會返回空。但該命令還調用了其他代碼,嵌套關系比較復雜,同時我也擔心改壞了 Django,目前就到此為止吧(有好奇心的同學不妨繼續深入研究,不過建議先做好備份)。實驗完畢后,別忘了把修改過的 rthooks.dat 再復原回來。

一點題外話:看過 Runtime hooks 的實現機制后,我會 PyInstaller 產生了一點擔憂。這樣的實現可以說近乎暴力破解,而且針對各種第三方模塊手工適配,很明顯會有維護性的問題——以后 Django 更新了怎么辦?這么多第三方模塊都要如此適配,工作量該有多大?

或許是我杞人憂天吧。不過我確實建議 PyInstaller 的用戶:請盡量在程序中只使用知名的第三方模塊,比如 tkiner、Django、PyQt,因為它們更容易得到充分的支持;比較小眾的模塊則未必有足夠的測試,遇到問題的可能性更大。當然如果你有足夠能力的話,也可以考慮為 PyInstaller 去修正 bug。

最后一點提醒,如果你遇到一些疑難問題的話,請首先去官方的 FAQ 或者 Recipes 頁面去看看,如果是常見問題的話,可能已經有對應的解決辦法了。

本文系列到此結束。祝編程愉快!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM