Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

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 提出了一种新的机制,该机制消除了在处理 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__ 属性已被用于处理许多不同的任务。

为此模块属性确定的关键用例是

  1. 使用 if __name__ == "__main__": 约定在程序中标记主模块。
  2. 作为相对导入的起点
  3. 识别正在运行的应用程序中函数和类定义的位置
  4. 识别类的位置,以便将其序列化为可能与其他解释器实例共享的 pickle 对象

新手陷阱

__name__ 语义的重载以及 sys.path[0] 初始化中的一些历史相关的行为导致了一些新手陷阱。这些陷阱在实践中可能非常令人烦恼,因为它们非常不明显(尤其对初学者而言)并且可能导致非常令人困惑的行为。

为什么我的导入失败了?

修改 sys.path 时适用的一般原则是:永远不要将包目录直接放在 sys.path 上。之所以会出现问题,是因为该目录中的每个模块现在都可能在两个不同的名称下访问:作为顶级模块(因为包目录位于 sys.path 上)和作为包的子模块(如果包含包本身的高一级目录也位于 sys.path 上)。

例如,Django(直至版本 1.3)对特定于站点的应用程序设置了完全相同的这种情况——应用程序最终可以作为模块命名空间中的 appsite.app 访问,并且它们实际上是模块的两个不同副本。如果存在任何有意义的可变模块级状态,这将导致混乱,因此此行为正在从版本 1.4 中的默认站点设置中消除(特定于站点的应用程序将始终使用站点名称完全限定)。

但是,当在确定 sys.path[0] 的值时,负责在主模块中设置 __name__ = "__main__" 的 Python 的同一部分也犯了完全相同的错误。

如果您关注 Stack Overflow 上的“python”和“import”标签,就可以看到它的影响相对频繁。当我还有时间关注它时,我经常遇到人们难以理解以下简单的包布局的行为(我实际上在我的项目中使用类似这样的包布局)

project/
    setup.py
    example/
        __init__.py
        foo.py
        tests/
            __init__.py
            test_foo.py

虽然我经常会在没有 __init__.py 文件的情况下看到它,但这很容易解释。难以解释的是,由于导入错误(要么无法找到绝对导入的 example,要么抱怨非包中的相对导入或超出顶级包的显式相对导入,或者如果某个其他子模块碰巧覆盖了顶级模块的名称,例如处理序列化的 example.json 模块或 example.tests.unittest 测试运行器,则会发出更模糊的错误),以下所有调用 test_foo.py 的方法可能都无法工作

# 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 实例正确地反序列化。此行为是许多 Python 资深人士建议在任何涉及任何形式的对象序列化和持久性的应用程序中,在 __main__ 模块中执行的操作尽可能少的根本原因。

类似地,在创建伪模块(参见下一段)时,pickle 会依赖于实际定义类的模块名称,而不是该类在模块层次结构中正式记录的位置。

出于本 PEP 的目的,“伪模块”是指像 Python 3.2 的 unittestconcurrent.futures 包一样的包。这些包在文档中被描述为单一模块,但实际上在内部实现为一个包。这**应该**是一个实现细节,用户和其他实现不需要担心,但是,由于 pickle(以及一般的序列化),这些细节通常会被暴露出来,并有效地成为公共 API 的一部分。

虽然本 PEP 特别关注 pickle 作为标准库中的主要序列化方案,但此问题也可能影响其他支持任意类实例序列化并依赖于 __module__ 属性来确定如何处理反序列化的机制。

源代码在哪里?

一些上面描述的伪模块技术的复杂用户认识到通过 pickle 模块泄漏实现细节的问题,并选择通过修改 __name__ 来引用模块的公共位置(在定义任何函数或类之前),或者通过修改这些对象的 __module__ 属性(在它们定义之后)来解决此问题。

这种方法可以有效地消除通过 pickling 泄漏信息的问题,但代价是破坏了函数和类的自省(因为它们的 __module__ 属性现在指向错误的位置)。

无分叉的 Windows

为了解决 Windows 上缺少 os.fork 的问题,multiprocessing 模块尝试使用相同的 main 模块重新执行 Python,但跳过任何由 if __name__ == "__main__": 检查保护的代码。它尽其所能利用它所拥有的信息,但它被迫做出一些假设,这些假设在主模块不是普通的直接执行脚本或顶级模块时根本无效。通过 -m 开关执行的包和非顶级模块,以及直接执行的 zip 文件或目录,在生成新进程时很可能会导致 Windows 上的多处理做错事(根据应用程序的细节,要么静默地,要么发出噪音)。

虽然此问题目前仅直接影响 Windows,但它也影响了通过 multiprocessing 模块在其他平台上提供 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 的某些方面可能能够挽救,但使来自 main 和其他模块的导入语义更加一致的核心概念将不再可行。

此不兼容性在相关的 import-sig 线程中进行了更详细的讨论 ([2][3])。

与存储在包中的脚本的潜在不兼容性

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 和/或 inspect API 来报告具有给定公共 __name__ 的所有模块。

修复 Windows 上的多进程

现在可以使用 __qualname__ 来告诉 multiprocessing 主模块的真实名称,它将能够简单地将其包含在传递给子进程的序列化信息中,从而无需当前对 __file__ 属性进行可疑的内省。

对于旧版本的 Python,可以通过在尝试根据其 __file__ 属性确定如何执行主模块时应用上面描述的 split_path_module() 算法来改进 multiprocessing

显式相对导入

本 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

上次修改时间:2023-10-11 12:05:51 GMT