From 9c4860ce964a4eb2e22eedc21f21d406c596a82f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sun, 6 Sep 2020 23:17:52 +0100 Subject: [PATCH] Valdiate arguments config (#1663) * add `configs` to validate_arguments * simplify `validate_arguments` and add annotation for parameter `configs` * change double quotes to single quotes * reformat code * fix mypy error * fix mypy 'maximum semantic analysis' error * rename 'configs' > 'config_params' * change name and usage, start tests * prevent setting fields on custom config * add docs and fix mypy * tweak docs * add change Co-authored-by: quantpy --- changes/1663-samuelcolvin.md | 1 + docs/examples/validation_decorator_config.py | 26 +++++++++ docs/usage/validation_decorator.md | 21 +++++++- pydantic/decorator.py | 51 +++++++++++++----- setup.cfg | 1 - tests/test_decorator.py | 55 ++++++++++++++++++++ 6 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 changes/1663-samuelcolvin.md create mode 100644 docs/examples/validation_decorator_config.py Index: pydantic-1.6.1/changes/1663-samuelcolvin.md =================================================================== --- /dev/null +++ pydantic-1.6.1/changes/1663-samuelcolvin.md @@ -0,0 +1 @@ +add `config` to `@validate_arguments` Index: pydantic-1.6.1/docs/examples/validation_decorator_config.py =================================================================== --- /dev/null +++ pydantic-1.6.1/docs/examples/validation_decorator_config.py @@ -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) Index: pydantic-1.6.1/docs/usage/validation_decorator.md =================================================================== --- pydantic-1.6.1.orig/docs/usage/validation_decorator.md +++ pydantic-1.6.1/docs/usage/validation_decorator.md @@ -78,8 +78,23 @@ _(This script is complete, it should run ```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 @@ -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 Index: pydantic-1.6.1/pydantic/decorator.py =================================================================== --- pydantic-1.6.1.orig/pydantic/decorator.py +++ pydantic-1.6.1/pydantic/decorator.py @@ -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 @@ -12,22 +12,30 @@ if TYPE_CHECKING: 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) - - 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) + def validate(_func: 'Callable') -> 'Callable': + vd = ValidatedFunction(_func, config) + + @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' @@ -36,7 +44,7 @@ V_POSITIONAL_ONLY_NAME = 'v__positional_ class ValidatedFunction: - def __init__(self, function: 'Callable'): + def __init__(self, function: 'Callable', config: 'ConfigType'): # noqa C901 from inspect import signature, Parameter parameters: Mapping[str, Parameter] = signature(function).parameters @@ -100,7 +108,7 @@ class ValidatedFunction: # 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) @@ -170,9 +178,24 @@ class ValidatedFunction: 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]: @@ -196,7 +219,7 @@ class ValidatedFunction: 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) Index: pydantic-1.6.1/setup.cfg =================================================================== --- pydantic-1.6.1.orig/setup.cfg +++ pydantic-1.6.1/setup.cfg @@ -1,6 +1,5 @@ [tool:pytest] testpaths = tests -timeout = 10 filterwarnings = error ignore::DeprecationWarning:distutils Index: pydantic-1.6.1/tests/test_decorator.py =================================================================== --- pydantic-1.6.1.orig/tests/test_decorator.py +++ pydantic-1.6.1/tests/test_decorator.py @@ -70,6 +70,7 @@ def test_wrap(): 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)) == '' @@ -262,3 +263,57 @@ def test_class_method(): {'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'}, + }, + ]