Pydantic validate_call 深度指南:用类型注解为任意函数注入参数校验

Pydantic validate_call 深度指南:用类型注解为任意函数注入参数校验

【免费下载链接】pydantic Data validation using Python type hints 【免费下载链接】pydantic 项目地址: https://gitcode.com/GitHub_Trending/py/pydantic

validate_call 是 Pydantic 提供的一个函数装饰器,它利用函数的类型注解,在被装饰函数真正执行之前自动完成参数的解析与校验(可选地校验返回值),并以极少的样板代码把 Pydantic 的校验能力带到普通函数、方法、偏函数(partial)与 lambda 上。本文以仓库中的 API 参考文档 及其指向的完整用法文档为主体,结合 装饰器源码内部实现测试用例 进行纵深讲解,读完你将能熟练使用 validate_call 的两种调用形式、掌握所有参数形态的校验、Field() 约束、别名、自定义配置与异步场景,并理解其底层原理与性能边界。

validate_call 是什么

validate_call 定义于 pydantic/validate_call_decorator.py__all__ = ('validate_call',),并从 pydantic/init.py 顶层导出)。它返回一个包裹原函数的包装函数,在函数调用前根据注解校验实参:

  • 用法既可以是裸装饰器 @validate_call,也可以是带参形式 @validate_call(...)
  • 带参形式支持两个关键字参数:config(配置字典)与 validate_return(是否校验返回值);
  • 当被装饰的调用失败时,抛出标准的 pydantic_core.ValidationError,错误信息会明确指出被拒绝的参数与输入值。

官方文档说明,validate_call 底层与模型创建和初始化共用同一套机制(参见 Validators 概念文档),也就是“用类型注解生成 core schema → 交给 pydantic-core 执行校验”的完整链路,但对使用者而言,它提供了一种极低样板的方式把校验注入到任意函数中。

从源码看,装饰器的核心入口在 validate_call() 函数(validate_call_decorator.py):先调用 _check_function_type 检查函数类型,再构造 _validate_call.ValidateCallWrapper,最后通过 update_wrapper_attributes 生成保留原函数签名与文档的包装函数。

基础用法与首个示例

最简单的用法是直接给函数加上 @validate_call

from pydantic import ValidationError, validate_call


@validate_call
def repeat(s: str, count: int, *, separator: bytes = b'') -> bytes:
    b = s.encode()
    return separator.join(b for _ in range(count))


a = repeat('hello', 3)
print(a)
#> b'hellohellohello'

b = repeat('x', '4', separator=b' ')
print(b)
#> b'x x x x'

try:
    c = repeat('hello', 'wrong')
except ValidationError as exc:
    print(exc)
    """
    1 validation error for repeat
    1
      Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='wrong', input_type=str]
    """

注意第二个调用 repeat('x', '4', ...)'4' 是字符串,但被自动转换为整数 4 后才传入函数体。这正是 validate_call 的默认行为——与 Pydantic 模型一致,类型默认会被“宽容模式”的强制转换(coercion)。而第三个调用传入 'wrong' 时,校验失败抛出 ValidationError,错误信息清楚标明是第 2 个位置参数(1)无法解析为整数。

参数类型推断与类型转换

参数类型完全由函数注解推断;未注解的参数会被推断为 Any,这意味着它不经过任何校验。所有在 types 概念文档 中列出的类型都可以被校验,包括 Pydantic 模型与自定义类型。

类型转换(coercion)在参数传入真实函数前由装饰器完成,下面的例子展示了字符串如何被转换为 date 对象:

from datetime import date

from pydantic import validate_call


@validate_call
def greater_than(d1: date, d2: date, *, include_equal=False) -> date:  # (1)!
    if include_equal:
        return d1 >= d2
    else:
        return d1 > d2


d1 = '2000-01-01'  # (2)!
d2 = date(2001, 1, 1)
greater_than(d1, d2, include_equal=True)
  1. 因为 include_equal 没有类型注解,它会被推断为 Any
  2. 虽然 d1 是字符串,但它会被自动转换为 date 对象再参与比较。

这种自动转换非常有用,但也可能造成困惑(详见 模型数据转换)。如果你不希望发生转换,可以通过自定义配置开启 Strict 严格模式

注意:默认不校验返回值。默认情况下函数返回值不做校验;如需校验,将装饰器的 validate_return 参数设为 True 即可。

支持的函数签名形态

validate_call 设计上支持所有可能的参数配置及其任意组合:

  • 带默认值或不带默认值的位置参数 / 关键字参数;
  • 仅关键字参数:* 之后的参数;
  • 仅位置参数:/ 之前的参数;
  • 可变位置参数:*args
  • 可变关键字参数:**kwargs
from pydantic import validate_call


@validate_call
def pos_or_kw(a: int, b: int = 2) -> str:
    return f'a={a} b={b}'


print(pos_or_kw(1, b=3))
#> a=1 b=3


@validate_call
def kw_only(*, a: int, b: int = 2) -> str:
    return f'a={a} b={b}'


print(kw_only(a=1))
#> a=1 b=2
print(kw_only(a=1, b=3))
#> a=1 b=3


@validate_call
def pos_only(a: int, b: int = 2, /) -> str:
    return f'a={a} b={b}'


print(pos_only(1))
#> a=1 b=2


@validate_call
def var_args(*args: int) -> str:
    return str(args)


print(var_args(1))
#> (1,)
print(var_args(1, 2, 3))
#> (1, 2, 3)


@validate_call
def var_kwargs(**kwargs: int) -> str:
    return str(kwargs)


print(var_kwargs(a=1))
#> {'a': 1}
print(var_kwargs(a=1, b=2))
#> {'a': 1, 'b': 2}


@validate_call
def armageddon(
    a: int,
    /,
    b: int,
    *c: int,
    d: int,
    e: int = None,
    **f: int,
) -> str:
    return f'a={a} b={b} c={c} d={d} e={e} f={f}'


print(armageddon(1, 2, d=3))
#> a=1 b=2 c=() d=3 e=None f={}
print(armageddon(1, 2, 3, 4, 5, 6, d=8, e=9, f=10, spam=11))
#> a=1 b=2 c=(3, 4, 5, 6) d=8 e=9 f={'f': 10, 'spam': 11}

最后一个 armageddon 综合了全部五种形态:仅位置参数 a、普通参数 b、可变位置参数 *c、仅关键字参数 d/e 以及可变关键字参数 **f,可以看到 *args**kwargs 的元素也会按注解 int 逐一校验。

从源码看,这一能力来自 _generate_schema._arguments_schemapydantic/_internal/_generate_schema.py):它通过 signature_no_eval 获取函数签名,将参数种类映射为 positional_only / positional_or_keyword / keyword_only 三种模式,并为 VAR_POSITIONALVAR_KEYWORD 单独生成元素 schema;未注解的参数在此处被显式回退为 Any(源码第 2019-2020 行)。

用 Unpack + TypedDict 注解 **kwargs

自 v2.10 起,Unpack 与 TypedDict 可用于注解函数的可变关键字参数(对应 PEP 692 规范):

from typing_extensions import TypedDict, Unpack

from pydantic import validate_call


class Point(TypedDict):
    x: int
    y: int


@validate_call
def add_coords(**kwargs: Unpack[Point]) -> int:
    return kwargs['x'] + kwargs['y']


add_coords(x=1, y=2)

源码层面,_arguments_schema 会识别 Unpack[...] 注解并要求其内部必须是 TypedDict 类,否则抛出 PydanticUserError(错误码 unpack-typed-dict),同时会检查 TypedDict 字段与函数其他非仅位置参数是否重叠。

用 Field() 描述函数参数

Field() 函数同样可以配合装饰器为参数提供额外信息与校验。如果不使用 default / default_factory 参数,官方建议采用 Annotated 模式,让类型检查器把参数推断为必填;否则可以把 Field() 当作默认值使用,以“骗过”类型检查器认为参数有默认值:

from typing import Annotated

from pydantic import Field, ValidationError, validate_call


@validate_call
def how_many(num: Annotated[int, Field(gt=10)]):
    return num


try:
    how_many(1)
except ValidationError as e:
    print(e)
    """
    1 validation error for how_many
    0
      Input should be greater than 10 [type=greater_than, input_value=1, input_type=int]
    """


@validate_call
def return_value(value: str = Field(default='default value')):
    return value


print(return_value())
#> default value

字段别名也可照常使用:

from typing import Annotated

from pydantic import Field, validate_call


@validate_call
def how_many(num: Annotated[int, Field(gt=10, alias='number')]):
    return num


how_many(number=42)

通过 raw_function 访问原始函数

被装饰的原始函数可以通过包装函数的 raw_function 属性访问。当你信任输入参数、希望以最高效的方式直接调用函数(参见下文性能讨论)时非常有用:

from pydantic import validate_call


@validate_call
def repeat(s: str, count: int, *, separator: bytes = b'') -> bytes:
    b = s.encode()
    return separator.join(b for _ in range(count))


a = repeat('hello', 3)
print(a)
#> b'hellohellohello'

b = repeat.raw_function('good bye', 2, separator=b', ')
print(b)
#> b'good bye, good bye'

这个属性由 pydantic/_internal/_validate_call.py 中的 update_wrapper_attributes 设置:它使用 functools.wraps 保留 __name____qualname____doc____module__ 等元数据,再显式回填包装函数的 raw_function。测试 tests/test_validate_call.py 验证了 __doc____name____module____qualname__ 以及 inspect.signature 均与原函数一致。

异步函数

validate_call 同样适用于 async 函数:

import asyncio

from pydantic import PositiveInt, ValidationError, validate_call


@validate_call
async def get_user_email(user_id: PositiveInt):
    # `conn` 是某个虚构的数据库连接
    email = await conn.execute('select email from users where id=$1', user_id)
    if email is None:
        raise RuntimeError('user not found')
    else:
        return email


async def main():
    email = await get_user_email(123)
    print(email)
    #> testing@example.com
    try:
        await get_user_email(-4)
    except ValidationError as exc:
        print(exc.errors(include_url=False))
        """
        [
            {
                'type': 'greater_than',
                'loc': (0,),
                'msg': 'Input should be greater than 0',
                'input': -4,
                'ctx': {'gt': 0},
            }
        ]
        """


asyncio.run(main())
# requires: `conn.execute()` 将返回 `'testing@example.com'`

注意 user_id: PositiveIntawait 之前就会被校验,负数 -4 直接触发 greater_than 错误。在源码中,异步包装由 update_wrapper_attributes 通过 inspect.iscoroutinefunction 检测并生成对应的 async def wrapper_function;而当 validate_return=True 时,ValidateCallWrapper._create_validators 会为协程专门生成一个 return_val_wrapper,先 await 协程再校验其返回值。测试 test_async_func 验证了这一行为。

与类型检查器的兼容性

由于装饰器完整保留了被装饰函数的签名(通过 functools.wraps),validate_call 与 mypy、pyright 等类型检查器兼容。不过受限于当前 Python 类型系统的能力,raw_function 等附加属性不会被类型检查器识别,访问时需要抑制报错(通常用 # type: ignore 注释)。

自定义配置

与 Pydantic 模型类似,装饰器的 config 参数可以传入自定义配置,例如允许任意类型的 ConfigDict(arbitrary_types_allowed=True)

from pydantic import ConfigDict, ValidationError, validate_call


class Foobar:
    def __init__(self, v: str):
        self.v = v

    def __add__(self, other: 'Foobar') -> str:
        return f'{self} + {other}'

    def __str__(self) -> str:
        return f'Foobar({self.v})'


@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def add_foobars(a: Foobar, b: Foobar):
    return a + b


c = add_foobars(Foobar('a'), Foobar('b'))
print(c)
#> Foobar(a) + Foobar(b)

try:
    add_foobars(1, 2)
except ValidationError as e:
    print(e)
    """
    2 validation errors for add_foobars
    0
      Input should be an instance of Foobar [type=is_instance_of, input_value=1, input_type=int]
    1
      Input should be an instance of Foobar [type=is_instance_of, input_value=2, input_type=int]
    """

除了 arbitrary_types_allowed,任何 ConfigDict 支持的选项(如 strictstr_strip_whitespaceextra 等)都可在此使用。值得补充的是:若配置了 defer_build=TrueValidateCallWrapper 会推迟 schema 的构建,直到首次调用时才在 __call__ 中触发 _create_validators()(源码第 87-90、133-136 行),这是一种惰性构建优化。

扩展:将参数校验与函数调用分离

在某些场景下,把参数校验与函数调用本身分离开是很有用的——尤其是当目标函数代价高昂、耗时较长时。官方文档给出了如下工作区模式:

from pydantic import validate_call


@validate_call
def validate_foo(a: int, b: int):
    def foo():
        return a + b

    return foo


foo = validate_foo(a=1, b=2)
print(foo())
#> 3

先通过 validate_foo(a=1, b=2) 完成参数校验并闭包捕获已校验的参数,再在真正需要时调用返回的 foo()——校验只发生一次,后续调用不再有任何校验开销。

源码层面的工作方式

validate_call 的实现虽然只有短短几行入口,但背后是一整套与模型相同的 schema 生成与校验流水线:

  1. 类型检查_check_function_typevalidate_call_decorator.py)只接受 LambdaType | FunctionType | MethodType | partial(见 pydantic/_internal/_generate_schema.pyValidateCallSupportedTypes 别名)。以下情况会抛出 PydanticUserError(错误码 validate-call-type):
    • 内建函数(如 breakpoint)不受支持;
    • @classmethod / @staticmethod 装饰器必须放在 @validate_call 之上(先写 @classmethod 再写 @validate_call),顺序颠倒会报错;
    • 直接对类使用会报错,官方建议把 @validate_call 加到 __init____new__ 上(测试 test_validate_class 验证了 A('5').x == 5);
    • 对可调用实例使用会报错,官方建议显式装饰其 __call__ 方法;
    • partial 的底层函数也必须属于受支持类型,例如 validate_call(partial(list)) 会被拒绝。
  2. Schema 生成ValidateCallWrapper._create_validatorspydantic/_internal/_validate_call.py)使用 GenerateSchema 为整个函数签名生成 core_schema.CallSchema(入口见 _generate_schema.py 的 _call_schema),再经 create_schema_validator 创建 __pydantic_validator__;当 validate_return=True 时,额外为返回注解生成一个返回校验器。
  3. 调用校验:每次调用时,实参被包装为 pydantic_core.ArgsKwargs(args, kwargs) 交给 __pydantic_validator__.validate_python(源码第 133-141 行),校验结果直接作为返回值(或先经返回校验器)。由于校验基于 pydantic-core 的 Rust 实现,参数检查本身非常高效。
  4. 命名空间解析:构造时还会通过 NsResolverparent_frame_namespace 解析函数定义处的命名空间,确保带字符串注解、TypeVar 等场景也能正确求值(见测试 test_eval_namespace_basic)。

限制与性能

校验异常

目前校验失败时会抛出标准的 Pydantic ValidationError(详见 模型错误处理),这一点对“缺少必填参数”同样成立——通常 Python 会抛出 TypeError,但使用 validate_call 后,缺失必填参数会以 missing_argument / missing_keyword_only_argument 错误类型的 ValidationError 报告(见测试 test_argstest_kwargs)。错误信息会指明被拒绝的参数与值;如果需要连同周围 trace 上下文一并记录这些细节,可配合 Logfire 集成 使用,参见排查校验错误

性能

官方文档明确说明:虽然对被装饰函数的检查(schema 生成)只执行一次,但每次调用包装函数相比直接调用原始函数仍存在性能开销。多数场景下这种开销几乎没有可感知的影响,但请务必理解:validate_call 不是强类型语言中函数定义的等价物或替代品,也不可能是。若某段热路径对性能敏感,可借助 raw_function 绕过校验直接调用原始函数。

【免费下载链接】pydantic Data validation using Python type hints 【免费下载链接】pydantic 项目地址: https://gitcode.com/GitHub_Trending/py/pydantic

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值