Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

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日

目录

Warning

导入的语言参考 [10] 和 importlib 文档 [11] 现已取代此 PEP。此文档不再更新,仅供历史参考。

摘要

本 PEP 提议添加一组新的导入钩子,以更好地定制 Python 导入机制。与当前的 __import__ 钩子相反,新式钩子可以注入到现有方案中,从而更细粒度地控制模块的查找和加载方式。

动机

目前定制导入机制的唯一方法是覆盖内置的 __import__ 函数。然而,覆盖 __import__ 存在许多问题。首先

  • 一个 __import__ 替换需要
    完全 重新实现整个导入机制,或者在自定义代码之前或之后调用原始的 __import__
  • 它具有非常复杂的语义和责任。
  • 即使对于已经存在于 sys.modules 中的模块,__import__ 也会被调用,这几乎不是你想要的,除非你正在编写某种监控工具。

当你需要从 C 语言扩展导入机制时,情况会变得更糟:目前不可能,除了修改 Python 的 import.c 或从头开始重新实现大部分 import.c

在 Python 中编写的工具历史相当悠久,它们基于 __import__ 钩子以各种方式扩展导入机制。标准库包含两个此类工具: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__ 支持),同时也能在运行时提供一组模块。这导致了 issue #642578 [4],该问题被神秘地接受了(主要是因为似乎没有人关心这两种方式 ;-)。然而,当本 PEP 被接受时,它完全是多余的,因为它提供了一种更好、更通用的方法来做同样的事情。

基本原理

在尝试替代实现内置 Zip 导入的方案时,发现只需对 import.c 进行少量修改即可实现此目的。这使得可以将 Zip 特定的内容分解到新的源文件中,同时创建一个
通用 的新导入钩子方案:即您现在正在阅读的这个。

早期设计允许 sys.path 上存在非字符串对象。这样的对象将拥有处理导入所需的必要方法。这有两个缺点:1) 它会破坏假设 sys.path 上的所有项都是字符串的代码;2) 它与 PYTHONPATH 环境变量不兼容。后者是 Zip 导入直接需要的。Jython 提供了一种折衷方案:允许字符串
子类 存在于 sys.path 上,然后它们将充当导入器对象。这避免了一些破坏,并且在 Jython 中似乎运行良好(它用于从 .jar 文件加载模块),但它被认为是“丑陋的 hack”。

这导致了一个更精细的方案(大部分复制自 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 包含一个他称之为
metapath 的东西。在本 PEP 的实现中,它是一个导入器对象列表,在 sys.path
之前 遍历。这个列表是 sys 模块中的另一个新对象:sys.meta_path。目前,此列表默认为空,冻结模块和内置模块的导入在遍历 sys.meta_path 之后,但在 sys.path 之前进行。

规范第一部分:导入器协议

本 PEP 引入了一个新协议:“导入器协议”。理解该协议运行的上下文非常重要,因此这里简要概述导入机制的外部结构。

当遇到导入语句时,解释器会在内置命名空间中查找 __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”,只有当“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
    

规范第二部分:注册钩子

导入钩子有两种类型:
元钩子
路径钩子。元钩子在导入处理开始时调用,在任何其他导入处理之前(以便元钩子可以覆盖 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_pathsys.path_hooks 必须是 Python 列表。
  • sys.path_importer_cache 必须是 Python 字典。

允许就地修改这些变量,也允许用新对象替换它们。

包和 __path__ 的作用

如果模块具有 __path__ 属性,则导入机制将其视为包。在导入包的子模块时,使用 __path__ 变量而不是 sys.pathsys.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) 方法应返回 True 如果由 'fullname' 指定的模块是一个包,如果不是则返回 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 脚本中存在一个 Python 原型(ImpWrapper 类),但此包装器目前尚未实现。

向前兼容性

现有 __import__ 钩子不会通过魔术调用新式钩子,除非它们将原始的 __import__ 函数作为备用调用。例如,ihooks.pyiu.pyimputil.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] 获得,但更有趣的是,该问题包含了开发和设计的相当详细的历史。

PEP 273 已使用 PEP 302 的导入钩子实现。

参考文献和脚注


来源:https://github.com/python/peps/blob/main/peps/pep-0302.rst

最后修改:2025-02-01 08:59:27 GMT