解析注解
Note
本节是内部实现文档的一部分,主要面向贡献者。
Pydantic 在运行时严重依赖类型提示来构建验证、序列化等所需的模式。
虽然类型提示最初是为静态类型检查器(如 Mypy 或 Pyright)引入的,但它们在运行时是可访问的(有时甚至会被求值)。这意味着以下代码会在运行时失败,因为 Node 在当前模块中尚未定义:
class 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
# 有了上面的 future 导入,这等价于:
# f: 'MyType'
type MyType = int
print(Foo.__annotations__)
#> {'f': 'MyType'}
运行时求值的挑战¶
静态类型检查器利用 AST 来分析已定义的注解。对于前面的例子,这有一个好处,即在分析 Foo 的类定义时能够理解 MyType 指的是什么,即使 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。 - 类的父命名空间(如果与上述全局命名空间不同)。这是定义类的帧的局部变量。对于
Base,因为类直接在模块中定义,这个命名空间不会被使用,因为它会导致再次使用全局命名空间。对于Model,父命名空间是inner()帧的局部变量。
- 一个即时创建的命名空间,包含当前类名(
- 当前模块的
- 如果注解求值失败,则保持原样,以便模型可以在后期重建。
f5就是这种情况。
一旦 Model 类创建完成,每个字段的解析后的类型注解如下表所示:
| 字段名 | 解析后的注解 |
|---|---|
f1 |
int |
f2 |
str |
f3 |
bool |
f4 |
bytes |
f5 |
'UnknownType' |
限制和向后兼容性问题¶
虽然命名空间获取逻辑试图尽可能准确,但我们仍然面临一些限制:
-
这是一个例子:
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` 和 `b` 不应该解析: a: 'Model' b: 'Inner' def func(): Inner = int class Model(BaseModel): foo: Foo Model.__pydantic_complete__ #> True, 应该是 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参数,则将其用作重建命名空间。 - 如果未提供命名空间,则调用该方法的命名空间将被用作重建命名空间。
这个重建命名空间将与模型的父命名空间(如果它是在函数中定义的)合并,并按原样使用(参见上面描述的向后兼容逻辑)。