python-pydantic/validate-config.patch

305 lines
10 KiB
Diff
Raw Normal View History

From 9c4860ce964a4eb2e22eedc21f21d406c596a82f Mon Sep 17 00:00:00 2001
From: Samuel Colvin <s@muelcolvin.com>
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 <quantpy@qq.com>
---
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)) == '<Signature (a: int, b: int)>'
@@ -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'},
+ },
+ ]