Skip to content

Commit

Permalink
change default and support 3.6
Browse files Browse the repository at this point in the history
  • Loading branch information
PrettyWood committed Mar 28, 2021
1 parent 9488c55 commit 0acde5b
Show file tree
Hide file tree
Showing 3 changed files with 53 additions and 20 deletions.
58 changes: 42 additions & 16 deletions pydantic/dataclasses.py
@@ -1,21 +1,22 @@
"""
The main purpose is to enhance stdlib dataclasses by adding validation
We also want to keep the dataclass untouched to still support the default hashing,
equality, repr, ...
This means we **don't want to create a new dataclass that inherits from it**
A pydantic dataclass can be generated from scratch or from a stdlib one.
To make this happen, we first attach a `BaseModel` to the dataclass
and magic methods to trigger the validation of the data.
Behind the scene, a pydantic dataclass is just like a regular one on which we attach
a `BaseModel` and magic methods to trigger the validation of the data.
The biggest problem is when a pydantic dataclass is generated from an existing stdlib one.
We indeed still want to support equality, hashing, repr, ... as if it was the stdlib one!
This means we **don't want to create a new dataclass that inherits from it**
Now the problem is: for a stdlib dataclass `Item` that now has magic attributes for pydantic
how can we have a new class `ValidatedItem` to trigger validation by default and keep `Item`
behaviour untouched!
behaviour untouched?
To do this `ValidatedItem` will in fact be an instance of `DataclassProxy`, a simple wrapper
around `Item` that acts like a proxy to trigger validation.
This wrapper will just inject an extra kwarg `__pydantic_run_validation__` for `ValidatedItem`
and not for `Item`! (Note that this can always be injected "a la mano" if needed)
"""
import sys
from functools import partial, wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union, overload

Expand Down Expand Up @@ -108,12 +109,26 @@ def dataclass(
def wrap(cls: Type[Any]) -> DataclassProxy:
import dataclasses

cls = dataclasses.dataclass( # type: ignore
cls, init=init, repr=repr, eq=eq, order=order, unsafe_hash=unsafe_hash, frozen=frozen
)
_add_pydantic_validation_attributes(cls, config)
if is_builtin_dataclass(cls):
# we don't want to overwrite default behaviour of a stdlib dataclass
# But with python 3.6 we can't use a simple wrapper that acts like a pure proxy
# because this proxy also needs to forward inheritance and that is achieved
# thanks to `__mro_entries__` that was only added in 3.7
# The big downside is that we now have a side effect on our decorator
if sys.version_info[:2] == (3, 6):
_add_pydantic_validation_attributes(cls, config, validate_by_default=True)
return cls

_add_pydantic_validation_attributes(cls, config, validate_by_default=False)
return DataclassProxy(cls) # type: ignore[no-untyped-call]
else:

dc_cls = dataclasses.dataclass( # type: ignore
cls, init=init, repr=repr, eq=eq, order=order, unsafe_hash=unsafe_hash, frozen=frozen
)

return DataclassProxy(cls) # type: ignore[no-untyped-call]
_add_pydantic_validation_attributes(dc_cls, config, validate_by_default=True)
return dc_cls

if _cls is None:
return wrap
Expand All @@ -134,6 +149,9 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any:
def _add_pydantic_validation_attributes(
dc_cls: Type['Dataclass'],
config: Optional[Type['BaseConfig']],
# hack used for python 3.6 (see https://github.com/samuelcolvin/pydantic/pull/2557)
*,
validate_by_default: bool,
) -> None:
"""
We need to replace the right method. If no `__post_init__` has been set in the stdlib dataclass
Expand All @@ -145,15 +163,17 @@ def _add_pydantic_validation_attributes(
post_init = dc_cls.__post_init__

@wraps(init)
def new_init(self: 'Dataclass', *args: Any, __pydantic_run_validation__: bool = False, **kwargs: Any) -> None:
def new_init(
self: 'Dataclass', *args: Any, __pydantic_run_validation__: bool = validate_by_default, **kwargs: Any
) -> None:
self.__post_init__ = partial( # type: ignore[assignment]
self.__post_init__, __pydantic_run_validation__=__pydantic_run_validation__
)
init(self, *args, **kwargs)

@wraps(post_init)
def new_post_init(
self: 'Dataclass', *args: Any, __pydantic_run_validation__: bool = False, **kwargs: Any
self: 'Dataclass', *args: Any, __pydantic_run_validation__: bool = validate_by_default, **kwargs: Any
) -> None:
post_init(self, *args, **kwargs)
if __pydantic_run_validation__:
Expand All @@ -168,7 +188,9 @@ def new_post_init(
init = dc_cls.__init__

@wraps(init)
def new_init(self: 'Dataclass', *args: Any, __pydantic_run_validation__: bool = False, **kwargs: Any) -> None:
def new_init(
self: 'Dataclass', *args: Any, __pydantic_run_validation__: bool = validate_by_default, **kwargs: Any
) -> None:
init(self, *args, **kwargs)
if __pydantic_run_validation__:
self.__pydantic_validate_values__()
Expand Down Expand Up @@ -286,7 +308,11 @@ def is_builtin_dataclass(_cls: Type[Any]) -> bool:
"""
import dataclasses

return not hasattr(_cls, '__processed__') and dataclasses.is_dataclass(_cls)
return (
not hasattr(_cls, '__processed__')
and dataclasses.is_dataclass(_cls)
and set(_cls.__dataclass_fields__).issuperset(set(_cls.__annotations__))
)


def make_dataclass_validator(dc_cls: Type['Dataclass'], config: Type['BaseConfig']) -> 'CallableGenerator':
Expand Down
13 changes: 10 additions & 3 deletions tests/test_dataclasses.py
@@ -1,5 +1,6 @@
import dataclasses
import pickle
import sys
from collections.abc import Hashable
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -707,9 +708,15 @@ class Foo(BaseModel):

with pytest.raises(ValidationError) as e:
FileChecked(filename=b'thefilename', meta=Meta(modified_date='2020-01-01T00:00', seen_count=['7']))
assert e.value.errors() == [
{'loc': ('meta', 'seen_count'), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}
]

if sys.version_info[:2] == (3, 6):
assert e.value.errors() == [
{'loc': ('seen_count',), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}
]
else:
assert e.value.errors() == [
{'loc': ('meta', 'seen_count'), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}
]

foo = Foo.parse_obj(
{
Expand Down
2 changes: 1 addition & 1 deletion tests/test_validators_dataclass.py
Expand Up @@ -67,7 +67,7 @@ class MyDataclass:

@validator('a')
def check_a(cls, v):
assert cls is MyDataclass.__wrapped__ and is_dataclass(MyDataclass)
assert cls is MyDataclass and is_dataclass(MyDataclass)
return v

m = MyDataclass(a='this is foobar good')
Expand Down

0 comments on commit 0acde5b

Please sign in to comment.