Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Valdiate arguments config #1663

Merged
merged 14 commits into from
Sep 6, 2020
1 change: 1 addition & 0 deletions changes/1663-samuelcolvin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
add `config` to `@validate_arguments`
26 changes: 26 additions & 0 deletions docs/examples/validation_decorator_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from pydantic import ValidationError, validate_arguments


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_arguments(config=dict(arbitrary_types_allowed=True))
def add_foobars(a: Foobar, b: Foobar):
return a + b


c = add_foobars(Foobar('a'), Foobar('b'))
print(c)

try:
add_foobars(1, 2)
except ValidationError as e:
print(e)
21 changes: 19 additions & 2 deletions docs/usage/validation_decorator.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,23 @@ _(This script is complete, it should run "as is")_
```py
{!.tmp_examples/validation_decorator_async.py!}
```
_(This script is complete, it should run "as is")_

## Custom Config

The model behind `validate_arguments` can be customised using a config setting which is equivalent to
setting the `Config` sub-class in normal models.

!!! warning
The `fields` and `alias_generator` properties of `Config` which allow aliases to be configured are not supported
yet with `@validate_arguments`, using them will raise an error.

Configuration is set using the `config` keyword argument to the decorator, it may be either a config class
or a dict of properties which are converted to a class later.

```py
{!.tmp_examples/validation_decorator_config.py!}
```
_(This script is complete, it should run "as is")_

## Limitations

Expand Down Expand Up @@ -126,7 +141,9 @@ in future.

### Config and Validators

Custom [`Config`](model_config.md) and [validators](validators.md) are not yet supported.
`fields` and `alias_generator` on custom [`Config`](model_config.md) are not supported, see [above](#custom-config).

Neither are [validators](validators.md).

### Model fields and reserved arguments

Expand Down
51 changes: 37 additions & 14 deletions pydantic/decorator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Tuple, TypeVar, cast, get_type_hints
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Tuple, Type, TypeVar, Union, cast, get_type_hints

from . import validator
from .errors import ConfigError
Expand All @@ -12,22 +12,30 @@
from .typing import AnyCallable

Callable = TypeVar('Callable', bound=AnyCallable)
ConfigType = Union[None, Type[Any], Dict[str, Any]]


def validate_arguments(function: 'Callable') -> 'Callable':
def validate_arguments(func: 'Callable' = None, *, config: 'ConfigType' = None) -> 'Callable':
"""
Decorator to validate the arguments passed to a function.
"""
vd = ValidatedFunction(function)

@wraps(function)
def wrapper_function(*args: Any, **kwargs: Any) -> Any:
return vd.call(*args, **kwargs)
def validate(_func: 'Callable') -> 'Callable':
vd = ValidatedFunction(_func, config)

wrapper_function.vd = vd # type: ignore
wrapper_function.raw_function = vd.raw_function # type: ignore
wrapper_function.model = vd.model # type: ignore
return cast('Callable', wrapper_function)
@wraps(_func)
def wrapper_function(*args: Any, **kwargs: Any) -> Any:
return vd.call(*args, **kwargs)

wrapper_function.vd = vd # type: ignore
wrapper_function.raw_function = vd.raw_function # type: ignore
wrapper_function.model = vd.model # type: ignore
return cast('Callable', wrapper_function)

if func:
return validate(func)
else:
return cast('Callable', validate)


ALT_V_ARGS = 'v__args'
Expand All @@ -36,7 +44,7 @@ def wrapper_function(*args: Any, **kwargs: Any) -> Any:


class ValidatedFunction:
def __init__(self, function: 'Callable'):
def __init__(self, function: 'Callable', config: 'ConfigType'): # noqa C901
from inspect import Parameter, signature

parameters: Mapping[str, Parameter] = signature(function).parameters
Expand Down Expand Up @@ -100,7 +108,7 @@ def __init__(self, function: 'Callable'):
# same with kwargs
fields[self.v_kwargs_name] = Dict[Any, Any], None

self.create_model(fields, takes_args, takes_kwargs)
self.create_model(fields, takes_args, takes_kwargs, config)

def call(self, *args: Any, **kwargs: Any) -> Any:
values = self.build_values(args, kwargs)
Expand Down Expand Up @@ -170,9 +178,24 @@ def execute(self, m: BaseModel) -> Any:
else:
return self.raw_function(**d)

def create_model(self, fields: Dict[str, Any], takes_args: bool, takes_kwargs: bool) -> None:
def create_model(self, fields: Dict[str, Any], takes_args: bool, takes_kwargs: bool, config: 'ConfigType') -> None:
pos_args = len(self.arg_mapping)

class CustomConfig:
pass

if not TYPE_CHECKING: # pragma: no branch
if isinstance(config, dict):
CustomConfig = type('Config', (), config) # noqa: F811
elif config is not None:
CustomConfig = config # noqa: F811

if hasattr(CustomConfig, 'fields') or hasattr(CustomConfig, 'alias_generator'):
raise ConfigError(
'Setting the "fields" and "alias_generator" property on custom Config for '
'@validate_arguments is not yet supported, please remove.'
)

class DecoratorBaseModel(BaseModel):
@validator(self.v_args_name, check_fields=False, allow_reuse=True)
def check_args(cls, v: List[Any]) -> List[Any]:
Expand All @@ -196,7 +219,7 @@ def check_positional_only(cls, v: List[str]) -> None:
keys = ', '.join(map(repr, v))
raise TypeError(f'positional-only argument{plural} passed as keyword argument{plural}: {keys}')

class Config:
class Config(CustomConfig):
extra = Extra.forbid

self.model = create_model(to_camel(self.raw_function.__name__), __base__=DecoratorBaseModel, **fields)
1 change: 0 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
[tool:pytest]
testpaths = tests
timeout = 10
filterwarnings =
error
ignore::DeprecationWarning:distutils
Expand Down
55 changes: 55 additions & 0 deletions tests/test_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def foo_bar(a: int, b: int):
assert issubclass(foo_bar.model, BaseModel)
assert foo_bar.model.__fields__.keys() == {'a', 'b', 'args', 'kwargs'}
assert foo_bar.model.__name__ == 'FooBar'
assert foo_bar.model.schema()['title'] == 'FooBar'
# signature is slightly different on 3.6
if sys.version_info >= (3, 7):
assert repr(inspect.signature(foo_bar)) == '<Signature (a: int, b: int)>'
Expand Down Expand Up @@ -262,3 +263,57 @@ def foo(cls, a: int, b: int):
{'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'},
},
]