PEP 420 – 隐式命名空间包
- 作者:
- Eric V. Smith <eric at trueblade.com>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2012年4月19日
- Python 版本:
- 3.3
- 发布历史:
- 决议:
- Python-Dev 消息
摘要
命名空间包是一种将单个 Python 包拆分到磁盘上多个目录中的机制。在当前的 Python 版本中,必须制定一个算法来计算包的 __path__。通过这里提出的增强,导入机制本身将构建组成包的目录列表。本 PEP 建立在先前的工作基础之上,这些工作记录在 PEP 382 和 PEP 402 中。这些 PEP 已被拒绝,转而支持本 PEP。本 PEP 的实现参见 [1]。
术语
在本 PEP 中
- “包”指的是 Python 的 import 语句定义的 Python 包。
- “分发”指的是存储在 Python 包索引中并由 distutils 或 setuptools 安装的可单独安装的 Python 模块集。
- “供应商包”指的是由操作系统的打包机制安装的文件组(例如,Debian 或 Redhat 包安装在 Linux 系统上)。
- “常规包”指的是 Python 3.2 及更早版本中实现的包。
- “部分”指的是单个目录中(可能存储在 zip 文件中)为命名空间包做出贡献的一组文件。
- “传统部分”指的是使用
__path__操作来实现命名空间包的部分。
本 PEP 定义了一种新型包:“命名空间包”。
当前的命名空间包
Python 目前提供了 pkgutil.extend_path 来将包指定为命名空间包。推荐的使用方式是将其放在
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
在包的 __init__.py 中。每个分发都需要在其 __init__.py 中提供相同的内容,这样无论首先导入哪个包部分,都会调用 extend_path。因此,包的 __init__.py 实际上无法定义任何名称,因为它依赖于 sys.path 上包片段的顺序来确定哪个部分首先被导入。作为一项特殊功能,extend_path 读取名为 <packagename>.pkg 的文件,允许声明附加部分。
setuptools 提供了一个名为 pkg_resources.declare_namespace 的类似函数,其使用形式为
import pkg_resources
pkg_resources.declare_namespace(__name__)
在部分的 __init__.py 中,不需要对 __path__ 进行赋值,因为 declare_namespace 通过 sys.modules 修改包的 __path__。作为一项特殊功能,declare_namespace 还支持 zip 文件,并在内部注册包名,以便 setuptools 将来的 sys.path 添加能够正确地为每个包添加附加部分。
setuptools 允许在分发的 setup.py 中声明命名空间包,这样分发开发者就不需要自己将神奇的 __path__ 修改放入 __init__.py 中。
有关命名空间包的其他动机,请参阅 PEP 402 的 “问题” 部分。请注意,PEP 402 已被拒绝,但其激励性用例仍然有效。
基本原理
当前对命名空间包的命令式方法导致了多种略有不兼容的提供命名空间包的机制。例如,pkgutil 支持 *.pkg 文件;setuptools 不支持。同样,setuptools 支持检查 zip 文件,并支持将其 _namespace_packages 变量添加部分,而 pkgutil 不支持。
命名空间包旨在支持跨多个目录(因此通过多个 sys.path 条目查找)拆分。在这种配置下,多个部分是否都提供了 __init__.py 文件并不重要,只要每个部分都正确初始化了命名空间包。然而,Linux 分发商(以及其他)更喜欢将单独的部分组合起来并将它们全部安装到同一个文件系统目录中。这会造成潜在的冲突,因为这些部分现在试图在目标系统上提供相同的文件——这是许多包管理器不允许的。允许隐式命名空间包意味着可以完全取消提供 __init__.py 文件的要求,受影响的部分可以根据分发的需要安装到公共目录或拆分到多个目录中。
命名空间包将不受在命名空间包创建时从父路径计算出的固定 __path__ 的限制。考虑标准库 encodings 包
- 假设
encodings成为一个命名空间包。 - 它有时会在解释器启动期间被导入以初始化标准 IO 流。
- 应用程序在启动后修改
sys.path并希望从新的路径条目贡献额外的编码。 - 尝试从步骤3中添加的路径条目中找到的
encodings部分导入编码。
如果导入系统被限制为只在创建 encodings 命名空间包时存在的 sys.path 值中查找部分,那么在步骤3中添加的额外路径将永远不会被搜索以查找在步骤4中导入的额外部分。此外,如果步骤2有时被跳过(由于某个运行时标志或其他条件),那么在步骤3中添加的路径项确实会在首次导入部分时使用。因此,本 PEP 要求在加载每个部分时动态计算路径条目列表。预计导入机制将通过缓存 __path__ 值并在检测到父路径已更改时才刷新它们来高效地完成此操作。对于像 encodings 这样的顶层包,此父路径将是 sys.path。
规范
常规包将继续拥有一个 __init__.py 并驻留在单个目录中。
命名空间包不能包含 __init__.py。因此,pkgutil.extend_path 和 pkg_resources.declare_namespace 在创建命名空间包方面变得过时。将没有用于指定命名空间包的标记文件或目录。
在导入处理过程中,导入机制将继续像 Python 3.2 中那样遍历父路径中的每个目录。在查找名为“foo”的模块或包时,对于父路径中的每个目录
- 如果找到
<directory>/foo/__init__.py,则导入并返回一个常规包。 - 如果未找到,但找到
<directory>/foo.{py,pyc,so,pyd},则导入并返回一个模块。确切的扩展名列表因平台和是否指定了 -O 标志而异。这里的列表是代表性的。 - 如果未找到,但找到
<directory>/foo且它是一个目录,则将其记录下来,并继续扫描父路径中的下一个目录。 - 否则,继续扫描父路径中的下一个目录。
如果扫描完成而没有返回模块或包,并且至少记录了一个目录,则创建命名空间包。新的命名空间包
- 具有一个
__path__属性,设置为在扫描过程中找到并记录的路径字符串的可迭代对象。 - 没有
__file__属性。
请注意,如果执行“import foo”并且“foo”被发现是一个命名空间包(使用上述规则),那么“foo”会立即作为一个包被创建。命名空间包的创建不会延迟到子级别导入发生时。
命名空间包与常规包没有根本区别。它只是一种不同的创建包的方式。一旦创建了命名空间包,它与常规包在功能上没有区别。
动态路径计算
导入机制将表现得好像在加载每个部分之前重新计算了命名空间包的 __path__。
出于性能原因,预计这将通过检测父路径是否已更改来实现。如果未发生更改,则无需重新计算 __path__。实现必须确保检测到父路径内容的更改,以及检测到父路径被新的路径条目列表对象替换。
对导入查找器和加载器的影响
PEP 302 定义了“查找器”,它们被调用来搜索路径元素。这些查找器的 find_module 方法返回一个“加载器”对象或 None。
为了让查找器为命名空间包做出贡献,它必须实现一个新的 find_loader(fullname) 方法。fullname 的含义与 find_module 相同。find_loader 总是返回一个 (loader, <iterable-of-path-entries>) 的2元组。loader 可以是 None,在这种情况下,<iterable-of-path-entries>(可能为空)被添加到已记录的路径条目列表中,并继续路径搜索。如果 loader 不是 None,则它会立即用于加载模块或常规包。
即使返回了 loader 且它不是 None,<iterable-of-path-entries> 仍然必须包含包的路径条目。这允许像 pkgutil.extend_path() 这样的代码计算它不加载的包的路径条目。
请注意,允许每个查找器有多个路径条目。这是为了支持查找器为给定 fullname 发现多个命名空间部分的情况。许多查找器将仅支持每个 find_loader 调用一个命名空间包部分,在这种情况下,此可迭代对象将仅包含一个字符串。
导入机制将调用 find_loader(如果存在),否则回退到 find_module。实现 find_module 但未实现 find_loader 的传统查找器将无法为命名空间包贡献部分。
此规范扩展了 PEP 302 加载器,以包含一个可选方法 module_repr(),如果存在,则用于生成模块对象的 repr。有关详细信息,请参阅下面的部分。
命名空间包和常规包的区别
命名空间包和常规包非常相似。区别在于
- 命名空间包的部分不必都来自相同的目录结构,甚至不必来自相同的加载器。常规包是自包含的:所有部分都位于相同的目录层次结构中。
- 命名空间包没有
__file__属性。 - 命名空间包的
__path__属性是一个只读的字符串可迭代对象,当父路径被修改时会自动更新。 - 命名空间包没有
__init__.py模块。 - 命名空间包的
__loader__属性是不同类型的对象。
标准库中的命名空间包
标准库的一部分可以作为命名空间包实现是可能的,本 PEP 明确允许。标准库中的任何包何时以及是否成为命名空间包超出了本 PEP 的范围。
从传统命名空间包迁移
如上所述,在本 PEP 之前,传统部分使用 pkgutil.extend_path() 来创建命名空间包。由于所有现有命名空间包部分可能不切实际地一次性迁移到本 PEP,extend_path() 将被修改以识别 PEP 420 命名空间包。这将允许命名空间的一些部分是传统部分,而其他部分迁移到 PEP 420。这些混合命名空间包将不具有普通命名空间包所具有的动态路径计算功能,因为 extend_path() 过去从未提供此功能。
打包影响
命名空间包的多个部分可以安装到同一个目录,也可以安装到不同的目录。对于本节,假设有两个部分定义了“foo.bar”和“foo.baz”。“foo”本身是一个命名空间包。
如果这些都安装在同一个位置,则在 sys.path 上的目录中会有一个名为“foo”的单一目录。在“foo”内部将有两个目录,“bar”和“baz”。如果“foo.bar”被移除(可能由操作系统包管理器移除),则必须小心不要移除“foo/baz”或“foo”目录。请注意,在这种情况下,“foo”将是一个命名空间包(因为它缺少 __init__.py),即使它的所有部分都在同一个目录中。
请注意,“foo.bar”和“foo.baz”可以安装到同一个“foo”目录中,因为它们不会有任何共同的文件。
如果这些部分安装在不同的位置,则在 sys.path 上的目录中会有两个不同的“foo”目录。“foo/bar”将位于其中一个 sys.path 条目中,“foo/baz”将位于另一个条目中。在移除“foo.bar”后,“foo/bar”和相应的“foo”目录可以完全移除。但“foo/baz”及其相应的“foo”目录不能移除。
还可以将“foo.bar”部分安装在 sys.path 上的一个目录中,并将“foo.baz”部分提供在一个 zip 文件中,该 zip 文件也在 sys.path 上。
示例
嵌套命名空间包
此示例使用以下目录结构
Lib/test/namespace_pkgs
project1
parent
child
one.py
project2
parent
child
two.py
在这里,父级和子级都是命名空间包:它们的部分存在于不同的目录中,并且它们没有 __init__.py 文件。
在这里,我们将父目录添加到 sys.path,并显示这些部分被正确找到
>>> import sys
>>> sys.path += ['Lib/test/namespace_pkgs/project1', 'Lib/test/namespace_pkgs/project2']
>>> import parent.child.one
>>> parent.__path__
_NamespacePath(['Lib/test/namespace_pkgs/project1/parent', 'Lib/test/namespace_pkgs/project2/parent'])
>>> parent.child.__path__
_NamespacePath(['Lib/test/namespace_pkgs/project1/parent/child', 'Lib/test/namespace_pkgs/project2/parent/child'])
>>> import parent.child.two
>>>
动态路径计算
此示例使用类似的目录结构,但添加了第三个部分
Lib/test/namespace_pkgs
project1
parent
child
one.py
project2
parent
child
two.py
project3
parent
child
three.py
我们将 project1 和 project2 添加到 sys.path,然后导入 parent.child.one 和 parent.child.two。然后我们将 project3 添加到 sys.path,当导入 parent.child.three 时,project3/parent 会自动添加到 parent.__path__
# add the first two parent paths to sys.path
>>> import sys
>>> sys.path += ['Lib/test/namespace_pkgs/project1', 'Lib/test/namespace_pkgs/project2']
# parent.child.one can be imported, because project1 was added to sys.path:
>>> import parent.child.one
>>> parent.__path__
_NamespacePath(['Lib/test/namespace_pkgs/project1/parent', 'Lib/test/namespace_pkgs/project2/parent'])
# parent.child.__path__ contains project1/parent/child and project2/parent/child, but not project3/parent/child:
>>> parent.child.__path__
_NamespacePath(['Lib/test/namespace_pkgs/project1/parent/child', 'Lib/test/namespace_pkgs/project2/parent/child'])
# parent.child.two can be imported, because project2 was added to sys.path:
>>> import parent.child.two
# we cannot import parent.child.three, because project3 is not in the path:
>>> import parent.child.three
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<frozen importlib._bootstrap>", line 1286, in _find_and_load
File "<frozen importlib._bootstrap>", line 1250, in _find_and_load_unlocked
ImportError: No module named 'parent.child.three'
# now add project3 to sys.path:
>>> sys.path.append('Lib/test/namespace_pkgs/project3')
# and now parent.child.three can be imported:
>>> import parent.child.three
# project3/parent has been added to parent.__path__:
>>> parent.__path__
_NamespacePath(['Lib/test/namespace_pkgs/project1/parent', 'Lib/test/namespace_pkgs/project2/parent', 'Lib/test/namespace_pkgs/project3/parent'])
# and project3/parent/child has been added to parent.child.__path__
>>> parent.child.__path__
_NamespacePath(['Lib/test/namespace_pkgs/project1/parent/child', 'Lib/test/namespace_pkgs/project2/parent/child', 'Lib/test/namespace_pkgs/project3/parent/child'])
>>>
讨论
在 PyCon 2012 上,我们讨论了命名空间包,其中 PEP 382 和 PEP 402 被拒绝,并由本 PEP [3] 取代。
没有移除对常规包支持的意图。如果开发者知道她的包永远不会是命名空间包的一部分,那么作为常规包(带有 __init__.py)会有一个性能优势。常规包的创建和加载可以在沿路径定位时立即进行。而对于命名空间包,必须扫描路径中的所有条目才能创建包。
请注意,对于缺少 __init__.py 文件的目录,将不再引发 ImportWarning。这样的目录现在将作为命名空间包导入,而在以前的 Python 版本中会引发 ImportWarning。
Alyssa (Nick) Coghlan 提出了她对这项提案的一些异议 [4]。它们是
- 隐式包目录与 Python 之禅背道而驰。
- 隐式包目录带来了尴尬的向后兼容性挑战。
- 隐式包目录使文件系统布局变得模糊。
- 隐式包目录将永久固化
__main__中当前对新手不友好的行为。
Alyssa 后来对她自己的异议给出了详细的回复 [5],总结如下
将命名空间包包含在标准库中是 Martin v. Löwis 提出的,他希望 encodings 包成为一个命名空间包 [6]。尽管本 PEP 允许标准库包成为命名空间,但它将 encodings 的决定推迟。
find_module 与 find_loader
本 PEP 的早期草案规定修改 find_module 方法以支持命名空间包。它将被修改为在发现命名空间包部分时返回一个字符串。
然而,这给标准库之外调用 find_module 的现有代码带来了问题。由于这些代码不会与本 PEP 所要求的更改同步升级,因此当它从 find_module 接收到意外的返回值时,它将失败。由于这种不兼容性,本 PEP 现在规定,希望提供命名空间部分的查找器必须实现上面描述的 find_loader 方法。
支持每次 find_loader 调用多个部分的用例在 [7] 中给出。
动态路径计算
Guido 提出担忧,认为自动动态路径计算是一个不必要的功能 [8]。在该讨论串的后续内容中,PJ Eby 和 Alyssa Coghlan 提出了论点,说明了动态计算如何最大程度地减少 Python 用户的困惑。该讨论的结论已包含在本 PEP 的“基本原理”部分中。
本 PEP 的早期版本要求动态路径计算只有在父路径对象原地修改时才能生效。也就是说,这将有效
sys.path.append('new-dir')
但这不会
sys.path = sys.path + ['new-dir']
在同一个讨论串 [8] 中,指出此限制不是必需的。如果父路径是通过名称而不是通过持有其引用来查找,则对父路径的修改或替换没有限制。对于顶层命名空间包,查找将是名为 "sys" 的模块及其属性 "path"。对于嵌套在包 foo 中的命名空间包,查找将是名为 "foo" 的模块及其属性 "__path__"。
模块reprs
以前,模块 repr 是根据模块 __file__ 属性的假设硬编码的。如果此属性存在且为字符串,则假定它是文件系统路径,并且模块对象的 repr 将其包含在值中。唯一的例外是 PEP 302 将缺失的 __file__ 属性保留给内置模块,并且在 CPython 中,此假设已内置到模块对象的实现中。由于此限制,某些模块包含人为的 __file__ 值,这些值不反映文件系统路径,并可能在以后导致意外问题(例如,在非路径 __file__ 上调用 os.path.join() 将返回乱码)。
本 PEP 放宽了此限制,并将 __file__ 的设置留给生成模块的加载器。如果没有任何文件系统路径是合适的,加载器可以选择不设置 __file__。加载器还可以根据需要为模块设置额外的保留属性。这意味着确定模块来源的明确方法是检查其 __loader__ 属性。
例如,本 PEP 中描述的命名空间包将没有 __file__ 属性,因为不存在对应的文件。为了在此类模块的 repr 中提供灵活性和描述性,向 PEP 302 加载器添加了一个新的可选协议。加载器可以实现一个 module_repr() 方法,该方法接受一个参数,即模块对象。此方法应返回用作模块 repr 的字符串。生成模块 repr 的规则现在标准化为
- 如果模块具有
__loader__并且该加载器具有module_repr()方法,则以模块对象作为单个参数调用它。返回的值用作模块的 repr。 - 如果在
module_repr()中发生异常,则会捕获并丢弃该异常,并且模块 repr 的计算将继续,如同module_repr()不存在一样。 - 如果模块具有
__file__属性,则此属性用作模块 repr 的一部分。 - 如果模块没有
__file__但有__loader__,则加载器的 repr 用作模块 repr 的一部分。 - 否则,只使用模块的
__name__作为 repr。
以下是显示命名空间模块 repr 如何从其加载器计算的代码片段
class NamespaceLoader:
@classmethod
def module_repr(cls, module):
return "<module '{}' (namespace)>".format(module.__name__)
内置模块的 repr 不再需要硬编码,而是也来自其加载器
class BuiltinImporter:
@classmethod
def module_repr(cls, module):
return "<module '{}' (built-in)>".format(module.__name__)
以下是不同类型模块在不同相关属性集下的 repr 示例
>>> import email
>>> email
<module 'email' from '/home/barry/projects/python/pep-420/Lib/email/__init__.py'>
>>> m = type(email)('foo')
>>> m
<module 'foo'>
>>> m.__file__ = 'zippy:/de/do/dah'
>>> m
<module 'foo' from 'zippy:/de/do/dah'>
>>> class Loader: pass
...
>>> m.__loader__ = Loader
>>> del m.__file__
>>> m
<module 'foo' (<class '__main__.Loader'>)>
>>> class NewLoader:
... @classmethod
... def module_repr(cls, module):
... return '<mystery module!>'
...
>>> m.__loader__ = NewLoader
>>> m
<mystery module!>
>>>
参考资料
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0420.rst