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 页面去看看,如果是常见问题的话,可能已经有对应的解决办法了。

本文系列到此结束。祝编程愉快!

PyInstaller 系列索引