PEP 302 – 新的导入钩子
- 作者:
- Just van Rossum <just at letterror.com>,Paul Moore <p.f.moore at gmail.com>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建:
- 2002年12月19日
- Python版本:
- 2.3
- 历史记录:
- 2002年12月19日
摘要
本PEP建议添加一组新的导入钩子,这些钩子可以更好地自定义Python导入机制。与当前的__import__
钩子相反,新的钩子可以注入到现有的方案中,从而可以更细粒度地控制如何查找模块以及如何加载模块。
动机
目前自定义导入机制的唯一方法是覆盖内置的__import__
函数。但是,覆盖__import__
存在许多问题。首先
__import__
替换需要完全重新实现整个导入机制,或者在自定义代码之前或之后调用原始的__import__
。- 它具有非常复杂的语义和职责。
__import__
即使对于已经存在于sys.modules
中的模块也会被调用,这几乎不是你想要的,除非你正在编写某种监控工具。
当你需要从C扩展导入机制时,情况会变得更糟:目前这是不可能的,除了修改Python的import.c
或从头开始重新实现大部分import.c
。
基于__import__
钩子,Python中有很多工具的历史,这些工具允许以各种方式扩展导入机制。标准库包含两个这样的工具:ihooks.py
(由GvR编写)和imputil.py
[1](Greg Stein编写),但也许最著名的是Gordon McMillan编写的iu.py
,作为其Installer包的一部分提供。它们的实用性有些有限,因为它们是用Python编写的;引导问题需要解决,因为你无法使用钩子本身加载包含钩子的模块。因此,如果你希望整个标准库能够从导入钩子加载,则该钩子必须用C编写。
用例
本节列出了几个依赖于导入钩子的现有应用程序。其中,如果当时存在更灵活的导入钩子,则可以节省大量重复的工作。此PEP应该使将来类似项目的开发变得更加容易。
当你想要加载以非标准方式存储的模块时,需要扩展导入机制。例如,捆绑在存档中的模块;未存储在pyc
格式文件中的字节码;从网络数据库加载的模块。
对本PEP的工作部分是由PEP 273的实现触发的,该PEP将从ZIP归档文件导入作为Python的内置功能添加。虽然PEP本身被广泛接受为一项必须具备的功能,但其实现仍有一些不足之处。一方面,它费尽心思地将自己与import.c
集成,添加了许多特定于ZIP文件导入或不特定于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)在imp
模块中将冻结模块的表公开给Python。主要原因是能够使用冻结导入钩子(避免花哨的__import__
支持),但也要能够在运行时提供一组模块。这导致了问题#642578 [4],该问题被神秘地接受了(主要是因为没有人对此表示关心;-)。然而,当本PEP被接受时,它完全是多余的,因为它提供了一种更好、更通用的方法来完成相同的事情。
基本原理
在尝试使用替代的实现想法来获得内置ZIP导入时,发现只需对import.c
进行少量更改即可实现此目的。这使得能够将特定于ZIP的内容分解到一个新的源文件中,同时创建了一个通用的新导入钩子方案:你现在正在阅读的方案。
早期的设计允许在sys.path
上使用非字符串对象。这样的对象将具有处理导入的必要方法。这有两个缺点:1) 它破坏了假定sys.path
上的所有项目都是字符串的代码;2) 它与PYTHONPATH
环境变量不兼容。后者对于ZIP导入是直接需要的。Jython提供了一个折衷方案:允许在sys.path
上使用字符串子类,然后这些子类将充当导入器对象。这避免了一些破坏,并且似乎对Jython(在其中用于从.jar
文件加载模块)效果很好,但它被认为是“丑陋的技巧”。
这导致了一个更复杂的方案(主要从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
包含一个他称为元路径的东西。在本PEP的实现中,它是一个导入器对象列表,在sys.path
之前遍历。此列表是sys
模块中的另一个新对象:sys.meta_path
。目前,此列表默认为空,并且冻结和内置模块导入在遍历sys.meta_path
之后完成,但在sys.path
之前完成。
规范第1部分:导入器协议
本 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.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__
属性设置为加载器对象。这主要用于内省和重新加载,但可用于导入器特定的额外功能,例如获取与导入器关联的数据。 - 必须设置
__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
规范第2部分:注册钩子
有两种类型的导入钩子:**元钩子**和**路径钩子**。元钩子在导入处理开始时被调用,在任何其他导入处理之前(以便元钩子可以覆盖 sys.path
处理、冻结模块甚至内置模块)。要注册元钩子,只需将查找器对象添加到 sys.meta_path
(已注册元钩子的列表)中。
路径钩子作为 sys.path
(或 package.__path__
)处理的一部分被调用,在遇到其关联路径项的点处。通过将导入器工厂添加到 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_path
和sys.path_hooks
必须是 Python 列表。sys.path_importer_cache
必须是 Python 字典。
允许就地修改这些变量,也允许用新对象替换它们。
包和__path__
的作用
如果模块具有 __path__
属性,则导入机制将将其视为包。__path__
变量在导入包的子模块时会代替 sys.path
使用。因此,sys.path
的规则也适用于 pkg.__path__
。所以当遍历 pkg.__path__
时也会咨询 sys.path_hooks
。元导入器不一定使用 sys.path
来完成其工作,因此可能会忽略 pkg.__path__
的值。在这种情况下,仍然建议将其设置为列表,该列表可以为空。
导入器协议的可选扩展
导入器协议定义了三个可选扩展。一个是检索数据文件,第二个是支持模块打包工具和/或分析模块依赖项的工具(例如 Freeze),最后一个是支持将模块作为脚本执行。后两类工具通常不会实际**加载**模块,它们只需要知道模块是否存在以及在哪里可用。所有三个扩展都强烈推荐用于通用导入器,但如果不需要这些功能,可以安全地省略。
为了从底层存储后端检索任意“文件”的数据,加载器对象可以提供名为 get_data()
的方法
loader.get_data(path)
此方法将数据作为字符串返回,如果未找到“文件”,则引发IOError
异常。数据始终以使用“二进制”模式的方式返回 - 例如,不会对文本文件进行CRLF转换。它适用于具有某些文件系统类属性的导入器。'path'参数是一个路径,可以通过对module.__file__
(或pkg.__path__
项)使用os.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()
调用的含义从:“它们公开内置导入机制”变为:“它们公开基本的未挂钩内置导入机制”。它们根本不会调用任何导入钩子。建议(但尚未实现)在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
脚本(ImpWrapper
类)中存在一个Python原型。
向前兼容性
现有的__import__
钩子不会自动调用新样式的钩子,除非它们调用原始的__import__
函数作为后备。例如,ihooks.py
、iu.py
和imputil.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中没有针对“堆叠”钩子的特定支持。例如,尚不清楚如何编写一个钩子,通过组合加载来自.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实现已集成到Python 2.3a1及更高版本中。较早的版本可作为补丁 #652586 [9]获得,但更有趣的是,该问题包含了开发和设计的相当详细的历史记录。
参考文献和脚注
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0302.rst
上次修改时间:2023-09-09 17:39:29 GMT