PEP 395 – 模块的限定名称
- 作者:
- Alyssa Coghlan <ncoghlan at gmail.com>
- 状态:
- 已撤回
- 类型:
- 标准跟踪
- 创建日期:
- 2011年3月4日
- Python 版本:
- 3.4
- 发布历史:
- 2011年3月5日,2011年11月19日
PEP 撤回
此 PEP 已被作者于 2013 年 12 月撤回,因为自撰写以来,其他重大变更已使其某些方面过时。最值得注意的是 PEP 420 命名空间包使得一些与包检测相关的提议不可行,而 PEP 451 模块规范解决了多进程问题,并为解决 pickle 兼容性问题提供了可能的手段。
未来解决剩余问题的 PEP 仍然是合适的,但值得将任何此类努力作为全新的 PEP 来启动,在更新的上下文中重述剩余问题,而不是直接在此 PEP 的基础上进行。
摘要
本 PEP 提出了新的机制,消除了在处理 Python 导入系统以及函数和类的序列化和内省时,粗心者面临的一些长期存在的陷阱。
它基于 PEP 3155 中定义的“限定名称”概念。
与其他 PEP 的关系
最重要的是,此 PEP 目前被推迟,因为它需要进行重大更改才能与 PEP 420 中删除强制性 __init__.py 文件(已在 Python 3.3 中实现和发布)兼容。
本 PEP 建立在 PEP 3155 引入的“限定名称”概念之上,并且还与该 PEP 的目标一致,即修复在处理任意函数和类的序列化时的一些丑陋的边缘情况。
它还基于 PEP 366,该 PEP 迈出了初步的尝试性步骤,使主模块中的显式相对导入在至少 某些 情况下能够正常工作。
最后,PEP 328 消除了从导入模块中的隐式相对导入。本 PEP 建议也消除由当前 sys.path[0] 初始化行为提供的事实上的从主模块的隐式相对导入。
__name__ 中有什么?
随着时间的推移,模块的 __name__ 属性已被用于处理许多不同的任务。
为该模块属性确定的主要用例是:
- 使用
if __name__ == "__main__":约定标记程序中的主模块。 - 作为相对导入的起点。
- 用于识别运行应用程序中函数和类定义的位置。
- 用于识别要序列化为 pickle 对象的类的位置,这些 pickle 对象可能会与其他解释器实例共享。
对粗心者的陷阱
__name__ 语义的重载,以及 sys.path[0] 初始化中的一些历史相关行为,给粗心者带来了几个陷阱。这些陷阱在实践中可能非常令人烦恼,因为它们非常不明显(尤其是对初学者而言),并可能导致非常令人困惑的行为。
为什么我的导入会出错?
修改 sys.path 时有一个通用原则:永远不要 将包目录直接放在 sys.path 上。这之所以有问题,是因为该目录中的每个模块现在都可能通过两个不同的名称访问:作为顶级模块(因为包目录位于 sys.path 上)和作为包的子模块(如果包含包本身的更高级目录也位于 sys.path 上)。
例如,Django(包括 1.3 版本)在为特定站点应用程序设置此情况时犯了同样的错误——应用程序最终在模块命名空间中可以作为 app 和 site.app 访问,而这实际上是模块的两个 不同 副本。如果存在任何有意义的可变模块级别状态,这会造成混乱,因此此行为正在从 1.4 版本的默认站点设置中消除(特定站点应用程序将始终使用站点名称完全限定)。
然而,很难为此责备 Django,因为 Python 中负责在主模块中设置 __name__ = "__main__" 的相同部分,在确定 sys.path[0] 的值时犯了完全相同的错误。
如果你在 Stack Overflow 上关注“python”和“import”标签,你可能会相对频繁地看到这种影响。当我有时追踪它时,我经常遇到人们难以理解像下面这样简单包布局的行为(我实际上在自己的项目中使用了类似这样的包布局):
project/
setup.py
example/
__init__.py
foo.py
tests/
__init__.py
test_foo.py
虽然我通常会先看到没有 __init__.py 文件的,但这是一个微不足道的解释修复。难以解释的是,以下所有调用 test_foo.py 的方式 可能都无法工作,因为导入已损坏(要么无法找到 example 进行绝对导入,要么抱怨非包中的相对导入或超出顶层包的显式相对导入,或者如果某个其他子模块恰好遮蔽了顶级模块的名称,例如处理序列化的 example.json 模块或 example.tests.unittest 测试运行器,则会发出更晦涩的错误)。
# These commands will most likely *FAIL*, even if the code is correct
# working directory: project/example/tests
./test_foo.py
python test_foo.py
python -m package.tests.test_foo
python -c "from package.tests.test_foo import main; main()"
# working directory: project/package
tests/test_foo.py
python tests/test_foo.py
python -m package.tests.test_foo
python -c "from package.tests.test_foo import main; main()"
# working directory: project
example/tests/test_foo.py
python example/tests/test_foo.py
# working directory: project/..
project/example/tests/test_foo.py
python project/example/tests/test_foo.py
# The -m and -c approaches don't work from here either, but the failure
# to find 'package' correctly is easier to explain in this case
没错,那个长长的列表就是你尝试时几乎肯定会 失败 的所有调用方法,而且如果你不熟悉 Python 导入系统的工作方式以及它是如何初始化的,错误消息将毫无意义。
长期以来,对于这种设置,正确设置 sys.path 的唯一方法是:要么在 test_foo.py 本身中手动设置(这不是一个新手,甚至许多经验丰富的 Python 程序员都能知道如何做的事情),要么确保导入模块而不是直接执行它。
# working directory: project
python -c "from package.tests.test_foo import main; main()"
由于 PEP 366 的实现(它定义了一种机制,允许当包内的模块通过 -m 开关执行时,相对导入能够正常工作),以下方法也能正常工作:
# working directory: project
python -m package.tests.test_foo
从命令行调用 Python 代码的大多数方法在代码位于包内部时都会中断,而其中两种工作方法对当前工作目录高度敏感,这些都让初学者感到非常困惑。我个人认为,这是导致人们认为 Python 包复杂且难以正确使用的关键因素之一。
这个问题甚至不限于命令行——如果 test_foo.py 在 Idle 中打开,并且你尝试通过按 F5 运行它,或者你尝试通过在图形文件浏览器中单击它来运行它,那么它将以与直接从命令行运行时相同的方式失败。
存在“sys.path 上没有包目录”的通用准则是有原因的,而解释器本身在确定 sys.path[0] 时不遵循它,是导致各种麻烦的根本原因。
过去,由于向后兼容性问题,这无法修复。然而,受此问题影响的脚本在移植到 Python 3.x 时 已经 需要修复(由于正常导入模块时隐式相对导入的消除)。这提供了一个方便的机会,可以在 sys.path[0] 的初始化语义中实现相应的更改。
两次导入主模块
另一个由来已久的陷阱是重复导入 __main__ 的问题。当主模块也以其真实名称导入时,就会发生这种情况,从而有效地创建了同一模块的两个不同名称的实例。
如果存储在 __main__ 中的状态对程序的正确运行至关重要,或者如果主模块中有具有非幂等副作用的顶层代码,则这种重复可能会导致晦涩和令人惊讶的错误。
陷入困境
许多用户可能没有意识到,pickle 模块有时在序列化任意类的实例时依赖于 __module__ 属性。因此,在 __main__ 中定义的类的实例会以这种方式被 pickle,并且不会被另一个仅导入该模块而不是直接运行它的 Python 实例正确地 unpickle。这种行为是许多 Python 资深人士建议在任何涉及任何形式对象序列化和持久化的应用程序中,在 __main__ 模块中尽可能少做事情的根本原因。
同样,在创建伪模块(参见下一段)时,pickle 依赖于实际定义类的模块的名称,而不是该类在模块层次结构中正式记录的位置。
为了本 PEP 的目的,“伪模块”是一个像 Python 3.2 unittest 和 concurrent.futures 包那样设计的包。这些包被记录为单个模块,但实际上在内部实现为一个包。这 应该 是一个实现细节,用户和其他实现不需要担心,但是,由于 pickle(以及一般的序列化),这些细节经常被暴露出来,并可能有效地成为公共 API 的一部分。
虽然本 PEP 专门关注作为标准库中主要序列化方案的 pickle,但此问题也可能影响其他支持任意类实例序列化并依赖 __module__ 属性来确定如何处理反序列化的机制。
源代码在哪里?
一些熟练使用上述伪模块技术的用户认识到通过 pickle 模块泄露实现细节的问题,并选择通过在定义任何函数或类之前将 __name__ 修改为引用模块的公共位置来解决这个问题(或者通过在定义这些对象之后修改它们的 __module__ 属性)。
这种方法可以有效地消除通过 pickle 泄露信息,但代价是破坏了函数和类的自省(因为它们的 __module__ 属性现在指向错误的位置)。
无 Fork 的 Windows
为了绕过 Windows 上缺少 os.fork 的问题,multiprocessing 模块尝试重新执行 Python,使用相同的主模块,但跳过任何受 if __name__ == "__main__": 检查保护的代码。它尽力而为地利用现有信息,但被迫做出一些假设,这些假设在主模块不是普通的直接执行脚本或顶级模块时根本无效。通过 -m 开关执行的包和非顶级模块,以及直接执行的 zip 文件或目录,在生成新进程时,可能会导致 Windows 上的多进程做错事(无论是在静默还是嘈杂的情况下,取决于应用程序细节)。
虽然此问题目前仅直接影响 Windows,但它也影响了通过多进程模块在其他平台上提供 Windows 风格“干净进程”调用的任何提议。
模块的限定名称
为了使这些问题能够一劳永逸地解决,建议添加一个新的模块级属性:__qualname__。这个“限定名称”的缩写取自 PEP 3155,其中它用于存储嵌套类或函数定义相对于顶级模块的命名路径。
对于模块,__qualname__ 通常与 __name__ 相同,就像 PEP 3155 中的顶级函数和类一样。但是,在某些情况下它会有所不同,以便解决上述问题。
具体而言,每当 __name__ 因其他目的(例如指示主模块)而修改时,__qualname__ 将保持不变,从而允许需要它的代码访问原始未修改的值。
如果模块加载器没有自行初始化 __qualname__,则导入系统将自动添加它(将其设置为与 __name__ 相同的值)。
替代名称
还考虑了新属性的两个替代名称:“全名”(__fullname__) 和“实现名称”(__implname__)。
这些名称中的任何一个对于本 PEP 中的用例实际上都是有效的。然而,作为一个元问题,PEP 3155 也 添加了一个新属性(用于函数和类),该属性“类似于 __name__,但在某些 __name__ 缺少必要信息的情况下有所不同”,而这些术语对于 PEP 3155 的函数和类用例并不准确。
PEP 3155 故意省略了模块信息,因此“全名”一词根本不正确,“实现名称”暗示它可能指定除 __name__ 指定的对象之外的其他对象,而对于 PEP 3155 而言,情况并非如此(在该 PEP 中,__name__ 和 __qualname__ 始终指代相同的函数或类,只是 __name__ 不足以准确识别嵌套函数和类)。
鉴于为仅因向后兼容性问题而无法更改 __name__ 本身行为的属性添加 两个 新术语似乎没有必要地不一致,因此本 PEP 选择了采用 PEP 3155 术语。
如果“限定名称”和 __qualname__ 的相对晦涩性促使感兴趣的开发人员至少查找一次它们,而不是仅仅从名称就假设它们的意思并猜错,那不一定是坏事。
此外,99% 的 Python 开发者应该永远不需要关心这些额外的属性是否存在——它们实际上是一个实现细节,旨在让我们修复导入、pickling 和内省表现出的一些问题行为,而不是人们会经常处理的事情。
消除陷阱
以下更改是相互关联的,并且在一起考虑时最有意义。它们共同完全消除了上述粗心者的陷阱,或者提供了处理它们的直接机制。
此处提出的一些概念的粗略草稿最初发布在 python-ideas 列表中 ([1]),但自该帖子首次讨论以来,它们已经发生了很大变化。随后在 import-sig 邮件列表中进行了进一步讨论 ([2]. [3])。
修复包内主模块导入
为了消除这个陷阱,建议在确定 sys.path[0] 的合适值时执行额外的文件系统检查。此检查将查找 Python 的显式包目录标记,并使用它们来查找要添加到 sys.path 的适当目录。
在相关情况下,当前设置 sys.path[0] 的算法大致如下:
# Interactive prompt, -m switch, -c switch
sys.path.insert(0, '')
# Valid sys.path entry execution (i.e. directory and zip execution)
sys.path.insert(0, sys.argv[0])
# Direct script execution
sys.path.insert(0, os.path.dirname(sys.argv[0]))
建议修改此初始化过程以考虑文件系统上存储的包详细信息:
# Interactive prompt, -m switch, -c switch
in_package, path_entry, _ignored = split_path_module(os.getcwd(), '')
if in_package:
sys.path.insert(0, path_entry)
else:
sys.path.insert(0, '')
# Start interactive prompt or run -c command as usual
# __main__.__qualname__ is set to "__main__"
# The -m switches uses the same sys.path[0] calculation, but:
# modname is the argument to the -m switch
# modname is passed to ``runpy._run_module_as_main()`` as usual
# __main__.__qualname__ is set to modname
# Valid sys.path entry execution (i.e. directory and zip execution)
modname = "__main__"
path_entry, modname = split_path_module(sys.argv[0], modname)
sys.path.insert(0, path_entry)
# modname (possibly adjusted) is passed to ``runpy._run_module_as_main()``
# __main__.__qualname__ is set to modname
# Direct script execution
in_package, path_entry, modname = split_path_module(sys.argv[0])
sys.path.insert(0, path_entry)
if in_package:
# Pass modname to ``runpy._run_module_as_main()``
else:
# Run script directly
# __main__.__qualname__ is set to modname
上述伪代码中使用的 split_path_module() 辅助函数将具有以下语义:
def _splitmodname(fspath):
path_entry, fname = os.path.split(fspath)
modname = os.path.splitext(fname)[0]
return path_entry, modname
def _is_package_dir(fspath):
return any(os.exists("__init__" + info[0]) for info
in imp.get_suffixes())
def split_path_module(fspath, modname=None):
"""Given a filesystem path and a relative module name, determine an
appropriate sys.path entry and a fully qualified module name.
Returns a 3-tuple of (package_depth, fspath, modname). A reported
package depth of 0 indicates that this would be a top level import.
If no relative module name is given, it is derived from the final
component in the supplied path with the extension stripped.
"""
if modname is None:
fspath, modname = _splitmodname(fspath)
package_depth = 0
while _is_package_dir(fspath):
fspath, pkg = _splitmodname(fspath)
modname = pkg + '.' + modname
return package_depth, fspath, modname
本 PEP 还提议通过 runpy 模块将 split_path_module() 功能直接暴露给 Python 用户。
通过此修复,并且使用前面描述的相同简单包布局,以下 所有 命令都将正确调用测试套件:
# working directory: project/example/tests
./test_foo.py
python test_foo.py
python -m package.tests.test_foo
python -c "from .test_foo import main; main()"
python -c "from ..tests.test_foo import main; main()"
python -c "from package.tests.test_foo import main; main()"
# working directory: project/package
tests/test_foo.py
python tests/test_foo.py
python -m package.tests.test_foo
python -c "from .tests.test_foo import main; main()"
python -c "from package.tests.test_foo import main; main()"
# working directory: project
example/tests/test_foo.py
python example/tests/test_foo.py
python -m package.tests.test_foo
python -c "from package.tests.test_foo import main; main()"
# working directory: project/..
project/example/tests/test_foo.py
python project/example/tests/test_foo.py
# The -m and -c approaches still don't work from here, but the failure
# to find 'package' correctly is pretty easy to explain in this case
通过这些更改,在图形文件浏览器中单击 Python 模块应始终正确执行它们,即使它们位于包中。根据它如何调用脚本的详细信息,Idle 也可能能够通过 F5 正确运行 test_foo.py,而无需任何 Idle 特定的修复。
可选补充:命令行相对导入
在上述更改到位后,允许显式相对导入作为 -m 开关的参数将是一个相当小的补充。
# working directory: project/example/tests
python -m .test_foo
python -m ..tests.test_foo
# working directory: project/example/
python -m .tests.test_foo
通过此添加,-m 开关的系统初始化将如下变化:
# -m switch (permitting explicit relative imports)
in_package, path_entry, pkg_name = split_path_module(os.getcwd(), '')
qualname= <<arguments to -m switch>>
if qualname.startswith('.'):
modname = qualname
while modname.startswith('.'):
modname = modname[1:]
pkg_name, sep, _ignored = pkg_name.rpartition('.')
if not sep:
raise ImportError("Attempted relative import beyond top level package")
qualname = pkg_name + '.' modname
if in_package:
sys.path.insert(0, path_entry)
else:
sys.path.insert(0, '')
# qualname is passed to ``runpy._run_module_as_main()``
# _main__.__qualname__ is set to qualname
与 PEP 382 的兼容性
使此提议与 PEP 382 命名空间包 PEP 兼容是微不足道的。_is_package_dir() 的语义仅需更改为:
def _is_package_dir(fspath):
return (fspath.endswith(".pyp") or
any(os.exists("__init__" + info[0]) for info
in imp.get_suffixes()))
与 PEP 402 的不兼容性
PEP 402 提议取消 Python 包在文件系统中的显式标记。这从根本上打破了能够将文件系统路径和 Python 模块名称进行明确映射到 Python 模块命名空间的概念。相反,适当的映射将取决于 sys.path 中的当前值,使得在解释器初始化时,永远无法修复上述关于 sys.path[0] 计算的问题。
虽然如果 PEP 402 被采纳,本 PEP 的某些方面可能可以挽救,但使主模块和其他模块的导入语义更加一致的核心概念将不再可行。
与存储在包中的脚本的潜在不兼容性
对 sys.path[0] 初始化的建议更改 可能 会破坏一些现有代码。具体来说,它将破坏存储在包目录中并依赖于 __main__ 中的隐式相对导入才能在 Python 3 下正确运行的脚本。
虽然此类脚本可以在 Python 2 中导入(由于隐式相对导入),但现在的情况是它们不能在 Python 3 中导入,因为模块导入时不再允许隐式相对导入。
通过同样不允许从主模块进行隐式相对导入,此类模块甚至不会与本 PEP 作为脚本一起工作。将它们切换到显式相对导入将使它们重新作为可执行脚本 和 可导入模块工作。
为了支持早期版本的 Python,脚本可以编写为根据 Python 版本使用不同形式的导入:
if __name__ == "__main__" and sys.version_info < (3, 3):
import peer # Implicit relative import
else:
from . import peer # explicit relative import
修复主模块的双重导入
鉴于上述关于在主模块中始终正确设置 __qualname__ 的提议,建议进行一个简单的更改以消除主模块重复导入的问题:添加一个 sys.metapath 钩子,它检测尝试以真实名称导入 __main__ 的操作,并返回原始主模块。
class AliasImporter:
def __init__(self, module, alias):
self.module = module
self.alias = alias
def __repr__(self):
fmt = "{0.__class__.__name__}({0.module.__name__}, {0.alias})"
return fmt.format(self)
def find_module(self, fullname, path=None):
if path is None and fullname == self.alias:
return self
return None
def load_module(self, fullname):
if fullname != self.alias:
raise ImportError("{!r} cannot load {!r}".format(self, fullname))
return self.main_module
这个元路径钩子将根据以下逻辑在导入系统初始化期间自动添加:
main = sys.modules["__main__"]
if main.__name__ != main.__qualname__:
sys.metapath.append(AliasImporter(main, main.__qualname__))
这可能是 PEP 中最不重要的提案——它只是在解决了解释器启动时 sys.path[0] 配置问题后,关闭了可能导致模块重复的最后一个机制。
修复 pickle 而不破坏内省
为了解决这个问题,建议利用新的模块级 __qualname__ 属性来确定当 __name__ 因任何原因被修改时模块的真实位置。
在主模块中,__qualname__ 将由解释器自动设置为主模块的“真实”名称(如上所述)。
调整 __name__ 指向公共命名空间的伪模块将保持 __qualname__ 不变,因此实现位置仍然易于内省。
如果模块顶部调整了 __name__,那么这将自动调整该模块中随后定义的所有函数和类的 __module__ 属性。
由于多个子模块可能被设置为使用相同的“公共”命名空间,因此函数和类将获得一个新的 __qualmodule__ 属性,该属性指向其模块的 __qualname__。
这对于函数来说并非严格必要(你可以通过查看它们的全局字典来找出它们模块的限定名称),但对于类来说却是必需的,因为它们不持有对其定义模块全局变量的引用。一旦给类添加了一个新属性,为了保持 API 的一致性,也给函数添加一个新属性会更方便。
这些更改意味着调整 __name__(以及直接或间接的相应函数和类 __module__ 属性)成为以包的形式实现命名空间的官方认可方式,同时将 API 暴露为仿佛它仍然是一个模块。
所有当前使用 __name__ 和 __module__ 属性的序列化代码将默认避免暴露实现细节。
为了正确处理主模块中项目的序列化,将更新类和函数定义逻辑,以便在 __name__ == "__main__" 的情况下,也为 __module__ 属性使用 __qualname__。
由于 __name__ 和 __module__ 被正式认可用于事物的 公共 名称,标准库中的自省工具将更新以在适当的地方使用 __qualname__ 和 __qualmodule__。例如:
pydoc将报告模块的公共名称和限定名称。inspect.getsource()(及类似工具) 将使用指向代码实现的限定名称。- 可以提供额外的
pydoc和/或inspectAPI,报告所有具有给定公共__name__的模块。
修复 Windows 上的多进程
现在 __qualname__ 可用于告知 multiprocessing 主模块的真实名称,它将能够简单地将其包含在传递给子进程的序列化信息中,从而消除了当前对 __file__ 属性进行可疑内省的需要。
对于旧版 Python,multiprocessing 可以通过在尝试根据其 __file__ 属性执行主模块时应用上述 split_path_module() 算法来改进。
显式相对导入
本 PEP 提议在主模块中无条件地将 __package__ 定义为 __qualname__.rpartition('.')[0]。除此之外,它提议保留显式相对导入的行为。
特别是,如果在发生显式相对导入时模块中未设置 __package__,则自动缓存的值将继续从 __name__ 而不是 __qualname__ 派生。这最大限度地减少了与现有代码的向后不兼容性,这些代码通过调整 __name__ 而不是直接设置 __package__ 来有意操纵相对导入。
本 PEP 不 提议弃用 __package__。尽管在引入 __qualname__ 之后它在技术上是多余的,但在 Python 3.x 的生命周期内弃用它并不值得。
参考实现
暂无。
参考资料
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0395.rst