解析注解
注意
本节是内部机制文档的一部分,部分面向贡献者。
Pydantic 在运行时严重依赖类型提示来构建用于验证、序列化等的模式。
虽然类型提示最初是为静态类型检查器(如 Mypy 或 Pyright)引入的,但它们在运行时是可访问的(有时会被求值)。这意味着以下代码在运行时会失败,因为 Node
尚未在当前模块中定义
class Node:
"""Binary tree node."""
# NameError: name 'Node' is not defined:
def __init__(self, l: Node, r: Node) -> None:
self.left = l
self.right = r
为了规避这个问题,可以使用前向引用(通过将注解包裹在引号中)。
在 Python 3.7 中,PEP 563 引入了延迟评估注解的概念,这意味着使用 from __future__ import annotations
future 语句,类型提示默认会被字符串化
from __future__ import annotations
from pydantic import BaseModel
class Foo(BaseModel):
f: MyType
# Given the future import above, this is equivalent to:
# f: 'MyType'
type MyType = int
print(Foo.__annotations__)
#> {'f': 'MyType'}
运行时求值的挑战¶
静态类型检查器利用 AST 来分析定义的注解。关于前面的示例,这样做的好处是能够理解 MyType
在分析 Foo
的类定义时指的是什么,即使 MyType
在运行时尚未定义。
然而,对于像 Pydantic 这样的运行时工具,正确解析这些前向注解更具挑战性。Python 标准库提供了一些工具来做到这一点(typing.get_type_hints()
, inspect.get_annotations()
),但它们存在一些局限性。因此,Pydantic 正在重新实现它们,以改进对边缘案例的支持。
随着 Pydantic 的发展,它已经适应了许多需要不规则注解评估模式的边缘案例。其中一些用例从静态类型检查的角度来看不一定合理。在 v2.10 中,内部逻辑被重构,试图简化和标准化注解评估。诚然,向后兼容性带来了一些挑战,并且由于这个原因,代码库中仍然存在一些明显的疤痕。人们希望 PEP 649(在 Python 3.14 中引入)将大大简化这个过程,尤其是在处理函数的局部变量时。
为了评估前向引用,Pydantic 大致遵循与 typing.get_type_hints()
函数文档中描述的相同的逻辑。也就是说,内置的 eval()
函数通过传递前向引用、全局命名空间和局部命名空间来使用。命名空间获取逻辑在以下章节中定义。
在类定义时解析注解¶
以下示例将在本节中用作参考
# module1.py:
type MyType = int
class Base:
f1: 'MyType'
# module2.py:
from pydantic import BaseModel
from module1 import Base
type MyType = str
def inner() -> None:
type InnerType = bool
class Model(BaseModel, Base):
type LocalType = bytes
f2: 'MyType'
f3: 'InnerType'
f4: 'LocalType'
f5: 'UnknownType'
type InnerType2 = complex
当构建 Model
类时,会涉及到不同的命名空间。对于 Model
的 MRO 的每个基类(以相反的顺序 — 即从 Base
开始),应用以下逻辑
- 从当前基类的
__dict__
中获取__annotations__
键(如果存在)。对于Base
,这将是{'f1': 'MyType'}
。 - 迭代
__annotations__
项,并尝试使用内置eval()
函数的自定义包装器来评估注解 1。此函数接受两个globals
和locals
参数- 当前模块的
__dict__
自然用作globals
。对于Base
,这将是sys.modules['module1'].__dict__
。 - 对于
locals
参数,Pydantic 将尝试在以下命名空间中解析符号,按优先级从高到低排序- 动态创建的命名空间,包含当前类名(
{cls.__name__: cls}
)。这样做是为了支持递归引用。 - 当前类的局部变量(即
cls.__dict__
)。对于Model
,这将包括LocalType
。 - 类的父命名空间,如果与上面描述的全局命名空间不同。这是定义类的帧的 locals。对于
Base
,由于该类直接在模块中定义,因此不会使用此命名空间,因为它将导致再次使用全局命名空间。对于Model
,父命名空间是inner()
帧的局部变量。
- 动态创建的命名空间,包含当前类名(
- 当前模块的
- 如果注解未能评估,则保持原样,以便可以在稍后阶段重建模型。
f5
就是这种情况。
下表列出了创建 Model
类后每个字段的已解析类型注解
字段名称 | 已解析注解 |
---|---|
f1 |
int |
f2 |
str |
f3 |
bool |
f4 |
bytes |
f5 |
'UnkownType' |
局限性和向后兼容性考虑¶
虽然命名空间获取逻辑试图尽可能准确,但我们仍然面临一些局限性
- 当前类的局部变量(
cls.__dict__
)可能包含不相关的条目,其中大多数是双下划线属性。这意味着以下注解:f: '__doc__'
将成功(且意外地)被解析。 - 当在函数内部创建
Model
类时,我们会保留帧的局部变量的副本。此副本仅包含在定义Model
时在局部变量中定义的符号,这意味着InnerType2
将不会被包含(并且如果在稍后进行模型重建,则不会被包含!)。- 为了避免内存泄漏,我们使用对函数局部变量的弱引用,这意味着某些前向引用可能无法在函数外部解析 (1)。
- 函数的局部变量仅在 Pydantic 模型中考虑,但此模式不适用于数据类、类型化字典或命名元组。
-
这是一个示例
def func(): A = int class Model(BaseModel): f: 'A | Forward' return Model Model = func() Model.model_rebuild(_types_namespace={'Forward': str}) # pydantic.errors.PydanticUndefinedAnnotation: name 'A' is not defined
出于向后兼容性原因,并且为了能够支持有效的用例而无需重建模型,上述命名空间逻辑在核心模式生成方面略有不同。以下面的示例为例
from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class Foo:
a: 'Bar | None' = None
class Bar(BaseModel):
b: Foo
一旦收集了 Bar
的字段(意味着注解已解析),GenerateSchema
类会将每个字段转换为核心模式。当它遇到另一个类似类的字段类型(例如数据类)时,它将尝试评估注解,大致遵循与上面描述的相同的逻辑。但是,为了评估 'Bar | None'
注解,Bar
需要存在于全局或局部变量中,但这通常不是这种情况:Bar
正在被创建,因此此时它尚未“分配”给当前模块的 __dict__
。
为了避免在 Bar
上调用 model_rebuild()
,父命名空间(如果 Bar
要在函数内部定义,以及模型重建期间提供的命名空间)和 {Bar.__name__: Bar}
命名空间都包含在 Foo
的注解评估期间的局部变量中(优先级最低)(1)。
-
这种向后兼容性逻辑可能会引入一些不一致性,例如以下情况
from dataclasses import dataclass from pydantic import BaseModel @dataclass class Foo: # `a` and `b` shouldn't resolve: a: 'Model' b: 'Inner' def func(): Inner = int class Model(BaseModel): foo: Foo Model.__pydantic_complete__ #> True, should be False.
在重建模型时解析注解¶
当前向引用无法评估时,Pydantic 将静默失败并停止核心模式生成过程。这可以通过检查模型类的 __pydantic_core_schema__
来看到
from pydantic import BaseModel
class Foo(BaseModel):
f: 'MyType'
Foo.__pydantic_core_schema__
#> <pydantic._internal._mock_val_ser.MockCoreSchema object at 0x73cd0d9e6d00>
如果您随后正确定义了 MyType
,则可以重建模型
type MyType = int
Foo.model_rebuild()
Foo.__pydantic_core_schema__
#> {'type': 'model', 'schema': {...}, ...}
model_rebuild()
方法使用重建命名空间,其语义如下
- 如果提供了显式的
_types_namespace
参数,则将其用作重建命名空间。 - 如果未提供命名空间,则将使用调用该方法的命名空间作为重建命名空间。
此重建命名空间将与模型的父命名空间(如果它是在函数中定义的)合并,并按原样使用(请参阅上面描述的向后兼容性逻辑)。