跳转到内容

解析注解

注意

本节是 内部原理 文档的一部分,部分面向贡献者。

Pydantic 在运行时高度依赖 类型提示 来构建验证、序列化等的模式。

虽然类型提示主要用于静态类型检查器(如 MypyPyright),但它们在运行时是可访问的(有时会被评估)。这意味着以下代码在运行时会失败,因为 `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` 未来语句,类型提示默认被字符串化

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 来分析已定义的注解。对于前面的例子,这有一个好处,即在分析 `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` 开始),应用以下逻辑

  1. 如果存在,从当前基类的 `__dict__` 中获取 `__annotations__` 键。对于 `Base`,这将是 `{'f1': 'MyType'}`。
  2. 遍历 `__annotations__` 项,并尝试使用内置 `eval()` 函数的自定义包装器来评估注解 1。此函数接受两个 `globals` 和 `locals` 参数
    • 当前模块的 `__dict__` 自然用作 `globals`。对于 `Base`,这将是 `sys.modules['module1'].__dict__`。
    • 对于 `locals` 参数,Pydantic 将尝试在以下命名空间中解析符号,按优先级从高到低排序
      • 即时创建的命名空间,包含当前类名(`{cls.__name__: cls}`)。这样做是为了支持递归引用。
      • 当前类的局部变量(即 `cls.__dict__`)。对于 `Model`,这将包括 `LocalType`。
      • 类的父命名空间,如果与上述全局变量不同。这是定义类的框架的 局部变量。对于 `Base`,由于类直接在模块中定义,因此不会使用此命名空间,因为它将导致再次使用全局变量。对于 `Model`,父命名空间是 `inner()` 函数框架的局部变量。
  3. 如果注解评估失败,它将保持原样,以便模型可以在稍后阶段重建。`f5` 就是这种情况。

下表列出了 `Model` 类创建后每个字段已解析的类型注解

字段名称 已解析的注解
f1 int
f2 str
f3 bool
f4 bytes
f5 'UnknownType'

局限性和向后兼容性问题

虽然命名空间获取逻辑力求尽可能准确,但我们仍然面临一些限制

  • 当前类的局部变量(`cls.__dict__`)可能包含不相关的条目,其中大部分是特殊属性。这意味着以下注解:`f: '__doc__'` 将成功(且意外地)解析。
  • 当 `Model` 类在函数内部创建时,我们会保留框架 局部变量 的副本。此副本仅包含 `Model` 定义时局部变量中定义的符号,这意味着 `InnerType2` 将不会被包含(如果稍后进行模型重建,也**不会**被包含!)。
    • 为了避免内存泄漏,我们使用 弱引用 指向函数的局部变量,这意味着某些前向引用可能在函数外部无法解析 (1)。
    • 函数的局部变量仅适用于 Pydantic 模型,但此模式不适用于数据类、类型化字典或命名元组。
  1. 这是一个例子

    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()`,在 `Foo` 的注解评估期间(优先级最低)(1),父命名空间(如果 `Bar` 在函数内部定义,以及 模型重建期间提供的命名空间)和 `{Bar.__name__: Bar}` 命名空间都包含在局部变量中。

  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` 参数,则将其用作重建命名空间。
  • 如果没有提供命名空间,则将调用方法的命名空间用作重建命名空间。

重建命名空间 将与模型的父命名空间(如果它是在函数中定义的)合并并按原样使用(请参阅上述 向后兼容性逻辑)。


  1. 这是无条件完成的,因为前向注解只能作为类型提示的**一部分**存在(例如 `Optional['int']`),正如 类型规范 所规定的那样。