Pydantic validate_call 验证装饰器实战指南:基于类型注解的函数入参与返回值校验
【免费下载链接】pydanticData validation using Python type hints项目地址: https://gitcode.com/GitHub_Trending/py/pydantic
pydantic.validate_call装饰器让普通 Python 函数也能像 Pydantic 模型一样,在调用前依据类型注解自动完成参数的解析、类型强制转换与校验,失败时抛出标准ValidationError。本指南以官方概念文档 validation_decorator.md 为主体,结合仓库内装饰器的实际实现源码与测试用例,系统讲解其用法、支持的参数形态、配置方式与已知限制,帮助你在接口封装、CLI 参数校验、数据管道等场景中以极简样板代码获得类型安全的函数调用层。
快速上手:一个装饰器完成函数入参校验
validate_call()装饰器允许在函数真正被调用之前,利用函数的类型注解对传入参数进行解析与校验。其底层复用了 Pydantic 模型创建与初始化的同一套机制(详见 Validators 中关于校验器的说明),但对使用者而言,它提供了一种极简样板代码即可为既有代码加上校验能力的方式:
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] """可以看到,count参数声明为int,传入字符串'4'会被自动转换为整数4;而传入无法解析的'wrong'时,会抛出ValidationError,错误信息中明确标识了出错参数的位置(1,即第二个位置参数)、错误类型int_parsing与原始输入值。
从源码实现看,装饰器的入口位于 pydantic/validate_call_decorator.py。它支持两种调用形态:作为裸装饰器@validate_call,或带参数调用@validate_call(...)。装饰过程的核心步骤是:
- 通过
_check_function_type校验被装饰对象必须是函数、方法、partial或 lambda,并具备合法签名; - 构造 pydantic/_internal/_validate_call.py 中的
ValidateCallWrapper,用GenerateSchema基于函数签名生成 Core Schema,并借助create_schema_validator创建__pydantic_validator__; - 调用时把
(args, kwargs)包装为pydantic_core.ArgsKwargs,交给 validator 的validate_python完成校验,校验通过后再执行真正的函数体。
关键细节:校验只发生在调用入口,装饰器的__call__逻辑(res = self.__pydantic_validator__.validate_python(pydantic_core.ArgsKwargs(args, kwargs)))与实际函数体是分离的,因此校验失败不会执行函数体。
参数类型:从注解推断,未注解默认为 Any
参数类型直接从函数的类型注解推断;若某个参数没有注解,则按Any处理,即不做任何校验与转换。文档中列出的全部类型(types 与 custom types)都可以被校验,包括 Pydantic 模型本身。
与 Pydantic 其他部分一致,装饰器默认会对类型做强制转换(coercion),转换完成后再把值传给真实函数:
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)include_equal没有类型注解,因此被推断为Any,不做校验;- 虽然
d1是字符串,但会在调用前被转换为date对象(输出为True)。
类型强制转换非常有用,但也可能带来困惑或不符合某些场景的预期(参见 模型数据转换 的讨论)。如果需要关闭转换,可以通过 自定义配置 开启 严格模式。测试 tests/test_validate_call.py 验证了strict=True下'foo'无法通过int校验、元组无法通过list校验的行为。
注意:默认不校验返回值。默认情况下,函数返回值不会被校验。需要校验返回值时,将装饰器的
validate_return参数设为True即可。从源码看,当validate_return=True时,ValidateCallWrapper会基于返回注解再生成一个__return_pydantic_validator__;对异步函数,它会先await协程拿到结果再校验(见 pydantic/_internal/_validate_call.py)。
函数签名:支持全部参数形态的组合
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}从源码层面看,参数形态信息来自_typing_extra.signature_no_eval取得的函数签名(见 pydantic/validate_call_decorator.py),并由GenerateSchema中的generate_schema转换为arguments类 Core Schema。测试用例(如 tests/test_validate_call.py)验证了各种调用组合:foo(*[1, 2])、foo(a=1, b=2)、foo(1, b=2)均能正确解析;而缺少必填参数会报missing_argument,多余的位置参数报unexpected_positional_argument,多余的关关键字参数报unexpected_keyword_argument,同一参数被重复传值则报multiple_argument_values。
用Unpack+TypedDict标注可变关键字参数
Unpack与 TypedDict 可用来给函数的可变关键字参数做细粒度注解(对应 PEP 692 与相关规范章节),该能力自v2.10起可用:
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)仓库测试进一步验证了该特性的边界行为(tests/test_validate_call.py):
**kwargs: Unpack[int](非 TypedDict)会触发PydanticUserError,错误码unpack-typed-dict;- TypedDict 字段与显式参数名重叠(如
def foo(a: int, b: int, **kwargs: Unpack[TD]))会报overlapping-unpack-typed-dict,但仅限位置参数(a: int, /)不与**kwargs冲突; TypedDict(total=False)中的Required字段仍然必须提供;closed=True的 TypedDict 不接受额外键,会报extra_forbidden;extra_items会约束额外键的值类型。
用 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 valueField()提供的约束(gt、lt、ge、le、multiple_of、max_length、min_length、pattern等)与在模型字段中的行为完全一致,测试 tests/test_validate_call.py 验证了Annotated[int, Field(gt=0), Field(lt=10)]分别触发greater_than与less_than错误。此外,Field(default_factory=...)也受支持(见test_field_can_provide_factory)。
别名(Alias)同样可用
字段别名在装饰器中正常工作,调用时需使用别名作为关键字:
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)测试还覆盖了更多别名场景:tests/test_validate_call.py 中的test_annotated_use_of_alias(空字符串别名、别名缺省会报missing_argument且原参数名被视为多余关键字)、test_validation_alias(validation_alias与AliasChoices('d', 'e'))、test_validate_by_name(validate_by_name: True时别名与原名可混用)以及test_populate_by_name。配置alias_generator也可生效(test_alias_generator,tests/test_validate_call.py)。
访问原始函数: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'在源码中,raw_function由update_wrapper_attributes赋值(pydantic/_internal/_validate_call.py):装饰器通过functools.wraps保留原始函数的__doc__、__module__等元数据,并手动修正__name__与__qualname__(对partial对象会显示为partial(func_name)的形式),最后挂载raw_function指向被包裹的原始函数。
异步函数支持
validate_call()同样适用于async函数,校验逻辑一致,且异步调用前同样完成参数校验:
class Connection: async def execute(self, sql, *args): return 'testing@example.com' conn = Connection() 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()) """ [ { 'type': 'greater_than', 'loc': (0,), 'msg': 'Input should be greater than 0', 'input': -4, 'ctx': {'gt': 0}, 'url': 'https://errors.pydantic.dev/2/v/greater_than', } ] """ asyncio.run(main()) # 需要:`conn.execute()` 返回 `'testing@example.com'`示例中user_id: PositiveInt传入-4时抛出的ValidationError,其loc为(0,)(第一个参数),错误类型为greater_than。源码层面,update_wrapper_attributes会通过inspect.iscoroutinefunction(wrapped)检测异步函数并返回对应的async def wrapper_function(pydantic/_internal/_validate_call.py),保证装饰后inspect.iscoroutinefunction仍返回True(见测试test_async)。若同时开启validate_return=True,返回值校验也会先await协程结果再执行。
与类型检查器(mypy / pyright)的兼容性
由于validate_call()装饰器保留了被装饰函数的签名(通过functools.wraps与签名修复机制),它与类型检查器(如 mypy、pyright)是兼容的——类型检查器看到的仍是原函数签名,因此参数类型检查可正常进行。测试test_wrap(tests/test_validate_call.py)确认了装饰后inspect.signature返回的签名与原始签名一致。
但受限于当前 Python 类型系统的能力,raw_function等额外属性不会被类型检查器识别,访问它们时通常需要抑制错误(一般通过# type: ignore注释)。
自定义配置(config 参数)
与 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] """配置在源码中被封装为ConfigWrapper(config)(pydantic/_internal/_validate_call.py),再传入GenerateSchema与create_schema_validator,因此与模型配置共享同一套解析与生效逻辑。除上述示例外,仓库测试验证了这些配置的可用性:
strict=True:禁止隐式类型转换(test_config_strict);validate_by_name=True/populate_by_name=True:别名之外同时接受原参数名(test_validate_by_name、test_populate_by_name);alias_generator:自动生成参数别名(test_alias_generator);field_title_generator:影响生成 JSON Schema 时的字段标题(test_json_schema_custom_title)。
扩展模式:先校验、后调用昂贵函数
某些场景下,你可能想把"参数校验"与"函数调用"分离——例如目标函数执行代价很高或耗时很长时,可以先用装饰器封装一个返回闭包的函数,让校验发生在最外层:
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(1, 2)的调用参数先被校验,但真正的计算逻辑(foo()闭包)尚未执行;之后任意时刻再调用foo()即可。这样既避免了昂贵计算在无效输入上浪费资源,也把校验点前移到了数据入口。
装饰器可接受的函数类型与常见误用
结合 pydantic/validate_call_decorator.py 的_check_function_type与测试(tests/test_validate_call.py),validate_call支持:普通函数、lambda、类方法(含__init__、__new__、__call__)、实例方法、staticmethod、classmethod及functools.partial。以下误用会抛出带错误码validate-call-type的PydanticUserError:
| 误用形式 | 报错提示 |
|---|---|
内置函数(如breakpoint) | Input built-in function ... is not supported |
对staticmethod/classmethod先于validate_call应用 | The @staticmethod decorator should be applied after @validate_call(应把@classmethod/@staticmethod放上面) |
| 直接装饰类 | validate_call应作用于函数,请装饰__init__或__new__ |
| 装饰可调用实例 | 应显式装饰__call__ |
| 无合法签名的函数 | doesn't have a valid signature |
partial(partial(...))等嵌套 | Partial of ... is invalid ... |
注意:由于functools.partial对象没有__name__与__qualname__,源码会将其命名为partial(原函数名)(pydantic/_internal/_validate_call.py)。
限制(Limitations)
校验异常的类型
目前校验失败时抛出的是标准的 PydanticValidationError(由 pydantic-core 提供)。即使缺少必填参数,也不会像原生 Python 那样抛TypeError,而是同样抛出ValidationError(错误类型如missing_argument、missing_keyword_only_argument、missing_positional_only_argument,见测试test_args、test_kwargs、test_positional_only)。
该错误能定位被拒绝的参数与值。如果你还需要在周边 trace 上下文中保留这些细节,Logfire 可以在被装饰的调用失败时记录它们。
性能(Performance)
Pydantic 在性能上做了大量努力:对装饰函数的签名检查与 Schema 生成只执行一次(ValidateCallWrapper构造时完成,见 pydantic/_internal/_validate_call.py;除非启用defer_build延迟构建,此时首次调用才触发_create_validators)。尽管如此,相比直接调用原始函数,经由装饰器的每次调用仍存在一定的性能开销。
在多数场景下这种开销几乎无感;但请务必认识到:validate_call()不等同于、也无法替代强类型语言中的函数定义,未来也不会变成那样。它只是一种"运行时校验"手段,不是编译期类型系统。在追求极致性能的热路径上,可以考虑在信任输入时通过raw_function绕过校验直接调用。
小结
validate_call是 Pydantic 中"用类型注解描述校验规则"理念在普通函数上的延伸:无需定义模型类,仅加一个装饰器,即可获得入参解析、类型转换、约束校验、异步支持、返回值校验(validate_return=True)、自定义配置与 JSON Schema 生成等完整能力。它适合为脚本入口、RPC/CLI 边界、数据管道节点等位置快速建立"可信边界",同时与Field()、Annotated 模式、别名、严格模式等 Pydantic 生态能力无缝衔接。相关更深入的细节可继续阅读 字段定义与约束、类型体系、严格模式 与 校验器机制。
【免费下载链接】pydanticData validation using Python type hints项目地址: https://gitcode.com/GitHub_Trending/py/pydantic
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考