PEP 749 – 实现 PEP 649
- 作者:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 要求:
- 649
- 创建日期:
- 2024 年 5 月 28 日
- Python 版本:
- 3.14
- 发布历史:
- 2024 年 6 月 4 日
- 决议:
- 2025 年 5 月 5 日
摘要
本 PEP 通过提供对其规范的各种调整和补充来补充 PEP 649。
from __future__ import annotations(PEP 563) 将至少在 Python 3.13 达到生命周期结束之前保持其当前行为。随后,它将被弃用并最终移除。- 一个新的标准库模块
annotationlib被添加,以提供注解工具。它将包括get_annotations()函数、一个注解格式的枚举、一个ForwardRef类,以及一个用于调用__annotate__函数的辅助函数。 - REPL 中的注解像其他模块级注解一样,被延迟评估。
- 我们指定了提供注解的包装器对象的行为,例如
classmethod()和使用functools.wraps()的代码。 - 将不再有代码标志用于标记可以在“伪全局变量”环境中运行的
__annotate__函数。相反,我们添加了第四种格式VALUE_WITH_FAKE_GLOBALS,以允许第三方注解函数实现者指示他们支持的格式。 - 直接删除
__annotations__属性也将清除__annotate__。 - 我们添加了功能,允许使用类似 PEP 649 的语义评估类型别名值以及类型参数的界限和默认值(由 PEP 695 和 PEP 696 添加)。
- 将
SOURCE格式重命名为STRING以提高清晰度并减少用户混淆的风险。 - 条件定义的类和模块注解得到正确处理。
- 如果对部分执行的模块访问注解,则返回迄今为止执行的注解,但不缓存。
动机
PEP 649 为在 Python 中创建更好的注解语义提供了一个出色的框架。它解决了注解用户(包括使用静态类型提示和运行时类型提示的用户)的常见痛点,并使语言更加优雅和强大。该 PEP 最初于 2021 年为 Python 3.10 提出,并于 2023 年被接受。然而,实现花费的时间比预期长,现在该 PEP 预计将在 Python 3.14 中实现。
我已开始在 CPython 中实现该 PEP。我发现该 PEP 留下了一些未明确说明的领域,并且在一些边缘情况下的决定值得商榷。这个新的 PEP 提出了对规范的几项更改和补充,以解决这些问题。
本 PEP 补充而非取代 PEP 649。此处提出的更改应能改善整体用户体验,但它们不会改变早期 PEP 的总体框架。
from __future__ import annotations 的未来
PEP 563 此前引入了未来导入 from __future__ import annotations,它将所有注解转换为字符串。PEP 649 提出了一种不需要这种未来导入的替代方法,并声明:
如果本 PEP 被接受,PEP 563 将被弃用并最终移除。
然而,该 PEP 没有提供详细的弃用计划。
之前在 Discourse 上有一些关于此主题的讨论(请注意,在链接的帖子中,我提出了与此处提出的不同建议)。
规范
我们建议以下弃用计划:
- 在 Python 3.14 中,
from __future__ import annotations将继续像以前一样工作,将注解转换为字符串。- 如果未来导入处于活动状态,则带有注解的对象的
__annotate__函数在以VALUE格式调用时将以字符串形式返回注解,反映__annotations__的行为。
- 如果未来导入处于活动状态,则带有注解的对象的
- 在不支持 PEP 649 语义的最后一个版本(预计为 3.13)达到生命周期结束后的某个时间,
from __future__ import annotations将被弃用。编译任何使用未来导入的代码都将发出DeprecationWarning。这不会早于 Python 3.13 达到生命周期结束后的第一个版本,但社区可能会决定等待更长时间。 - 至少经过两个版本后,未来导入将被移除,注解将始终按照 PEP 649 进行评估。继续使用未来导入的代码将引发
SyntaxError,类似于任何其他未定义的未来导入。
被拒绝的替代方案
立即使未来导入成为空操作:我们曾考虑在 Python 3.14 中对所有代码应用 PEP 649 语义,使未来导入成为空操作。然而,这会破坏在 3.13 中在以下条件下工作的代码:
__future__ import annotations已激活- 存在依赖于前向引用的注解
- 注解在导入时被急切地评估,例如通过元类或类或函数装饰器。例如,这目前适用于已发布的
typing_extensions.TypedDict版本。
这预计是一个常见的模式,因此我们无法承受在从 3.13 升级到 3.14 期间破坏此类代码。
当未来导入最终被移除时,此类代码仍将中断。然而,那是在许多年以后,受影响的库有充足的时间更新其代码。
立即弃用未来导入:我们可以不等到 Python 3.13 达到生命周期结束,而是立即在使用未来导入时发出警告。然而,许多库已经将 from __future__ import annotations 作为一种优雅的方式来启用注解中无限制的前向引用。如果我们立即弃用未来导入,这些库将无法在所有受支持的 Python 版本上使用无限制的前向引用,同时避免弃用警告:与其他从标准库中弃用的功能不同,__future__ 导入必须是给定模块中的第一个语句,这意味着不可能只在 Python 3.13 及更低版本上条件性导入 __future__.annotations。(必要的 sys.version_info 检查将被视为在 __future__ 导入之前的语句。)
永远保留未来导入:我们也可以决定无限期保留未来导入。然而,这会永久性地分化 Python 语言的行为。这是不可取的;语言应该只有一套语义,而不是两种永久不同的模式。
未来使未来导入成为空操作:我们可以在 Python 3.13 生命周期结束后的某个时间点,不再让 from __future__ import annotations 引发 SyntaxError,而是使其不做任何事情。这仍然存在上述立即使其成为空操作的一些相同问题,尽管生态系统将有更长的时间来适应。最好是在用户确认他们不依赖字符串化注解后,明确地从他们的代码中移除未来导入。
新的 annotationlib 模块
PEP 649 建议将与注解相关的工具添加到 inspect 模块中。然而,该模块相当庞大,直接或间接依赖于至少 35 个其他标准库模块,并且导入速度很慢,以至于其他标准库模块通常不鼓励导入它。此外,我们预计除了 inspect.get_annotations() 函数和 VALUE、FORWARDREF 和 SOURCE 格式之外,还会添加更多工具。
一个新的标准库模块为这些功能提供了一个逻辑上的归宿,也使我们能够添加更多对注解使用者有用的工具。
基本原理
PEP 649 指出 typing.ForwardRef 应该用于在 inspect.get_annotations() 中实现 FORWARDREF 格式。然而,typing.ForwardRef 的现有实现与 typing 模块的其他部分交织在一起,将 typing 特定的行为添加到通用的 get_annotations() 函数中是没有意义的。此外,typing.ForwardRef 是一个有问题的类:它是公共且有文档的,但文档中没有列出它的任何属性或方法。尽管如此,第三方库仍使用其一些未文档化的属性。例如,Pydantic 和 Typeguard 使用 _evaluate 方法;beartype 和 pyanalyze 使用 __forward_arg__ 属性。
我们用一个新类 annotationlib.ForwardRef 替换了现有但规范不佳的 typing.ForwardRef。它的设计旨在与 typing.ForwardRef 类的现有用法基本兼容,但没有 typing 模块特有的行为。为了与现有用户兼容,我们保留了私有 _evaluate 方法,但将其标记为已弃用。它委托给 typing 模块中的一个新的公共函数 typing.evaluate_forward_ref,该函数旨在以类型提示特有的方式评估前向引用。
我们添加了一个函数 annotationlib.call_annotate_function 作为调用 __annotate__ 函数的辅助函数。这是实现需要在构建类时部分评估注解的功能时的一个有用构建块。例如,typing.NamedTuple 的实现需要在命名元组类本身构建之前从类命名空间字典中检索注解,因为注解决定了命名元组上存在哪些字段。
规范
一个新的模块 annotationlib 被添加到标准库中。其目标是提供用于内省和包装注解的工具。
该模块的设计借鉴了更新标准库(例如,dataclasses 和 typing.TypedDict)以使用 PEP 649 语义的经验。
该模块将包含以下功能:
get_annotations():一个返回函数、模块或类注解的函数。它将替换inspect.get_annotations()。后者将委托给新函数。它最终可能会被弃用,但为了尽量减少干扰,我们不提议立即弃用。get_annotate_from_class_namespace(namespace: Mapping[str, Any]):一个从类命名空间字典返回__annotate__函数的函数,如果不存在则返回None。这在类构造期间的元类中很有用。它是一个单独的函数,以避免暴露关于__annotate__函数内部存储的实现细节(参见下文)。Format:一个枚举,包含注解的可能格式。它将替换 PEP 649 中的VALUE、FORWARDREF和SOURCE格式。PEP 649 提议将这些值作为inspect模块的全局成员;我们更喜欢将它们放置在枚举中。我们提议添加第四种格式VALUE_WITH_FAKE_GLOBALS(见下文)。ForwardRef:一个表示前向引用的类;当格式为FORWARDREF时,它可能由get_annotations()返回。现有类typing.ForwardRef将成为此类的别名。其成员包括:__forward_arg__:前向引用的字符串参数evaluate(globals=None, locals=None, type_params=None, owner=None):一个尝试评估前向引用的方法。ForwardRef对象可能持有对其来源对象的全局变量和其他命名空间的引用。如果是这样,这些命名空间可用于评估前向引用。owner 参数可以是持有原始注解的对象,例如类或模块对象;它用于在未提供全局变量和局部变量命名空间时提取这些命名空间。_evaluate(),与现有ForwardRef._evaluate方法具有相同的接口。它将没有文档并立即弃用。它旨在与typing.ForwardRef的现有用户兼容。
call_annotate_function(func: Callable, format: Format):一个用于以给定格式调用__annotate__函数的辅助函数。如果函数不支持此格式,call_annotate_function()将设置一个“伪全局变量”环境,如 PEP 649 中所述,并使用该环境返回所需的注解格式。call_evaluate_function(func: Callable | None, format: Format):类似于call_annotate_function,但不依赖于函数返回注解字典。这旨在用于评估由 PEP 695 和 PEP 696 引入的延迟属性;详见下文。func 可以为None以方便;如果传入None,函数也返回None。annotations_to_string(annotations: dict[str, object]) -> dict[str, str]:一个将注解字典中的每个值转换为字符串表示的函数。这对于在原始源代码不可用(例如在typing.TypedDict的函数式语法中)的情况下实现SOURCE格式很有用。type_repr(value: object) -> str:一个将单个值转换为字符串表示的函数。它由annotations_to_string使用。它对大多数值使用repr(),但对于类型,它返回完全限定名。它也可用作typing和collections.abc模块中许多对象的repr()的辅助函数。
typing 模块中也添加了一个新函数 typing.evaluate_forward_ref。此函数是 ForwardRef.evaluate 方法的包装器,但它执行类型提示特有的额外工作。例如,它会递归进入复杂类型并评估这些类型中的额外前向引用。
与 PEP 649 相反,注解格式(VALUE、FORWARDREF 和 SOURCE)将不会作为 inspect 模块的全局成员添加。引用这些常量的唯一推荐方式将是 annotationlib.Format.VALUE。
被拒绝的替代方案
使用不同的名称:命名很困难,我考虑了几种想法:
annotations:最明显的名称,但它可能与现有的from __future__ import annotations造成混淆,因为用户可能在同一个模块中同时有import annotations和from __future__ import annotations。使用一个常用词作为名称将使模块更难搜索。PyPI 上有一个包 annotations,但它在 2015 年只发布了一次,看起来已被废弃。annotation(单数):类似,但不会与未来导入造成混淆。PyPI 上有一个废弃的包 annotation,但它似乎从未发布任何制品。annotools:类似于itertools和functools,但“anno”不如“iter”或“func”明显。截至本文撰写之时,PyPI 上没有同名包。annotationtools:一个更明确的版本。PyPI 上有一个包 annotationtools,它在 2023 年发布了一个版本。annotation_tools:上述的变体,但没有 PyPI 冲突。然而,没有其他公共标准库模块名称中包含下划线。annotationslib:类似于tomllib、pathlib和importlib。PyPI 上没有同名包。annotationlib:与上述类似,但字符少一个,主观上读起来更好。PyPI 上也没有被占用。
annotationlib 似乎是最好的选择。
将功能添加到 inspect 模块:如上所述,inspect 模块已经相当庞大,其导入时间对于某些用例来说是禁止的。
将功能添加到 typing 模块:虽然注解主要用于类型,但它们也可用于其他目的。我们倾向于将用于内省注解的功能与专门用于类型提示的功能清晰地分离。
将功能添加到 types 模块:types 模块旨在用于与 类型 相关的功能,而注解可以存在于函数和模块上,而不仅仅是类型上。
在第三方包中开发此功能:这个新模块中的功能将是纯 Python 代码,并且可以通过直接与解释器生成的 __annotate__ 函数交互来实现提供相同功能的第三方包。然而,所提出的新模块的功能肯定会在标准库本身中很有用(例如,用于实现 dataclasses 和 typing.NamedTuple),因此将其包含在标准库中是有意义的。
将此功能添加到私有模块:可以最初在一个私有标准库模块中(例如,_annotations)开发该模块,并在我们对 API 获得更多经验后才将其公开。然而,我们已经知道标准库本身将需要该模块的部分功能(例如,用于实现 dataclasses 和 typing.NamedTuple)。即使我们将其设为私有,该模块也必然会被第三方用户使用。最好从一开始就使用清晰、文档化的 API,以便第三方用户能够像标准库一样彻底支持 PEP 649 语义。该模块将立即在标准库的其他部分中使用,确保它涵盖了合理的使用场景。
REPL 的行为
PEP 649 指定了交互式 REPL 的以下行为:
为简单起见,在这种情况下,我们放弃延迟评估。REPL shell 中的模块级注解将继续完全按照它们在“原始语义”下的方式工作,立即评估并将结果直接设置在__annotations__字典中。
这种提议的行为存在几个问题。它使 REPL 成为唯一一个仍然立即评估注解的上下文,这让用户感到困惑并使语言复杂化。
它还使得 REPL 的实现更加复杂,因为它需要确保所有语句都以“交互式”模式编译,即使它们的输出不需要显示。(如果 REPL 评估的单行中有多个语句,这很重要。)
最重要的是,这会破坏一些经验不足的用户可能会遇到的合理用例。用户可能会在文件中写入以下内容:
a: X | None = None
class X: ...
在 PEP 649 下,这将正常工作:当 X 在 a 的注解中使用时,它尚未定义,但注解是惰性评估的。然而,如果用户将相同的代码粘贴到 REPL 中并逐行执行,它将抛出 NameError,因为名称 X 尚未定义。
此前已在 Discourse 上讨论过此主题。
规范
我们建议将交互式控制台视为任何其他模块级代码,并使注解延迟评估。这使得语言更具一致性,并避免了模块和 REPL 之间微妙的行为变化。
由于 REPL 是逐行评估的,我们将为全局范围中包含注解的每个评估语句生成一个新的 __annotate__ 函数。每当评估包含注解的行时,之前的 __annotate__ 函数就会丢失。
>>> x: int
>>> __annotate__(1)
{'x': <class 'int'>}
>>> y: str
>>> __annotate__(1)
{'y': <class 'str'>}
>>> z: doesntexist
>>> __annotate__(1)
Traceback (most recent call last):
File "<python-input-5>", line 1, in <module>
__annotate__(1)
~~~~~~~~~~~~^^^
File "<python-input-4>", line 1, in __annotate__
z: doesntexist
^^^^^^^^^^^
NameError: name 'doesntexist' is not defined
REPL 的全局命名空间中将没有 __annotations__ 键。在模块命名空间中,当访问模块对象的 __annotations__ 描述符时,此键会延迟创建,但在 REPL 中没有这样的模块对象。
在 REPL 中定义的类和函数也将像其他任何类一样工作,因此它们的注解评估将被推迟。可以访问 __annotations__ 和 __annotate__ 属性或使用 annotationlib 模块来内省注解。
提供 __annotations__ 的包装器
标准库中和其他地方的几个对象为其包装对象提供注解。PEP 649 没有指定这些包装器应该如何行为。
规范
提供注解的包装器应在设计时考虑到以下目标:
- 在可能的情况下,应尽可能延迟评估
__annotations__,与内置函数、类和模块的行为保持一致。 - 应保留与 PEP 649 实现之前的行为的向后兼容性。
__annotate__和__annotations__属性都应提供与包装对象语义一致的语义。
更具体地说:
functools.update_wrapper()(因此也包括functools.wraps())将只从被包装对象复制__annotate__属性到包装器。包装器函数上的__annotations__描述符将使用复制的__annotate__。classmethod()和staticmethod()的构造函数目前会将__annotations__属性从包装对象复制到包装器。它们将改为拥有__annotate__和__annotations__的可写属性。读取这些属性将从底层可调用对象中检索相应属性并将其缓存在包装器的__dict__中。写入这些属性将直接更新__dict__,而不影响被包装的可调用对象。
注解和元类
对本 PEP 初始实现的测试揭示了元类和类注解之间交互的严重问题。
现有错误
在调查本 PEP 中要指定的行为时,我们发现了类上 __annotations__ 现有行为的几个错误。在 Python 3.13 及更早版本上修复这些错误不在本 PEP 的范围之内,但在此处记录它们以解释需要处理的边缘情况。
就上下文而言,在 Python 3.10 到 3.13 中,如果类有任何注解,__annotations__ 字典会放置在类命名空间中。如果没有,在创建类时就没有 __annotations__ 类字典键,但访问 cls.__annotations__ 会调用在 type 上定义的描述符,该描述符返回一个空字典并将其存储在类字典中。静态类型 是一个例外:它们从没有注解,访问 .__annotations__ 会引发 AttributeError。在 Python 3.9 及更早版本中,行为有所不同;请参阅 gh-88067。
以下代码在 Python 3.10 到 3.13 上均会失败:
class Meta(type): pass
class X(metaclass=Meta):
a: str
class Y(X): pass
Meta.__annotations__ # important
assert Y.__annotations__ == {}, Y.__annotations__ # fails: {'a': <class 'str'>}
如果元类 Meta 上的注解在 Y 上的注解之前被访问,那么基类 X 的注解将泄露给 Y。然而,如果元类的注解 未 被访问(即,上述行 Meta.__annotations__ 被移除),那么 Y 的注解将正确地为空。
同样,来自带有注解的元类的注解会泄露给没有注解的类,这些类是元类的实例:
class Meta(type):
a: str
class X(metaclass=Meta):
pass
assert X.__annotations__ == {}, X.__annotations__ # fails: {'a': <class 'str'>}
这些行为的原因是,如果元类在其类字典中包含一个 __annotations__ 条目,这将阻止元类的实例使用基类 type 上的 __annotations__ 数据描述符。在第一种情况下,访问 Meta.__annotations__ 会附带地设置 Meta.__dict__["__annotations__"] = {}。然后,在 Y 上查找 __annotations__ 属性时,首先看到元类属性,但因为它是数据描述符而跳过它。接下来,它在其方法解析顺序 (MRO) 中的类的类字典中查找,找到 X.__annotations__,并返回它。在第二个示例中,MRO 中没有任何地方有注解,因此 type.__getattribute__ 会回退到返回元类属性。
PEP 649 下的元类行为
在 PEP 649 中,当涉及元类时,访问类上的 .__annotations__ 属性的行为变得更加不稳定,因为现在即使对于带有注解的类,__annotations__ 也只是延迟添加到类字典中。新的 __annotate__ 属性也会在没有注解的类上延迟创建,这在涉及元类时会导致进一步的错误行为。
这些问题的原因是我们仅在某些情况下设置 __annotate__ 和 __annotations__ 类字典条目,并依赖于在 type 上定义的描述符来填充它们(如果它们未设置)。当使用普通属性查找时,这种方法在存在元类的情况下会失效,因为元类自己的类字典中的条目会使描述符不可见。
我们考虑了几种解决方案,但最终选择了一种将 __annotate__ 和 __annotations__ 对象存储在类字典中,但使用不同的、仅内部使用的名称。这意味着类字典条目不会干扰在 type 上定义的描述符。
这种方法意味着类对象中的 .__annotate__ 和 .__annotations__ 对象将表现得大部分直观,但也有一些缺点。
其中一个涉及与在 from __future__ import annotations 下定义的类的交互。这些类将继续在类字典中包含 __annotations__ 条目,这意味着它们将继续显示一些错误行为。例如,如果一个元类在启用 __future__ 导入的情况下定义并具有注解,而一个使用该元类的类在没有 __future__ 导入的情况下定义,访问该类上的 .__annotations__ 将产生错误的结果。然而,这个错误在 Python 的早期版本中已经存在。它也可以通过在这种情况下将注解设置在类字典中的不同键上进行修复,但这会破坏直接访问类字典的用户(例如,在类构造期间)。我们更倾向于尽可能保持 __future__ 导入下的行为不变。
其次,在 Python 的早期版本中,可以在带有注解的用户定义类的实例上访问 __annotations__ 属性。然而,这种行为没有文档记录,也不受 inspect.get_annotations() 的支持,并且在 PEP 649 框架下,如果没有更大的更改,例如新的 object.__annotations__ 描述符,则无法保留此行为。这种行为更改应在移植指南中明确指出。
规范
类对象上的 .__annotate__ 和 .__annotations__ 属性应该可靠地返回注解函数和注解字典,即使在存在自定义元类的情况下也是如此。
用户不应直接访问类字典来访问注解或注解函数;类字典中存储的数据是实现细节,其格式将来可能会更改。如果只有类命名空间字典可用(例如,在类构造期间),可以使用 annotationlib.get_annotate_from_class_namespace 从类字典中检索注解函数。
被拒绝的替代方案
我们考虑了三种处理类中 __annotations__ 和 __annotate__ 条目行为的广泛方法:
- 确保该条目 始终 存在于类字典中,即使它为空或尚未评估。这意味着我们不必依赖在
type上定义的描述符来填充该字段,因此元类的属性不会干扰。(原型在 gh-120719 中。) - 警告用户不要直接使用
__annotations__和__annotate__属性。相反,用户应该调用annotationlib中的函数,这些函数直接调用type描述符。(在 gh-122074 中实现。) - 确保该条目 从不 出现在类字典中,或者至少从不被语言核心中的逻辑添加。这意味着
type上的描述符将始终被使用,而不会受到元类的干扰。(初始原型在 gh-120816 中;后来在 gh-132345 中实现。)
Alex Waygood 建议使用第一种方法实现。当创建堆类型(例如通过 class 语句创建的类)时,cls.__dict__["__annotations__"] 被设置为一个特殊的描述符。在 __get__ 上,描述符通过调用 __annotate__ 并返回结果来评估注解。注解字典缓存到描述符实例中。描述符也表现得像一个映射,因此使用 cls.__dict__["__annotations__"] 的代码通常仍然有效:将对象视为映射将评估注解,并表现得好像描述符本身就是注解字典。(但是,假设 cls.__dict__["__annotations__"] 特别是 dict 实例的代码可能会中断。)
对于 __annotate__,这种方法也很容易实现:对于带有注解的类,此属性已经始终设置,我们可以明确地将其设置为 None 对于没有注解的类。
虽然这种方法可以修复元类的已知边缘情况,但它给所有类带来了显著的复杂性,包括一种行为异常的新内置类型(用于注解描述符)。
第二种方法实现起来很简单,但缺点是直接访问 cls.__annotations__ 仍然容易出现不稳定的行为。
添加 VALUE_WITH_FAKE_GLOBALS 格式
PEP 649 规定:
本 PEP 假设第三方库可能会实现自己的__annotate__方法,而这些函数在这种“伪全局变量”环境中运行时几乎肯定会出错。因此,本 PEP 在代码对象上分配了一个标志,co_flags中未使用的一个位,表示“此代码对象可以在‘伪全局变量’环境中运行。”这使得“伪全局变量”环境严格地成为可选的,并且预计只有 Python 编译器生成的__annotate__方法才会设置它。
然而,这种机制将实现与代码对象的低级细节耦合在一起。代码对象标志是 CPython 特定的,文档 明确警告 不要依赖这些值。
Larry Hastings 提出了一种不依赖代码标志的替代方法:第四种格式,VALUE_WITH_FAKE_GLOBALS。编译器生成的注解函数将只支持 VALUE 和 VALUE_WITH_FAKE_GLOBALS 格式,两者实现相同。标准库将在特殊“伪全局变量”环境中调用注解函数时使用 VALUE_WITH_FAKE_GLOBALS 格式。
这种方法作为未来添加新注解格式的前向兼容机制很有用。手动编写注解函数的用户应在请求 VALUE_WITH_FAKE_GLOBALS 格式时引发 NotImplementedError,这样标准库就不会在“伪全局变量”环境下调用手动编写的注解函数,这可能会导致不可预测的结果。
注解格式的名称表明 __annotate__ 函数应该返回哪种类型的对象:对于 STRING 格式,它应该返回字符串;对于 FORWARDREF 格式,它应该返回前向引用;对于 VALUE 格式,它应该返回值。名称 VALUE_WITH_FAKE_GLOBALS 表示函数仍应返回值,但正在一个不寻常的“伪全局变量”环境中执行。
规范
在 annotationlib 模块的 Format 枚举中添加了一个额外的格式 VALUE_WITH_FAKE_GLOBALS,其值为 2。(因此,其他格式的值相对于 PEP 649 将发生变化:FORWARDREF 将为 3,SOURCE 将为 4。)这些格式的整数值指定用于在枚举不易获得的地方,例如在 C 语言实现的 __annotate__ 函数中。
编译器生成的注解函数将支持此格式并返回与 VALUE 格式相同的值。标准库将在“伪全局变量”环境中调用 __annotate__ 函数时传递此格式,如用于实现 FORWARDREF 和 SOURCE 格式。 annotationlib 模块中所有接受格式参数的公共函数,如果格式为 VALUE_WITH_FAKE_GLOBALS,则会引发 NotImplementedError。
实现 __annotate__ 函数的第三方代码,如果传入 VALUE_WITH_FAKE_GLOBALS 格式且函数未准备好在“伪全局变量”环境中运行,则应引发 NotImplementedError。这应在 __annotate__ 的数据模型文档中提及。
删除 __annotations__ 的影响
PEP 649 规定:
将o.__annotations__设置为合法值会自动将o.__annotate__设置为None。
然而,PEP 没有说明如果(使用 del)删除 __annotations__ 属性会发生什么。最一致的做法是删除该属性也会删除 __annotate__。
规范
删除函数、模块和类上的 __annotations__ 属性会导致将 __annotate__ 设置为 None。
PEP 695 和 696 对象的延迟评估
自 PEP 649 编写以来,Python 3.12 和 3.13 增加了对几个新功能的支持,这些功能也使用延迟评估,类似于本 PEP 为注解提出的行为:
- 通过
type语句创建的类型别名的值 (PEP 695) - 通过泛型语法创建的
typing.TypeVar对象的界限和约束 (PEP 695) -
typing.TypeVar、ParamSpec和typing.TypeVarTuple对象的默认值(PEP 696)
目前,这些对象使用延迟评估,但无法直接访问用于延迟评估的函数对象。为了实现与现在对注解进行自省相同的功能,我们建议暴露内部函数对象,允许用户使用 FORWARDREF 和 SOURCE 格式对其进行评估。
规范
我们将添加以下新属性:
- 在
typing.TypeAliasType上添加evaluate_value - 在
typing.TypeVar上添加evaluate_bound、evaluate_constraints和evaluate_default - 在
typing.ParamSpec上添加evaluate_default - 在
typing.TypeVarTuple上添加evaluate_default
除了 evaluate_value,如果对象没有边界、约束或默认值,这些属性可能为 None。否则,该属性是一个可调用对象,类似于 __annotate__ 函数,它接受一个整数参数并返回评估后的值。与 __annotate__ 函数不同,这些可调用对象返回单个值,而不是注解字典。这些属性是只读的。
通常,用户会将这些属性与 annotationlib.call_evaluate_function 结合使用。例如,要在 SOURCE 格式中获取 TypeVar 的边界,可以编写 annotationlib.call_evaluate_function(T.evaluate_bound, annotationlib.Format.SOURCE)。
数据类字段类型的行为
注解的延迟评估的一个结果是,数据类可以在其注解中使用前向引用。
>>> from dataclasses import dataclass
>>> @dataclass
... class D:
... x: undefined
...
然而,FORWARDREF 格式会泄露到数据类的字段类型中。
>>> fields(D)[0].type
ForwardRef('undefined')
我们考虑了一种更改,即字段对象的 .type 属性会触发注解的评估,这样在数据类本身创建之后但在访问字段类型之前定义的前向引用情况下,字段类型可以包含实际值。然而,这也意味着访问 .type 现在可能会在注解中运行任意代码,并可能抛出诸如 NameError 等错误。
因此,我们认为将 ForwardRef 对象保留在类型中更为用户友好,并说明想要解析前向引用的用户可以使用 ForwardRef.evaluate 方法。
如果将来出现用例,我们可以添加更多功能,例如一个从头开始重新评估注解的新方法。
将 SOURCE 重命名为 STRING
SOURCE 格式旨在供需要显示接近原始源代码的可读格式的工具使用。然而,我们无法在 __annotate__ 函数中检索原始源代码,并且在某些情况下,我们有 Python 代码中的 __annotate__ 函数无法访问原始代码。例如,这适用于 dataclasses.make_dataclass() 和 typing.TypedDict 的基于调用的语法。
这使得 SOURCE 这个名称有点名不副实。该格式的目标确实应该是重新创建源代码,但这个名称在实践中可能会误导用户。一个更中性的名称会强调该格式返回一个只包含字符串的注解字典。我们建议使用 STRING。
规范
SOURCE 格式已重命名为 STRING。重申本 PEP 中的更改,现在支持的四种格式是:
VALUE:默认格式,它评估注解并返回结果值。VALUE_WITH_FAKE_GLOBALS:供内部使用;应由支持使用伪全局变量执行的注解函数像VALUE一样处理。FORWARDREF:用ForwardRef对象替换未定义的名称。STRING:返回字符串,尝试重新创建接近原始源代码的代码。
条件定义的注解
PEP 649 不支持在类或模块主体中条件定义的注解。
目前,在if或try语句中设置带有注解的模块和类属性是可能的,并且它按预期工作。当本 PEP 激活时,支持这种行为是不可持续的。
然而,广泛使用的 SQLAlchemy 库的维护者 报告说,这种模式实际上很常见且很重要。
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from some_module import SpecialType
class MyClass:
somevalue: str
if TYPE_CHECKING:
someothervalue: SpecialType
在 PEP 649 设想的行为下,MyClass 的 __annotations__ 将包含 somevalue 和 someothervalue 的键。
幸运的是,有一种可行的实现策略可以使此代码再次按预期运行。此策略依赖于一些偶然的情况:
- 此行为更改仅与模块和类注解相关,因为本地作用域中的注解被忽略。
- 模块和类主体只执行一次。
- 在类主体执行完成之前,类的注解是不可外部见的。对于模块,这不完全正确,因为部分执行的模块可能对其他导入的模块可见,但这种情况由于其他原因而存在问题(请参阅下一节)。
这允许以下实现策略:
- 每个带注解的赋值都分配一个唯一标识符(例如,一个整数)。
- 在类或模块主体执行期间,会创建一个最初为空的集合,用于保存已定义的注解的标识符。
- 当执行带注解的赋值时,其标识符会添加到集合中。
- 生成的
__annotate__函数使用该集合来确定在类或模块主体中定义了哪些注解,并仅返回这些注解。
这已在 python/cpython#130935 中实现。
规范
对于类和模块,__annotate__ 函数将仅返回在类或模块主体执行时已执行的赋值的注解。
部分执行模块上注解的缓存
PEP 649 指定类和模块上的 __annotations__ 属性的值在首次访问时通过调用 __annotate__ 函数确定,然后缓存以供以后访问。这在大多数情况下是正确的,并保持兼容性,但存在一个可能导致意外行为的边缘情况:部分执行的模块。
考虑这个例子:
# recmod/__main__.py
from . import a
print("in __main__:", a.__annotations__)
# recmod/a.py
v1: int
from . import b
v2: int
# recmod/b.py
from . import a
print("in b:", a.__annotations__)
请注意,当 recmod/b.py 执行时,recmod.a 模块已定义,但尚未完成执行。
在 3.13 上,这会产生:
$ python3.13 -m recmod
in b: {'v1': <class 'int'>}
in __main__: {'v1': <class 'int'>, 'v2': <class 'int'>}
但是,如果 PEP 649 按最初提议实现,这将打印两次空字典,因为 __annotate__ 函数仅在模块执行完成时设置。这显然不直观。
有关实现,请参见 python/cpython#131550。
规范
访问部分执行模块上的 __annotations__ 将继续返回到目前为止已执行的注解,类似于 Python 早期版本的行为。但是,在这种情况下,__annotations__ 字典将不会被缓存,因此以后对 __annotations__ 属性的访问将返回一个新的字典。这是必要的,因为必须再次调用 __annotate__ 才能合并其他注解。
杂项实现细节
PEP 649 详细讨论了实现的一些方面。为了避免混淆,我们描述了当前实现与 PEP 中描述的实现不同的几个方面。但是,这些细节不保证在将来保持不变,并且将来可能会在不通知的情况下更改,除非它们在语言参考中进行了文档记录。
ForwardRef 对象支持的操作
SOURCE 格式通过“字符串化器”技术实现,其中函数的全局字典被增强,使得每次查找都会产生一个特殊对象,该对象可用于重建对对象执行的操作。
PEP 649 规定:
实际上,“字符串化器”功能将在目前定义在typing模块中的ForwardRef对象中实现。ForwardRef将被扩展以实现所有字符串化器功能;它还将扩展以支持评估其包含的字符串,以生成真实值(假设所有引用的符号都已定义)。
然而,这在实践中可能会导致混淆。一个实现字符串化器功能的对象必须实现几乎所有特殊方法,包括 __getattr__ 和 __eq__,以返回一个新的字符串化器。这样的对象难以使用:所有操作都成功,但它们很可能返回与用户预期不同的对象。
当前的实现相反只在 ForwardRef 类上实现了少数有用的方法。在注解评估期间,使用私有字符串化器类的实例而不是 ForwardRef。评估完成后,FORWARDREF 格式的实现将这些内部对象转换为 ForwardRef 对象。
__annotate__ 函数的签名
PEP 649 指定 __annotate__ 函数的签名为:
__annotate__(format: int) -> dict
然而,使用 format 作为参数名称可能会导致冲突,如果注解使用名为 format 的符号。为了避免这个问题,当前实现使用了一个仅位置参数,其在函数签名中名为 format,但不会遮蔽注解中使用名称 format 的情况。
向后兼容性
PEP 649 详细讨论了对使用标准或 PEP 563 语义的现有代码的向后兼容性影响。
然而,还有另一组兼容性问题:新编写的代码假定 PEP 649 语义,但使用了急切评估注解的现有工具。例如,考虑一个类似于 dataclass 的类装饰器 @annotator,它通过直接访问 __annotations__ 或调用 inspect.get_annotations() 来检索它所装饰类中的带注解字段。
一旦 PEP 649 实施,这样的代码将正常工作:
class X:
y: Y
class Y: pass
但这将无法工作,除非 @annotator 被更改为使用新的 FORWARDREF 格式。
@annotator
class X:
y: Y
class Y: pass
这并非严格意义上的向后兼容性问题,因为没有以前正常工作的代码会因此而损坏;在 PEP 649 之前,此代码将在运行时引发 NameError。从某种意义上说,它与任何其他需要第三方库支持的新 Python 功能没有什么不同。尽管如此,它对于执行自省的库来说是一个严重的问题,我们必须尽可能地让库以直接、用户友好的方式支持新语义。
标准库中的多项功能受此问题影响,包括 dataclasses、typing.TypedDict 和 typing.NamedTuple。这些都已更新,以支持使用新 annotationlib 模块中的功能。
安全隐患
PEP 649 的一个结果是,访问对象上的注解(即使对象是函数或模块)现在可能会执行任意代码。即使使用 STRING 格式也是如此,因为字符串化器机制只覆盖全局命名空间,这不足以完全沙盒 Python 代码。
在之前的 Python 版本中,访问函数或模块的注解无法执行任意代码,但类和其他对象在访问 __annotations__ 属性时已经可以执行任意代码。类似地,几乎任何对注解的进一步自省(例如,使用 isinstance(),调用 typing.get_origin 等函数,甚至使用 repr() 显示注解)都可能已经执行任意代码。当然,从不受信任的代码访问注解意味着不受信任的代码已经被导入。
如何教授此内容
PEP 649 的语义,经本 PEP 修改后,对于在代码中添加注解的用户来说,应该在很大程度上是直观的。我们消除了手动为需要前向引用的注解添加引号的需要,这是用户困惑的主要来源。
对于需要自省注解的高级用户来说,情况变得更加复杂。新 annotationlib 模块的文档将作为需要以编程方式与注解交互的用户的参考。
参考实现
本 PEP 中提出的更改已在 CPython 仓库的主分支上实现。
致谢
首先,我感谢 Larry Hastings 编写了 PEP 649。本 PEP 修改了他的一些初始决定,但总体设计仍然是他的。
我感谢 Carl Meyer 和 Alex Waygood 对本 PEP 早期草稿的反馈。Alex Waygood、Alyssa Coghlan 和 David Ellis 提供了关于元类与 __annotations__ 之间交互的深刻反馈和建议。Larry Hastings 也对本 PEP 提供了有益的反馈。Nikita Sobolev 对标准库进行了各种更改,利用了 PEP 649 功能,他的经验有助于改进设计。
附录
哪些表达式可以字符串化?
PEP 649 承认字符串化器无法处理所有表达式。现在我们有了草稿实现,我们可以更精确地说明可以和不能被字符串化器恢复的表达式。下面列出了 Python AST 中所有可以和不能被字符串化器恢复的表达式。完整的列表可能不应添加到文档中,但创建它是一个有用的练习。
首先,字符串化器当然无法恢复编译代码中不存在的任何信息,包括注释、空白、括号以及被 AST 优化器简化的操作。
其次,字符串化器几乎可以拦截所有涉及在某个作用域中查找名称的操作,但它不能拦截完全作用于常量的操作。作为推论,这也意味着在不受信任的代码上请求 SOURCE 格式是不安全的:Python 足够强大,即使无法访问任何全局变量或内置函数,也可能实现任意代码执行。例如:
>>> def f(x: (1).__class__.__base__.__subclasses__()[-1].__init__.__builtins__["print"]("Hello world")): pass
...
>>> annotationlib.get_annotations(f, format=annotationlib.Format.SOURCE)
Hello world
{'x': 'None'}
(这个特定的例子在我目前实现的本 PEP 草案中有效;确切的代码可能在将来无法继续工作。)
支持以下功能(有时有注意事项):
BinOp(二元运算)UnaryOp(一元运算)- 支持
Invert(~)、UAdd(+) 和USub(-) - 不支持
Not(not)
- 支持
Dict(除了使用**解包时)SetCompare- 支持
Eq和NotEq - 支持
Lt、LtE、Gt和GtE,但操作数可能会翻转 - 不支持
Is、IsNot、In和NotIn
- 支持
Call(除了使用**解包时)Constant(但不是常量的确切表示;例如,字符串中的转义序列会丢失;十六进制数会转换为十进制)Attribute(假设值为非常量)Subscript(假设值为非常量)Starred(*解包)名称ListTupleSlice
以下不受支持,但在字符串化器遇到时会抛出信息性错误:
FormattedValue(f-字符串;如果使用!r等转换说明符,则不会检测到错误)JoinedStr(f-字符串)
以下不受支持且导致不正确输出:
BoolOp(and和or)IfExpLambdaListCompSetCompDictCompGeneratorExp
以下在注解作用域中不被允许,因此不相关:
NamedExpr(:=)AwaitYieldYieldFrom
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0749.rst