0%

深入 Python import 机制 —— PEP 302:新的 import hook

上一篇 blog 主要介绍了 Python import 的机制,其中的 finder 、 loader 、 importer 以及 import 协议都和 PEP320 密切相关。PEP320 的内容倒不是很长,也主要是一些前因后果的内容,同时作为(曾经)新提出的标准,现在也以通过标准库的 importlib 得以实现了。不过,作为前文的扩展内容,也是值得一看的。这篇依然是译文,感觉个人的翻译能力还是有限,不过多少还是会坚持下去,如果有不正确的地方,也欢迎指出 🙂。

概要

这个 PEP 为了自定义 Python 的导入(import)机制提供了一系列新的 import hook(俗称“钩子”)。和现有的 __import__ hook 不同,新的 hook 可以注入(inject)到现有的 schema 中,以便更好地控制模块的查找和加载。

动机(初衷)

目前唯一可以客制化 import 机制的手段就是重写内置的 __import__ 函数。但是,重写 __import__ 函数会带来很多问题,例如:

  • 替换掉 __import__ 需要重新实现一遍整个 import 机制,或者是在自定义代码之前或之后调用原始的 __import__ (来控制影响范围);
  • import 机制不仅语义复杂而且(在 Python 语言中)责任重大,影响深远
  • 对于已经在 sys.modules 中的模块,它们也会调用到 __import__,这几乎是你不想发生的(而又确实发生了的),除非你是在写某些监控工具。

当你需要扩展那些使用 C 语言(编写的模块)的导入机制时,这种情况会变得更加糟糕:除了要 hack Python 的 import.c 之外,还要重新实现大量的 import.c —— 目前来说几乎是不可能的。

通过 __import__ 钩子,以各种途径来扩展 import 机制的工具编写已经有很长的一段历史了。在标准库就包含了两个这样的工具: ihooks.py 以及 imputil.py ;但是最有名的应该是 iu.py 。因为它们是 Python 编写的所以用处不大;这其实是一个 bootstrap 问题(俗称“载入问题”) —— 你无法通过钩子来加载包含钩子本身的模块。所以如果你希望整个标准库都可以通过 import hook 加载,那么这个 hook 必须是用 C 语言编写的。

用例

本小节列出了几个依赖于 import hook 的实际应用例子。它们当中有大量重复的工作,如果当时有更为灵活的 import hook ,是可以被节省下来的。这个 PEP 会使得将来类似的项目更为容易地实现。

当需要加载一个以非标准方式存储的模块时,你不得不扩展 import 机制。示例中包括了:在归档文件中捆绑打包(bundled)的若干模块;没有存储在 pyc 文件中的字节码;通过网络从数据库加载的模块等等。

这个 PEP 的工作有部分是根据 PEP273 的实现而得到启发的,后者是为了给 Python 提供内置的从 zip 格式的归档文件中导入模块的功能而提出的。尽管这个 PEP 作为“必须要有的功能”而得到了广泛的认可,但是其实现上还是不尽人意。首先,它花了很多时间在如何整合 import.c 上,同时为了区分 从 .zip 文件中导入 或者 不指定从 .zip 文件中导入 而添加了大量的代码,这不仅没有什么用,而且也没有人想要。这其实也不能归咎于 PEP273 的实现 —— 鉴于目前 import.c 的实现,它(PEP273)的野心可谓举步维艰。

import hook 的一个典型示例就是最终用户(end user)给应用程序打包 —— 但这不是唯一典型的示例。分发大量源文件或者 .pyc 文件的做法通常都不怎么合适(更不用说要单独装 Python),因此经常有这种需要:将所有需要的模块打包到一个单独的文件中。而实际上这么多年来已经实现了多种的解决方案。

最古老的一个做法是包含在 Python 的源代码中,例如 Freeze 。它将 编组字节码(marshalled byte code) 放到 C 源码的静态对象中去。Freeze 的所谓 “import hook” 实际上很难连接到 import.c,同时它还有好几个问题待解决。后来的解决方案包括了 Fredrik Lundh 的 Squeeze ,Gordon McMillan
Installer(它包括了上文提到的 iu.py) 以及 Thomas Heller 的 py2exe 。MacPython 本身也自带了一个工具叫做 BuildApplication

SqueezeInstaller 以及 py2exe 所用到的都是基于 __import__ 的模式(py2exe 现在还用到了 Installeriu.pySqueeze 则用到了 ihooks.py)。而 MacPython 有两个特定于 Mac 的 import hook 用于硬链接到 import.c ,它们有点类似于 Freeze 的 hook 。而这个 PEP 所提出的 hook 让我们摆脱以往需要硬编码的 hook 才能链接到 import.c(至少在理论上是这样 —— 毕竟这不是一个短期的目标),同时还允许基于 __import__ 的工具摆脱它们当中大部分的 import.c 仿码。

在这个 PEP 的设计和实现工作开始之前,Mac OS X 上的一个类似于 BuildApplication 的新工具,给了该 PEP 作者之一 —— JvR, 一个启发,在 imp 模块中将 Python 的冻结(固化)模块表(Table of frozen module)暴露出来,这其中主要的原因就是可以使用固定的 import hook (避免花里胡俏的 __import__ 支持),同时还能在运行时提供一组模块。这导致了 issue #642578(尽管它被大众莫名其妙地接受了 —— 这主要还是因为似乎并没有人关心这个问题)。然而,当这个 PEP 得到认可时,你就会发现这是多虑的,因为它提供了一种更好、更通用的方式来做同样的事情。

合理性

当你尝试使用其他方法来实现内置的 zip 导入时,你会发现只需要对 import.c 做相当少量的修改就可以实现这一目标。它能够将特定于 .zip 文件的内容拆分为新的源文件,同时创建了一个新的通用的 import hook 模式 —— 即你上面所读到的。

在早期的设计上,sys.path 实际上是允许非字符串类型的对象的。这样的对象必须要有处理 import 的方法。但这样做有两个缺点:

  1. 代码不能再假设 sys.path 中存储都都是字符串了;
  2. 它与 PYTHONPATH 环境变量不兼容了,而这正是基于 .zip 导入所需要的条件。

Jython 提出了一个折衷的做法:sys.path 可以接受 string 的子类对象,让它们充当 importer 对象。这避免了崩坏(大部分的不兼容),同时看上去也很适用于 Jython(因为它通常是从 .jar 文件中加载模块的),但它依然被视为 ugly hack

这导致了一个更为复杂的方案(这大部分都出自 iu.py) —— 准备一个 候选列表,询问列表中的每一项,看它们是否能够处理 sys.path 的项目,直到有一个可以为止。这个所谓 候选列表 就是 sys 模块中的 sys.path_hooks

为每个 sys.path 项遍历 sys.path_hooks 代价过于高昂,因此遍历得到的结果会缓存到 sys 模块的另一个新对象 sys.path_importer_cache 中。实际上它就是将 sys.path 的每一项映射为一个个 importer 对象。

为了最小化对 import.c 的影响以及避免增加额外的开销,它并没有选择给现有的文件系统 import 逻辑增加任何显式的 hook 以及 importer 对象(正如 iu.py 所做的),它选择了一种简单的做法:当没有在 sys.path_hooks 中找到可以处理 sys.path 的项时,交回给内置的逻辑来处理。如果出现这种情况,将会在 sys.path_importer_cache 中存储一个 None 值,以避免重复查找。(稍后我们会进一步地为这个内置的机制添加一个真正的 importer 对象,但现在一个 None 足够作为兜底方案了)。

这时候问题就来了:如果 importer 对象不需要任何 sys.path 上的值(例如内置模块和固化模块 frozen module 这两类),那该怎么办?同样,Gordon 有一个解决办法:iu.py 包含了一个他称为 metapath (元路径)的东西。在这个 PEP 的实现里,这个 importer 对象中的列表会先于 sys.path 被遍历。这个所谓 metapath 列表也是 sys 模块的新对象 —— sys.meta_path。现在,这个列表默认为空,任何内置模块或固化模块都会在这个列表被遍历完之后导入,不过依然会先于 sys.path 的模块导入。

规范第一部分:importer 对象协议

这个 PEP 会介绍一个新的协议(protocol) —— Importer Protocol 。了解协议运行的上下文非常重要,因此在这里会简单介绍这个 import 机制的外部接口(outer shell)。

译者注:在 Python 中,协议 一词通常与 magic method 相关联,也是 duck type(鸭子类型)的一部分。例如,迭代器协议即要求实现 __iter__ 方法;上下文管理器协议要求实现 __enter____exit__ 方法等。

当遇到 import 语句时,解释器会在内置的命名空间中查找 __import__ 函数,然后给它传入四个参数,其中包括要导入的模块名称(可能是相对引用名称)以及当前全局命名空间 globals 的引用。

然后,内置的 __import__ 函数(即 import.c 中的 PyImport_ImportModuleEx() 方法)将检查要导入的模块是一个 package 还是 package 的子模块。如果它是 pacakge(或者是 package 的子模块),他会先尝试相对于 package (子模块的package)进行导入(译者:相对导入)。例如,如果一个 spam 包执行 import eggs 语句,首先函数会去寻找叫 spam.eggs 的模块。如果失败,它会以绝对路径的形式继续导入:他会查找名为 eggs 的模块。

带点号的模块名的工作方式类似于:如果 spam 包执行 import eggs.bacon (同时 spam.eggs 是存在的而且它也是一个包),那么函数会尝试寻找 spam.eggs.bacon 这个模块。如果失败,则尝试 eggs.bacon 模块(这里省略了大量细节,不过这与 importer 协议的实现没有关系)。

让我们更加深入理解这个机制:带点号的模块名导入行为会根据其组件(components)进行分割;例如 import spam.ham,函数会先执行 import spam,只有成功执行之后才会将 ham 作为 spam 的子模块进行导入。

importer 协议作用在单个引入(individual import)上:如果 importer 得到了 spam.ham 的导入请求,那么 spam 必须是已经被导入了的。

这个协议包括两个对象:一个 finder 以及一个 loader

finder 对象只有一个方法:

1
finder.find_module(fullname, path=None)

需要通过模块的 完全限定名 (fully qualified name)来调用。如果已经在 sys.meta_path 安装了这个 finder ,它将接收第二个参数 path —— 对于顶层模块这个参数为 None,对于子模块或者子包来说这个值是 package.__path__。如果找到模块,那么它将返回一个 loader 对象,否则返回 None。如果 finderfind_module() 方法抛出异常,这个异常将会传递给调用者并终止 import 。

loader 同样也只有一个方法:

1
loader.load_module(fullname)

此方法返回已加载的模块或抛出异常。如果一个现有的异常没有传递出去,较好的做法是提示 ImportError : 如果 load_module 加载不到所要求的模块,则抛出 ImportError

很多情况下 finder 和 loader 可以是同一个i对象:即 finder.find_module() 返回 self

两个方法的 fullname 参数都是模块的完全限定名,例如 spam.eggs.ham 。如上所述,当 finder.find_module("spam.eggs.ham") 被调用时,spam.eggs 要求已经被导入同时存在于 sys.modules 列表中。但是在实际的导入中 find_module() 方法不一定会被调用:像一些元工具(如 freeze、Installer 和 py2exe)会分析导入的依赖关系而不会真正导入模块。因此 finder 不能依赖于 “父 package 已经存在于 sys.modules” 这种想法。

load_module() 方法在执行任何代码之前都必须要做一些工作:

  • 如果 sys.modules 中存在跟 fullname 命名一致的模块,loader 必须使用这个现有的模块(否则,内置 reload() 就不能正常工作)。如果 sys.modules 没有找到 fullname 命名的模块,loader 必须创建一个新的 module 对象并添加到 sys.modules 中去。

    请注意,在 loader 执行模块代码之前,module 对象必须已经在 sys.modules 中。这至关重要 —— 因为模块代码可能(直接或间接地) import 它本身。首先将其添加到 sys.modules 可以防止发生无限递归(在最坏情况下)以及多次加载(在最好情况下)。

    如果加载失败,loader 需要移除那些可能已经插入到 sys.modules 的模块。如果模块是之前已经加载到 sys.modules 中的,那就不用管。

  • 必须设置 __file__ 属性。它必须是一个字符串,但它可以是一个虚拟值(dummy value),例如 <fronze> 。只有内置模块可以有这种不设置 __file__ 属性的特权。
  • 必须设置 __name__ 属性。如果是通过 imp.new_module() 创建的那么这个属性就会被自动设置。
  • 如果它是一个 package,那么 __path__ 变量必须设置。它必须是一个 list ,但可以为空 —— 如果 __path__ 对 importer 没什么意义(这会在后面详细介绍)。
  • loader 对象必须包含 __loader__ 属性。这主要是用于内省(introspection)和重载(reload)。但它们也可以被用于特定的导入扩展,例如通过 importer 获取数据。
  • __package__ 对象必须设置。

如果这个模块是一个 Python 模块(而不是内置模块或者是动态加载的扩展),他应该在模块的全局命名空间(module.__dict__)中执行模块代码。

这里有一个符合上述要求的轻量级的 load_module() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Consider using importlib.util.module_for_loader() to handle
# most of these details for you
# 建议使用 importlib.util.module_for_loader 来处理其中的细节
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

规范第二部分:注册 hook

这里有两种类型的 import hook :Meta hook 以及 Path hook 。Meta hook 会在 import 处理过程开头被调用,也就是说,它会比其他导入处理更早被处理(所以 meta hook 可以覆盖 sys.path 的处理,甚至是 frozen module 以及内置 module)。要注册 meta hook ,只需要将 finder 对象添加到 sys.meta_path (即已注册的 meta hook 列表)即可。

sys.path (或者是 package.__path__) 中相关的 path 项需要处理时,path hook 就会作为这个处理过程的一部分而被调用。添加一个 importer 的对象工厂到 sys.path_hooks 即可注册 path hook 。

sys.path_hooks 是一个可调用对象列表,它会按顺序遍历其中的可调用对象,看它们中是否能够处理给定的 path 项。(如果可以)这将这个 path 项作为参数调用这个可调用对象。可调用对象如果不能处理这个 path 项,必须抛出 ImportError 异常;否则返回一个可以处理这个 path 项的 importer 对象。注意如果可调用对象返回了针对这个特定的 sys.path 条目的 importer 对象,则内置默认的 import 机制就不会再被用来处理这个条目了,即便后面这个 importer 对象处理失败也不会有任何默认机制。

这个可调用对象一般是 import hook 的类,因此会调用到类的 __init__() 方法(这也是为什么在失败时要抛出 ImportError 异常:因为 __init__() 方法不会返回任何值。当然你也可以用新式类的 __new__ 来实现这个 callable ,但我们并不想强制要求针对 hook 的实现做文章)。

path hook 的检查结果将会缓存在 sys.path_importer_cache 中,这是一个字典,负责映射 path 条目和 importer 对象之间的关系。在扫描 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__ 属性,根据导入机制,它将会被认为是一个包(package)。在导入包的子模块时, __path__ 变量会替代 sys.pathsys.path 的这个规则也适用于 pkg.__path__ 。因此当遍历 pkg.__path__ 时也会查询 sys.path_hooks 。对于 Meta importer 对象而言,它们的工作不一定会用到 sys.path ,因此可能会忽略 pkg.__path__ 的值。在这种情况下,我们依然建议将其设置为空列表(而不是不设置)。

可选的 importer 协议扩展

importer 协议定义了三个可选的扩展,一个是负责检索数据文件,第二个是提供支持给模块打包工具或模块依赖分析工具(例如 Freeze),最后一个是支持模块作为脚本直接执行。后两个通常不会实际加载模块,它们只需要知道模块们是否可用以及身在何处。对于通用的 importer 来说,我们强烈建议实现这全部三个扩展,但如果不需要这些功能,省略实现也没有关系。

译者:这部分内容暂时不做翻译 😂 请见谅

imp 模块的整合

新的 import hook 要集成到现有的 imp.find_module() 以及 imp.load_module() 中并不容易。我们不确定是否能在不破坏现有代码的前提下实现这个目标 —— 因此最好的方式是为 imp 模块加一个新的函数。这意味着现有的 imp.find_module() 以及 imp.load_module() 的作用将从 “暴露内置导入机制” 变为 “暴露基本的、未经 hook 的内置导入机制” 。它们不会调用任何 import hook 。一个名为 get_loader() 的新的 imp 模块函数(尽管尚未实现)将遵循以下的模式使用:

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

在 “基本” 的导入过程中,一旦使用 imp.find_module() 函数来处理,得到的 loader 对象会被包装为 imp.find_module() 函数的输出,然后 loader.load_module() 函数会带着这个输出调用 imp.load_module() 函数。

注意,这个包装器尚未实现,尽管在 test_importhooks.py 脚本中已经存在了包含这个补丁的一个 Python 原型(即 ImpWrapper 类)。

实现

PEP302 已经在 Python 2.3a1 版本中被实现。早期的版本可以参加 issue #652586 的补丁来实现这个 PEP。但更为有意思的是,这个 issue 包含了相当详细的开发和设计历史。

PEP273 已经通过 PEP302 的 import hook 得到了实现。