上一篇 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。
Squeeze,Installer 以及 py2exe 所用到的都是基于 __import__
的模式(py2exe 现在还用到了 Installer 的 iu.py
而 Squeeze 则用到了 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 的方法。但这样做有两个缺点:
- 代码不能再假设
sys.path
中存储都都是字符串了; - 它与
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
。如果 finder 的 find_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 | # Consider using importlib.util.module_for_loader() to handle |
规范第二部分:注册 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_path
和sys.path_hooks
必须是 Python 列表sys.path_importer_cache
必须是 Python 字典
可以适当的修改这些变量 —— 用新的对象替换掉它们即可。
包以及 __path__
的角色
如果一个模块包含了 __path__
属性,根据导入机制,它将会被认为是一个包(package)。在导入包的子模块时, __path__
变量会替代 sys.path
。sys.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 | loader = imp.get_loader(fullname, path) |
在 “基本” 的导入过程中,一旦使用 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 得到了实现。