Files
pydantic/tests/test_decorator.py
T
Mateo Aguirre Duque 7431683e88 Alias support in decorator validate_arguments (#3019)
* add support for alias handling in validate_arguments

* add test for alias handling in validate_arguments

* add documentation on alias support for decorator

* bug fixed in the validate_argumen decorator documentation

* add changes README

* change in the documentation due to a drafting error

* Update changes/3019-MAD-py.md

Co-authored-by: Eric Jolibois <em.jolibois@gmail.com>

* more challenging tests

* run the format checker

* integration of empty string as alias

Co-authored-by: Eric Jolibois <em.jolibois@gmail.com>
Co-authored-by: Samuel Colvin <s@muelcolvin.com>
2022-08-08 13:33:51 +01:00

481 lines
14 KiB
Python

import asyncio
import inspect
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List
import pytest
from typing_extensions import Annotated, TypedDict
from pydantic import BaseModel, Extra, Field, ValidationError, validate_arguments
from pydantic.decorator import ValidatedFunction
from pydantic.errors import ConfigError
skip_pre_38 = pytest.mark.skipif(sys.version_info < (3, 8), reason='testing >= 3.8 behaviour only')
def test_args():
@validate_arguments
def foo(a: int, b: int):
return f'{a}, {b}'
assert foo(1, 2) == '1, 2'
assert foo(*[1, 2]) == '1, 2'
assert foo(*(1, 2)) == '1, 2'
assert foo(*[1], 2) == '1, 2'
with pytest.raises(ValidationError) as exc_info:
foo()
assert exc_info.value.errors() == [
{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'},
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 'x')
assert exc_info.value.errors() == [
{'loc': ('b',), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, 3)
assert exc_info.value.errors() == [
{'loc': ('args',), 'msg': '2 positional arguments expected but 3 given', 'type': 'type_error'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, apple=3)
assert exc_info.value.errors() == [
{'loc': ('kwargs',), 'msg': "unexpected keyword argument: 'apple'", 'type': 'type_error'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, a=3)
assert exc_info.value.errors() == [
{'loc': ('v__duplicate_kwargs',), 'msg': "multiple values for argument: 'a'", 'type': 'type_error'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, a=3, b=4)
assert exc_info.value.errors() == [
{'loc': ('v__duplicate_kwargs',), 'msg': "multiple values for arguments: 'a', 'b'", 'type': 'type_error'}
]
def test_wrap():
@validate_arguments
def foo_bar(a: int, b: int):
"""This is the foo_bar method."""
return f'{a}, {b}'
assert foo_bar.__doc__ == 'This is the foo_bar method.'
assert foo_bar.__name__ == 'foo_bar'
assert foo_bar.__module__ == 'tests.test_decorator'
assert foo_bar.__qualname__ == 'test_wrap.<locals>.foo_bar'
assert isinstance(foo_bar.vd, ValidatedFunction)
assert callable(foo_bar.raw_function)
assert foo_bar.vd.arg_mapping == {0: 'a', 1: 'b'}
assert foo_bar.vd.positional_only_args == set()
assert issubclass(foo_bar.model, BaseModel)
assert foo_bar.model.__fields__.keys() == {'a', 'b', 'args', 'kwargs', 'v__duplicate_kwargs'}
assert foo_bar.model.__name__ == 'FooBar'
assert foo_bar.model.schema()['title'] == 'FooBar'
assert repr(inspect.signature(foo_bar)) == '<Signature (a: int, b: int)>'
def test_kwargs():
@validate_arguments
def foo(*, a: int, b: int):
return a + b
assert foo.model.__fields__.keys() == {'a', 'b', 'args', 'kwargs'}
assert foo(a=1, b=3) == 4
with pytest.raises(ValidationError) as exc_info:
foo(a=1, b='x')
assert exc_info.value.errors() == [
{'loc': ('b',), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 'x')
assert exc_info.value.errors() == [
{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('args',), 'msg': '0 positional arguments expected but 2 given', 'type': 'type_error'},
]
def test_untyped():
@validate_arguments
def foo(a, b, c='x', *, d='y'):
return ', '.join(str(arg) for arg in [a, b, c, d])
assert foo(1, 2) == '1, 2, x, y'
assert foo(1, {'x': 2}, c='3', d='4') == "1, {'x': 2}, 3, 4"
@pytest.mark.parametrize('validated', (True, False))
def test_var_args_kwargs(validated):
def foo(a, b, *args, d=3, **kwargs):
return f'a={a!r}, b={b!r}, args={args!r}, d={d!r}, kwargs={kwargs!r}'
if validated:
foo = validate_arguments(foo)
assert foo(1, 2) == 'a=1, b=2, args=(), d=3, kwargs={}'
assert foo(1, 2, 3, d=4) == 'a=1, b=2, args=(3,), d=4, kwargs={}'
assert foo(*[1, 2, 3], d=4) == 'a=1, b=2, args=(3,), d=4, kwargs={}'
assert foo(1, 2, args=(10, 11)) == "a=1, b=2, args=(), d=3, kwargs={'args': (10, 11)}"
assert foo(1, 2, 3, args=(10, 11)) == "a=1, b=2, args=(3,), d=3, kwargs={'args': (10, 11)}"
assert foo(1, 2, 3, e=10) == "a=1, b=2, args=(3,), d=3, kwargs={'e': 10}"
assert foo(1, 2, kwargs=4) == "a=1, b=2, args=(), d=3, kwargs={'kwargs': 4}"
assert foo(1, 2, kwargs=4, e=5) == "a=1, b=2, args=(), d=3, kwargs={'kwargs': 4, 'e': 5}"
def test_field_can_provide_factory() -> None:
@validate_arguments
def foo(a: int, b: int = Field(default_factory=lambda: 99), *args: int) -> int:
"""mypy is happy with this"""
return a + b + sum(args)
assert foo(3) == 102
assert foo(1, 2, 3) == 6
def test_annotated_field_can_provide_factory() -> None:
@validate_arguments
def foo2(a: int, b: Annotated[int, Field(default_factory=lambda: 99)], *args: int) -> int:
"""mypy reports Incompatible default for argument "b" if we don't supply ANY as default"""
return a + b + sum(args)
assert foo2(1) == 100
@skip_pre_38
def test_positional_only(create_module):
module = create_module(
# language=Python
"""
from pydantic import validate_arguments
@validate_arguments
def foo(a, b, /, c=None):
return f'{a}, {b}, {c}'
"""
)
assert module.foo(1, 2) == '1, 2, None'
assert module.foo(1, 2, 44) == '1, 2, 44'
assert module.foo(1, 2, c=44) == '1, 2, 44'
with pytest.raises(ValidationError) as exc_info:
module.foo(1, b=2)
assert exc_info.value.errors() == [
{
'loc': ('v__positional_only',),
'msg': "positional-only argument passed as keyword argument: 'b'",
'type': 'type_error',
}
]
with pytest.raises(ValidationError) as exc_info:
module.foo(a=1, b=2)
assert exc_info.value.errors() == [
{
'loc': ('v__positional_only',),
'msg': "positional-only arguments passed as keyword arguments: 'a', 'b'",
'type': 'type_error',
}
]
def test_args_name():
@validate_arguments
def foo(args: int, kwargs: int):
return f'args={args!r}, kwargs={kwargs!r}'
assert foo.model.__fields__.keys() == {'args', 'kwargs', 'v__args', 'v__kwargs', 'v__duplicate_kwargs'}
assert foo(1, 2) == 'args=1, kwargs=2'
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, apple=4)
assert exc_info.value.errors() == [
{'loc': ('v__kwargs',), 'msg': "unexpected keyword argument: 'apple'", 'type': 'type_error'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, apple=4, banana=5)
assert exc_info.value.errors() == [
{'loc': ('v__kwargs',), 'msg': "unexpected keyword arguments: 'apple', 'banana'", 'type': 'type_error'}
]
with pytest.raises(ValidationError) as exc_info:
foo(1, 2, 3)
assert exc_info.value.errors() == [
{'loc': ('v__args',), 'msg': '2 positional arguments expected but 3 given', 'type': 'type_error'}
]
def test_v_args():
with pytest.raises(
ConfigError, match='"v__args", "v__kwargs", "v__positional_only" and "v__duplicate_kwargs" are not permitted'
):
@validate_arguments
def foo1(v__args: int):
pass
with pytest.raises(
ConfigError, match='"v__args", "v__kwargs", "v__positional_only" and "v__duplicate_kwargs" are not permitted'
):
@validate_arguments
def foo2(v__kwargs: int):
pass
with pytest.raises(
ConfigError, match='"v__args", "v__kwargs", "v__positional_only" and "v__duplicate_kwargs" are not permitted'
):
@validate_arguments
def foo3(v__positional_only: int):
pass
with pytest.raises(
ConfigError, match='"v__args", "v__kwargs", "v__positional_only" and "v__duplicate_kwargs" are not permitted'
):
@validate_arguments
def foo4(v__duplicate_kwargs: int):
pass
def test_async():
@validate_arguments
async def foo(a, b):
return f'a={a} b={b}'
async def run():
v = await foo(1, 2)
assert v == 'a=1 b=2'
loop = asyncio.get_event_loop_policy().get_event_loop()
loop.run_until_complete(run())
with pytest.raises(ValidationError) as exc_info:
loop.run_until_complete(foo('x'))
assert exc_info.value.errors() == [{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'}]
def test_string_annotation():
@validate_arguments
def foo(a: 'List[int]', b: 'Path'):
return f'a={a!r} b={b!r}'
assert foo([1, 2, 3], '/')
with pytest.raises(ValidationError) as exc_info:
foo(['x'])
assert exc_info.value.errors() == [
{'loc': ('a', 0), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'},
{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'},
]
def test_item_method():
class X:
def __init__(self, v):
self.v = v
@validate_arguments
def foo(self, a: int, b: int):
assert self.v == a
return f'{a}, {b}'
x = X(4)
assert x.foo(4, 2) == '4, 2'
assert x.foo(*[4, 2]) == '4, 2'
with pytest.raises(ValidationError) as exc_info:
x.foo()
assert exc_info.value.errors() == [
{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'},
]
def test_class_method():
class X:
@classmethod
@validate_arguments
def foo(cls, a: int, b: int):
assert cls == X
return f'{a}, {b}'
x = X()
assert x.foo(4, 2) == '4, 2'
assert x.foo(*[4, 2]) == '4, 2'
with pytest.raises(ValidationError) as exc_info:
x.foo()
assert exc_info.value.errors() == [
{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'},
]
def test_config_title():
@validate_arguments(config=dict(title='Testing'))
def foo(a: int, b: int):
return f'{a}, {b}'
assert foo(1, 2) == '1, 2'
assert foo(1, b=2) == '1, 2'
assert foo.model.schema()['title'] == 'Testing'
def test_config_title_cls():
class Config:
title = 'Testing'
@validate_arguments(config=Config)
def foo(a: int, b: int):
return f'{a}, {b}'
assert foo(1, 2) == '1, 2'
assert foo(1, b=2) == '1, 2'
assert foo.model.schema()['title'] == 'Testing'
def test_config_fields():
with pytest.raises(ConfigError, match='Setting the "fields" and "alias_generator" property on custom Config for @'):
@validate_arguments(config=dict(fields={'b': 'bang'}))
def foo(a: int, b: int):
return f'{a}, {b}'
def test_config_arbitrary_types_allowed():
class EggBox:
def __str__(self) -> str:
return 'EggBox()'
@validate_arguments(config=dict(arbitrary_types_allowed=True))
def foo(a: int, b: EggBox):
return f'{a}, {b}'
assert foo(1, EggBox()) == '1, EggBox()'
with pytest.raises(ValidationError) as exc_info:
assert foo(1, 2) == '1, 2'
assert exc_info.value.errors() == [
{
'loc': ('b',),
'msg': 'instance of EggBox expected',
'type': 'type_error.arbitrary_type',
'ctx': {'expected_arbitrary_type': 'EggBox'},
},
]
def test_validate(mocker):
stub = mocker.stub(name='on_something_stub')
@validate_arguments
def func(s: str, count: int, *, separator: bytes = b''):
stub(s, count, separator)
func.validate('qwe', 2)
with pytest.raises(ValidationError):
func.validate(['qwe'], 2)
stub.assert_not_called()
def test_annotated_use_of_alias():
@validate_arguments
def foo(a: Annotated[int, Field(alias='b')], c: Annotated[int, Field()], d: Annotated[int, Field(alias='')]):
return a + c + d
assert foo(**{'b': 10, 'c': 12, '': 1}) == 23
with pytest.raises(ValidationError) as exc_info:
assert foo(a=10, c=12, d=1) == 10
assert exc_info.value.errors() == [
{'loc': ('b',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('',), 'msg': 'field required', 'type': 'value_error.missing'},
{'loc': ('a',), 'msg': 'extra fields not permitted', 'type': 'value_error.extra'},
{'loc': ('d',), 'msg': 'extra fields not permitted', 'type': 'value_error.extra'},
]
def test_use_of_alias():
@validate_arguments
def foo(c: int = Field(default_factory=lambda: 20), a: int = Field(default_factory=lambda: 10, alias='b')):
return a + c
assert foo(b=10) == 30
def test_allow_population_by_field_name():
@validate_arguments(config=dict(allow_population_by_field_name=True))
def foo(a: Annotated[int, Field(alias='b')], c: Annotated[int, Field(alias='d')]):
return a + c
assert foo(a=10, d=1) == 11
assert foo(b=10, c=1) == 11
assert foo(a=10, c=1) == 11
def test_validate_all():
@validate_arguments(config=dict(validate_all=True))
def foo(dt: datetime = Field(default_factory=lambda: 946684800)):
return dt
assert foo() == datetime(2000, 1, 1, tzinfo=timezone.utc)
assert foo(0) == datetime(1970, 1, 1, tzinfo=timezone.utc)
@skip_pre_38
def test_validate_all_positional(create_module):
module = create_module(
# language=Python
"""
from datetime import datetime
from pydantic import Field, validate_arguments
@validate_arguments(config=dict(validate_all=True))
def foo(dt: datetime = Field(default_factory=lambda: 946684800), /):
return dt
"""
)
assert module.foo() == datetime(2000, 1, 1, tzinfo=timezone.utc)
assert module.foo(0) == datetime(1970, 1, 1, tzinfo=timezone.utc)
def test_validate_extra():
class TypedTest(TypedDict):
y: str
@validate_arguments(config={'extra': Extra.allow})
def test(other: TypedTest):
return other
assert test(other={'y': 'b', 'z': 'a'}) == {'y': 'b', 'z': 'a'}
@validate_arguments(config={'extra': Extra.ignore})
def test(other: TypedTest):
return other
assert test(other={'y': 'b', 'z': 'a'}) == {'y': 'b'}