PEP 402 – 简化包布局和分区
- 作者:
- Phillip J. Eby
- 状态:
- 已拒绝
- 类型:
- 标准跟踪
- 主题:
- 打包
- 创建:
- 2011年7月12日
- Python 版本:
- 3.3
- 历史记录:
- 2011年7月20日
- 替换:
- 382
拒绝通知
在 2012 年美国 PyCon 冲刺的第一天,我们对PEP 382 和PEP 402进行了长时间而富有成效的讨论。我们最终拒绝了这两个提案,但将撰写一个新的 PEP 以延续 PEP 402 的精神。Martin von Löwis 撰写了一份总结:[3]。
摘要
本 PEP 提出对 Python 的包导入进行增强,以
- 减少其他语言用户的惊讶感,
- 简化将模块转换为包的过程,以及
- 支持将包划分为独立安装的组件(类似于“命名空间包”,如PEP 382中所述)
提议的增强功能不会更改任何当前可导入的目录布局的语义,但使包能够使用简化的目录布局(当前不可导入)。
但是,提议的更改不会对现有模块或包的导入增加任何性能开销,并且新目录布局的性能应该与以前的“命名空间包”解决方案(例如pkgutil.extend_path()
)大致相同。
问题
“大多数包都像模块。它们的内容高度相互依赖,无法拆分。[但是,]一些包存在是为了提供一个单独的命名空间。……应该能够独立分发这些[命名空间包]的子包或子模块。”——Jim Fulton,在 Python 2.3 发布之前不久[1]
当新用户从其他语言转向 Python 时,他们经常会对 Python 的包导入语义感到困惑。例如,在 Google,Guido 收到了来自“一大群手持干草叉的人”[2]的抱怨,他们认为包必须包含__init__
模块的要求是一个“缺陷”,应该删除。
此外,来自 Java 或 Perl 等语言的用户有时会对 Python 的导入路径搜索中的差异感到困惑。
在大多数其他具有与 Python 的sys.path
类似路径机制的语言中,包仅仅是一个包含模块或类的命名空间,因此可以在语言的路径中的多个目录中分布。例如,在 Perl 中,Foo::Bar
模块将在模块包含路径上的所有Foo/
子目录中搜索,而不仅仅是在找到的第一个此类子目录中。
更糟糕的是,这不仅仅是新用户的问题:它阻止任何人轻松地将包拆分为独立安装的组件。用 Perl 的术语来说,这就像 CPAN 上每个可能的Net::
模块都必须捆绑在一个 tarball 中一起发布!
出于这个原因,存在各种解决此后一种限制的变通方法,这些方法在“命名空间包”这个术语下流传。Python 标准库自 Python 2.3 以来就提供了一种这样的变通方法(通过pkgutil.extend_path()
函数),而“setuptools”包提供了另一种变通方法(通过pkg_resources.declare_namespace()
)。
但是,这些变通方法本身却成为了 Python 将包在文件系统中布局的方式的第三个问题。
因为包必须包含一个__init__
模块,所以任何尝试分发该包的模块都必须包含该__init__
模块,如果这些模块要可导入的话。
但是,每个包的模块分发都必须包含此(重复的)__init__
模块这一事实意味着,打包这些模块分发的操作系统供应商必须以某种方式处理由多个模块分发将该__init__
模块安装到文件系统中同一位置引起的冲突。
这导致了PEP 382(“命名空间包”)的提出——一种向 Python 的导入机制发出信号的方法,表明一个目录是可导入的,每个模块分发使用唯一的文件名。
但是,这种方法不止一个缺点。所有导入操作的性能都会受到影响,并且指定包的过程变得更加复杂。必须发明新的术语来解释解决方案,等等。
随着 Import-SIG 上术语讨论的继续,很快变得很明显,解释与“命名空间包”相关的概念如此困难的主要原因是,与其他语言相比,Python 当前处理包的方式有点力不从心。
也就是说,在其他具有包系统的流行语言中,不需要特殊术语来描述“命名空间包”,因为所有包通常都以所需的方式运行。
在其他语言中,包通常不是具有特殊标记模块的孤立的单个目录(如在 Python 中),而是整个导入或包含路径中适当命名的目录的并集。
例如,在 Perl 中,模块Foo
始终位于Foo.pm
文件中,而模块Foo::Bar
始终位于Foo/Bar.pm
文件中。(换句话说,有一种显而易见的方法来查找特定模块的位置。)
这是因为 Perl 认为模块与包不同:包纯粹是其他模块可能驻留的命名空间,并且也仅仅碰巧是模块的名称。
但是,在当前版本的 Python 中,模块和包的绑定更加紧密。Foo
始终是一个模块——无论它是在Foo.py
中还是Foo/__init__.py
中找到的——并且它与它的子模块(如果有)紧密关联,这些子模块必须驻留在找到__init__.py
的完全相同的目录中。
从积极方面来看,这种设计选择意味着包是完全自包含的,可以通过对包的根目录执行操作将其作为一个单元进行安装、复制等。
但是,从消极方面来看,对于初学者来说,它不直观,并且需要更复杂的步骤才能将模块转换为包。如果Foo
最初以Foo.py
的形式出现,则必须将其移动并重命名为Foo/__init__.py
。
相反,如果您打算从一开始就创建一个Foo.Bar
模块,但没有要放在Foo
本身中的特定模块内容,那么您必须创建一个空且看似无关的Foo/__init__.py
文件,以便可以导入Foo.Bar
。
(这些问题不仅会让语言新手感到困惑,还会让许多经验丰富的开发人员感到恼火。)
因此,在 Import-SIG 上进行了一些讨论后,创建了本 PEP 作为 PEP 382 的替代方案,试图解决所有上述问题,而不仅仅是“命名空间包”的使用案例。
并且,作为一种令人愉快的副作用,本 PEP 中提出的解决方案不会影响普通模块或自包含(即基于__init__
)包的导入性能。
解决方案
过去,已经提出了各种建议来允许更直观的包目录布局方法。但是,其中大多数都失败了,因为存在明显的向后兼容性问题。
也就是说,如果简单地删除了对__init__
模块的要求,则会打开sys.path
上名为string
的目录阻止导入标准库string
模块的可能性。
然而,具有讽刺意味的是,这种方法的失败并非源于__init__
要求的消除!
相反,失败的原因在于底层方法认为包只是一件事,而不是两件事。
实际上,一个包包含两个独立但相关的实体:一个模块(及其自身可选的内容)和一个可以找到其他模块或包的命名空间。
但是,在当前版本的 Python 中,模块部分(在__init__
中找到)和子模块导入的命名空间(由__path__
属性表示)都在第一次导入包时同时初始化。
并且,如果您假设这是初始化这两件事的唯一方法,那么就没有办法删除对__init__
模块的需求,同时仍然与现有的目录布局向后兼容。
毕竟,一旦您在sys.path
上遇到与所需名称匹配的目录,就意味着您已经“找到”了该包,并且必须停止搜索,对吧?
嗯,不完全是。
一个思想实验
让我们短暂地乘坐时光机回到过去,假设我们回到了 1990 年代初期,在 Python 包和__init__.py
发明之前。但是,假设我们确实熟悉类似 Perl 的包导入,并且我们想在 Python 中实现类似的系统。
我们仍然可以使用 Python 的模块导入作为基础,因此我们当然可以设想将Foo.py
作为Foo
包的父Foo
模块。但是我们如何实现子模块和子包导入呢?
好吧,如果我们还没有 __path__
属性的概念,我们可能会直接搜索 sys.path
来查找 Foo/Bar.py
。
但我们**只有**在有人实际尝试导入 Foo.Bar
时才会这样做。
而不是在他们导入 Foo
时。
而**这**让我们能够摆脱 2011 年这里删除 __init__
要求带来的向后兼容性问题。
怎么做到的呢?
当我们 import Foo
时,我们甚至**不查找** sys.path
上的 Foo/
目录,因为我们**还不关心**。我们唯一关心的时候,是有人尝试实际导入 Foo
的子模块或子包的时候。
这意味着,如果 Foo
是一个标准库模块(例如),而我碰巧在 sys.path
上有一个 Foo
目录(当然,没有 __init__.py
),那么**没有任何问题**。 Foo
模块仍然只是一个模块,并且仍然可以正常导入。
自包含与“虚拟”包
当然,在今天的 Python 中,如果 Foo
只是一个 Foo.py
模块(因此缺少 __path__
属性),那么尝试 import Foo.Bar
会失败。
因此,这个 PEP 建议在缺少 __path__
的情况下**动态**创建它。
也就是说,如果我尝试 import Foo.Bar
,对导入机制的提议更改将注意到 Foo
模块缺少 __path__
,因此会在继续之前尝试**构建**它。
它会通过列出 sys.path
中列出的所有目录中现有的所有 Foo/
子目录来做到这一点。
如果列表为空,则导入将以 ImportError
失败,就像今天一样。但如果列表**不**为空,则将其保存在新的 Foo.__path__
属性中,使该模块成为“虚拟包”。
也就是说,因为它现在具有有效的 __path__
,所以我们可以以正常方式继续导入子模块或子包。
现在,请注意,此更改不会影响包含 __init__
模块的“经典”自包含包。此类包已经**拥有** __path__
属性(在导入时初始化),因此导入机制不会尝试稍后创建另一个属性。
这意味着(例如),标准库 email
包不会受到您在 sys.path
上拥有大量名为 email
的无关目录的任何影响。(即使它们包含 *.py
文件。)
但它**确实**意味着,如果您想将 Foo
模块转换为 Foo
包,您只需在 sys.path
上的某个位置添加一个 Foo/
目录,然后开始向其中添加模块即可。
但是,如果您只需要一个“命名空间包”怎么办?也就是说,一个**仅**作为各种单独分发的子模块和子包的命名空间的包?
例如,如果您是 Zope 公司,分发数十种单独的工具,例如 zc.buildout
,每个工具都在 zc
命名空间下的包中,您不希望必须在您发布的每个工具中创建和包含一个空的 zc.py
。(而且,如果您是 Linux 或其他操作系统供应商,您不希望处理尝试将十个 zc.py
的副本安装到同一位置而导致的包安装冲突!)
没问题。我们只需对导入过程进行一次较小的调整:如果“经典”导入过程未能找到自包含模块或包(例如,如果 import zc
找不到 zc.py
或 zc/__init__.py
),那么我们再次尝试通过搜索 sys.path
上的所有 zc/
目录并将其放入列表中来构建 __path__
。
如果此列表为空,则我们引发 ImportError
。但如果它不为空,我们创建一个空的 zc
模块,并将列表放入 zc.__path__
中。恭喜:zc
现在是一个仅命名空间的“纯虚拟”包!它没有模块内容,但您仍然可以从中导入子模块和子包,无论它们位于 sys.path
的哪个位置。
(顺便说一句,这两个添加到导入协议中的内容(即动态添加的 __path__
和动态创建的模块)递归地应用于子包,使用父包的 __path__
代替 sys.path
作为生成子 __path__
的基础。这意味着自包含和虚拟包可以相互包含,不受限制,但需要注意的是,如果您将虚拟包放入自包含包中,它的 __path__
将非常短!)
向后兼容性和性能
请注意,这两个更改**仅**影响今天会导致 ImportError
的导入操作。因此,不涉及虚拟包的导入性能不受影响,潜在的向后兼容性问题也受到很大限制。
今天,如果您尝试从没有 __path__
的模块导入子模块或子包,则会立即出错。当然,如果您在 sys.path
上的某个位置没有 zc.py
或 zc/__init__.py
,那么 import zc
同样会失败。
因此,唯一潜在的向后兼容性问题是
- 期望包目录具有
__init__
模块、期望没有__init__
模块的目录无法导入,或期望__path__
属性是静态的工具将无法识别虚拟包作为包。(实际上,这仅仅意味着工具需要更新以支持虚拟包,例如,使用
pkgutil.walk_modules()
而不是使用硬编码的文件系统搜索。) - 期望某些导入失败的代码现在可能会做一些意想不到的事情。在实践中,这应该相当罕见,因为大多数合理的非测试代码不会导入预期不存在的内容!
上述情况最有可能的例外情况是,当一段代码尝试通过导入某个包来检查该包是否已安装时。如果这**仅**通过导入顶级模块来完成(即,不检查 __version__
或某些其他属性),**并且**在 sys.path
上的某个位置存在与所需包同名的目录,**并且**该包实际上没有安装,那么此类代码可能会被误认为某个包已安装,而实际上并没有。
例如,假设有人编写了一个包含以下代码的脚本(datagen.py
)
try:
import json
except ImportError:
import simplejson as json
并在如下布局的目录中运行它
datagen.py
json/
foo.js
bar.js
如果 import json
由于 json/
子目录的存在而成功,则代码会错误地认为 json
模块可用,并继续以错误失败。
但是,我们可以通过对迄今为止提出的算法进行一项小的更改来防止此类极端情况的发生。与其允许您导入“纯虚拟”包(如 zc
),我们只允许导入虚拟包的内容。
也就是说,类似 import zc
的语句如果在 sys.path
上没有 zc.py
或 zc/__init__.py
,则应引发 ImportError
。但是,执行 import zc.buildout
仍然应该成功,只要在 sys.path
上存在 zc/buildout.py
或 zc/buildout/__init__.py
即可。
换句话说,我们不允许直接导入纯虚拟包,只允许导入模块和自包含包。(这是一个可以接受的限制,因为本身导入这样的包没有任何功能价值。毕竟,在您至少导入其一个子包或子模块之前,模块对象将没有任何内容!)
但是,一旦成功导入 zc.buildout
,sys.modules
中**将**存在一个 zc
模块,并且尝试导入它当然会成功。我们只是为了防止初始导入成功,以防止在 sys.path
上存在冲突的子目录时出现误报的导入成功。
因此,有了这个小小的改动,上面的 datagen.py
示例将正常工作。当它执行 import json
时,即使 json/
目录包含 .py
文件,其单纯的存在也不会影响导入过程。只有在尝试类似 import json.converter
的导入时,才会搜索 json/
目录。
同时,期望通过遍历目录树来定位包和模块的工具可以更新为使用现有的 pkgutil.walk_modules()
API,而需要在内存中检查包的工具应该使用下面“标准库更改/添加”部分中描述的其他 API。
规范
在导入包含至少一个 .
的名称时,对现有的导入过程进行了更改——即,导入具有父包的模块。
具体来说,如果父包不存在,或者存在但缺少 __path__
属性,则首先尝试为父包创建一个“虚拟路径”(遵循下面“虚拟路径”部分中描述的算法)。
如果计算出的“虚拟路径”为空,则会产生 ImportError
,就像今天一样。但是,如果获得了非空虚拟路径,则子模块或子包的正常导入将继续进行,使用该虚拟路径查找子模块或子包。(就像使用父包的 __path__
一样,如果父包存在并且具有 __path__
)。
当找到子模块或子包(但尚未加载)时,将创建父包并将其添加到 sys.modules
中(如果它之前不存在),并且其 __path__
将设置为计算出的虚拟路径(如果它之前未设置)。
这样,当发生子模块或子包的实际加载时,它将看到一个存在的父包,并且任何相对导入都将正常工作。但是,如果不存在子模块或子包,则不会创建父包,也不会将独立模块转换为包(通过添加虚假的 __path__
属性)。
顺便说一下,请注意,此更改必须递归应用:也就是说,如果 foo
和 foo.bar
是纯虚拟包,则 import foo.bar.baz
必须等到找到 foo.bar.baz
之后才能为两者 foo
和 foo.bar
创建模块对象,然后一起创建它们,并将 foo
模块的 .bar
属性正确设置为指向 foo.bar
模块。
这样,纯虚拟包永远不会直接可导入:单独的 import foo
或 import foo.bar
将失败,并且相应的模块不会出现在 sys.modules
中,直到它们需要指向成功导入的子模块或自包含子包。
虚拟路径
通过为 sys.path
(对于顶级模块)或父 __path__
(对于子模块)中找到的每个路径条目获取一个 PEP 302 “导入器”对象来创建虚拟路径。
(注意:因为 sys.meta_path
导入器不与 sys.path
或 __path__
条目字符串关联,所以此类导入器不参与此过程。)
检查每个导入器是否存在 get_subpath()
方法,如果存在,则使用正在为其构建路径的模块/包的全名调用该方法。返回值要么是表示请求包的子目录的字符串,要么是 None
(如果不存在这样的子目录)。
返回的字符串按找到的顺序添加到正在构建的路径列表中。(None
值和缺少 get_subpath()
方法将被简单跳过。)
然后,生成的列表(无论是否为空)都将存储在 sys.virtual_package_paths
字典中,以模块名称为键。
此字典有两个用途。首先,它用作缓存,以防多次尝试导入虚拟包的子模块。
其次,更重要的是,该字典可供在运行时扩展 sys.path
的代码使用,以相应地更新导入的包的 __path__
属性。(有关更多详细信息,请参见下面的“标准库更改/添加”)。
在 Python 代码中,虚拟路径构造算法看起来像这样
def get_virtual_path(modulename, parent_path=None):
if modulename in sys.virtual_package_paths:
return sys.virtual_package_paths[modulename]
if parent_path is None:
parent_path = sys.path
path = []
for entry in parent_path:
# Obtain a PEP 302 importer object - see pkgutil module
importer = pkgutil.get_importer(entry)
if hasattr(importer, 'get_subpath'):
subpath = importer.get_subpath(modulename)
if subpath is not None:
path.append(subpath)
sys.virtual_package_paths[modulename] = path
return path
并且像这样的函数应该在标准库中公开,例如 imp.get_virtual_path()
,以便创建 __import__
替换或 sys.meta_path
钩子的用户可以重用它。
标准库更改/添加
pkgutil
模块应更新为适当地处理此规范,包括对 extend_path()
、iter_modules()
等的任何必要更改。
具体来说,对 pkgutil
的建议更改和添加如下
- 一个新的
extend_virtual_paths(path_entry)
函数,用于扩展现有的、已导入的虚拟包的__path__
属性,以包括在新的sys.path
条目中找到的任何部分。此函数应由在运行时扩展sys.path
的应用程序调用,例如,当向路径添加插件目录或 egg 时。此函数的实现对
sys.virtual_package_paths
执行简单的自上而下的遍历,并执行任何必要的get_subpath()
调用,以识别需要添加到该包的虚拟路径中的路径条目,因为path_entry
已添加到sys.path
中。(或者,对于子包,添加基于其父包的虚拟路径的派生子路径条目。)(注意:此函数必须更新
sys.virtual_package_paths
中的路径值以及sys.modules
中任何相应模块的__path__
属性,即使在常见情况下它们都将是相同的list
对象。) - 一个新的
iter_virtual_packages(parent='')
函数,允许从sys.virtual_package_paths
自上而下遍历虚拟包,通过生成parent
的子虚拟包。例如,调用iter_virtual_packages("zope")
可能会生成zope.app
和zope.products
(如果它们是sys.virtual_package_paths
中列出的虚拟包),但不会生成zope.foo.bar
。(此函数需要实现extend_virtual_paths()
,但也可能对需要检查导入的虚拟包的其他代码有用。) ImpImporter.iter_modules()
应更改为检测并生成在虚拟包中找到的模块的名称。
除了上述更改之外,zipimport
导入器也应该类似地更改其 iter_modules()
实现。(注意:当前版本的 Python 通过 pkgutil
中的 shim 实现这一点,因此从技术上讲,这也是对 pkgutil
的更改。)
最后但并非最不重要的一点是,imp
模块(或 importlib
,如果合适)应公开上面“虚拟路径”部分中描述的算法,作为一个 get_virtual_path(modulename, parent_path=None)
函数,以便 __import__
替换的创建者可以使用它。
实现说明
对于虚拟包的用户、开发人员和分发者
- 虽然虚拟包易于设置和使用,但仍然有使用自包含包的时间和地点。虽然这不是绝对必要的,但向自包含包中添加
__init__
模块可以让包的用户(以及 Python 本身)知道包的所有代码都将在这个单个子目录中找到。此外,它允许您定义__all__
,公开公共 API,提供包级文档字符串,以及执行对自包含项目更有意义而不是对仅仅的“命名空间”包更有意义的其他操作。 sys.virtual_package_paths
允许包含不存在或尚未导入的包名称的条目;使用其内容的代码不应假设此字典中的每个键也存在于sys.modules
中,或者导入名称一定会成功。- 如果您正在将当前的自包含包更改为虚拟包,则需要注意,您不能再使用其
__file__
属性来定位存储在包目录中的数据文件。相反,您必须搜索__path__
或使用与所需文件相邻的子模块的__file__
,或包含所需文件的自包含子包的__file__
。(注意:对于现有的“命名空间包”用户来说,此警告已经适用。也就是说,能够对包进行分区会导致您必须知道所需数据文件位于哪个分区。我们在这里提到它只是为了让新的从自包含包转换为虚拟包的用户也意识到这一点。)
- XXX “纯虚拟”包的
__file__
是什么?None
?一些任意字符串?第一个目录的路径加上尾部分隔符?无论我们放什么,某些代码都会中断,但最后一个选择可能会使一些代码意外地工作。这是好还是坏?
对于那些实现 PEP 302 导入器对象的开发者
- 支持
iter_modules()
方法(由pkgutil
用于查找可导入的模块和包)的导入器,如果想要添加虚拟包支持,应该修改其iter_modules()
方法,使其能够发现并列出虚拟包以及标准模块和包。为此,导入器只需列出其管辖范围内所有有效的 Python 标识符作为直接子目录名称即可。XXX 这可能会列出许多并非真正包的目录。我们是否应该要求存在可导入的内容?如果是,我们应该搜索多深,以及如何防止例如循环链接或遍历到不同的文件系统等?太复杂了。此外,即使列出了虚拟包,它们仍然无法被导入,这对
pkgutil.walk_modules()
的当前实现方式来说是一个问题。 - “元”导入器(即,放置在
sys.meta_path
上的导入器)不需要实现get_subpath()
,因为此方法仅在对应于sys.path
条目和__path__
条目的导入器上调用。如果元导入器希望支持虚拟包,则必须完全在其自己的find_module()
实现中完成。不幸的是,任何这样的实现都不太可能将其包子路径与其他元导入器或
sys.path
导入器的子路径合并,因此对于元导入器来说,“支持虚拟包”的含义目前尚不明确!(但是,由于元导入器的预期用例是完全替换 Python 的正常导入过程以用于某些模块子集,并且当前实现的此类导入器的数量非常少,因此在实践中这似乎不太可能成为一个大问题。)
参考文献
版权
本文档已进入公有领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0402.rst