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 2.3起,Python标准库提供了一种此类变通方法(通过pkgutil.extend_path()函数),而“setuptools”包则提供了另一种(通过pkg_resources.declare_namespace())。
然而,这些变通方法本身也受到了Python文件系统上包布局方式的**第三个**问题的困扰。
由于包**必须**包含一个__init__模块,任何尝试分发包的模块都必须包含这个__init__模块,才能被导入。
然而,每个分发包模块都必须包含这个(重复的)__init__模块的事实,意味着打包这些模块分发的操作系统供应商必须以某种方式处理多个模块分发将__init__模块安装到同一文件系统位置所引起的冲突。
这导致了PEP 382(“命名空间包”)的提出——一种通过唯一的模块分发文件名来向Python导入机制发出目录可导入信号的方式。
然而,这种方法有不止一个缺点。所有导入操作的性能都会受到影响,而指定包的过程变得更加复杂。必须发明新的术语来解释解决方案,等等。
随着在Import-SIG上关于术语的讨论继续,很快就显而易见,解释“命名空间包”概念如此困难的主要原因是因为Python当前处理包的方式有些能力不足,与其他语言相比。
也就是说,在拥有包系统的其他流行语言中,不需要特殊术语来描述“命名空间包”,因为**所有**包通常都以期望的方式运行。
与其他语言不同,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上遇到一个名称匹配的目录,就意味着你“找到了”包,并且必须停止搜索,对吧?
嗯,不完全是。
思想实验
让我们暂时跳进时光机,假装我们回到了20世纪90年代初,就在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模块仍然只是一个模块,它仍然正常导入。
自包含 vs. “虚拟”包
当然,在今天的Python中,如果尝试import Foo.Bar,如果Foo只是一个Foo.py模块(因此缺少__path__属性),将会失败。
因此,此PEP提议在缺失__path__的情况下**动态创建**一个__path__。
也就是说,如果我尝试import Foo.Bar,对导入机制的拟议更改将注意到Foo模块缺少__path__,因此将尝试**构建**一个,然后再继续。
它将通过列出sys.path中目录的所有现有Foo/子目录来做到这一点。
如果列表为空,导入将像今天一样以ImportError失败。但如果列表**不**为空,它将被保存在新的Foo.__path__属性中,使该模块成为一个“虚拟包”。
也就是说,因为现在它有一个有效的__path__,我们可以按正常方式继续导入子模块或子包。
现在,请注意,此更改不会影响包含__init__模块的“经典”自包含包。这些包已经**拥有**一个__path__属性(在导入时初始化),因此导入机制不会稍后尝试创建另一个。
这意味着(例如)你拥有sys.path上大量不相关的名为email的目录,而标准库email包不会受到任何影响。(即使它们包含*.py文件。)
但它**确实**意味着,如果你想将你的Foo模块转换为Foo包,你所要做的就是在sys.path的某个地方添加一个Foo/目录,并开始向其中添加模块。
但如果你只想要一个“命名空间包”呢?也就是说,一个**仅**作为各种独立分发的子模块和子包的命名空间的包?
例如,如果你是Zope Corporation,分发了数十个独立工具,如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。但是,只要sys.path上存在zc/buildout.py或zc/buildout/__init__.py,那么import zc.buildout仍应成功。
换句话说,我们不允许直接导入纯虚拟包,只允许导入模块和自包含包。(这是一个可接受的限制,因为单独导入这样的包没有任何**功能**价值。毕竟,在导入至少一个子包或子模块之前,模块对象将没有任何**内容**!)
一旦zc.buildout成功导入,sys.modules中**将**存在一个zc模块,尝试导入它自然会成功。我们只是阻止**初始**导入成功,以防止在sys.path上存在冲突的子目录时出现错误的导入成功。
因此,通过这个微小的改动,上面datagen.py的示例将正确工作。当它执行import json时,即使json/目录包含.py文件,仅仅存在json/目录也不会影响导入过程。只有在尝试像import json.converter这样的导入时,json/目录才会被搜索。
同时,期望通过遍历目录树来定位包和模块的工具可以通过使用现有的pkgutil.walk_modules() API进行更新,而需要检查内存中包的工具应该使用下面“标准库更改/新增”部分描述的其他API。
规范
对现有导入过程进行了一项更改,该更改发生在导入包含至少一个.的名称时——也就是说,导入具有父包的模块。
具体来说,如果父包不存在,或者存在但缺少__path__属性,将首先尝试为父包创建“虚拟路径”(遵循下面“虚拟路径”部分描述的算法)。
如果计算出的“虚拟路径”为空,则会像今天一样引发ImportError。然而,如果获得了一个非空的虚拟路径,则正常导入子模块或子包会继续进行,并使用该虚拟路径来查找子模块或子包。(就像如果父包存在且具有__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“importer”对象来创建的。
(注意:由于sys.meta_path importers不与sys.path或__path__条目字符串关联,因此这些importers**不**参与此过程。)
检查每个importer是否具有get_subpath()方法,如果存在,则使用要为该包构建的完整模块/包名称调用该方法。返回值是要么表示请求包的子目录的字符串,要么是None(如果没有这样的子目录)。
importers返回的字符串按找到的顺序添加到正在构建的路径列表中。(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 importer的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 importer对象的开发者
- 支持
iter_modules()方法(pkgutil用来定位可导入模块和包)并且希望添加虚拟包支持的importer,应该修改其iter_modules()方法,使其能够发现和列出虚拟包以及标准模块和包。要做到这一点,importer应该简单地列出其管辖范围内的所有合法的Python标识符的直接子目录名称。XXX 这可能会列出很多并非真正的包。我们是否应该要求存在可导入的内容?如果是,我们搜索多深,以及如何防止例如链接循环,或遍历到不同的文件系统等?太麻烦了。另外,如果列出了虚拟包,它们仍然**不能**被导入,这对
pkgutil.walk_modules()当前的实现方式是个问题。 - “元”importer(即放在
sys.meta_path上的importer)不需要实现get_subpath(),因为该方法仅在对应于sys.path条目和__path__条目的importer上调用。如果一个元importer希望支持虚拟包,它必须完全在其自己的find_module()实现中做到这一点。不幸的是,不太可能任何这样的实现能够将其包子路径与来自其他元importer或
sys.pathimporter的包子路径合并,因此“支持虚拟包”对于元importer的含义目前是未定义的!(然而,鉴于元importer的预期用途是完全替换Python的正常导入过程(对于某些模块子集),并且目前实现的此类importer的数量非常少,这似乎不太可能成为一个实际问题。)
参考资料
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0402.rst