1"""
2The main purpose is to enhance stdlib dataclasses by adding validation
3A pydantic dataclass can be generated from scratch or from a stdlib one.
4
5Behind the scene, a pydantic dataclass is just like a regular one on which we attach
6a `BaseModel` and magic methods to trigger the validation of the data.
7`__init__` and `__post_init__` are hence overridden and have extra logic to be
8able to validate input data.
9
10When a pydantic dataclass is generated from scratch, it's just a plain dataclass
11with validation triggered at initialization
12
13The tricky part if for stdlib dataclasses that are converted after into pydantic ones e.g.
14
15```py
16@dataclasses.dataclass
17class M:
18 x: int
19
20ValidatedM = pydantic.dataclasses.dataclass(M)
21```
22
23We indeed still want to support equality, hashing, repr, ... as if it was the stdlib one!
24
25```py
26assert isinstance(ValidatedM(x=1), M)
27assert ValidatedM(x=1) == M(x=1)
28```
29
30This means we **don't want to create a new dataclass that inherits from it**
31The trick is to create a wrapper around `M` that will act as a proxy to trigger
32validation without altering default `M` behaviour.
33"""
34import copy
35import dataclasses
36import sys
37from contextlib import contextmanager
38from functools import wraps
39
40try:
41 from functools import cached_property
42except ImportError:
43 # cached_property available only for python3.8+
44 pass
45
46from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Generator, Optional, Type, TypeVar, Union, overload
47
48from typing_extensions import dataclass_transform
49
50from pydantic.v1.class_validators import gather_all_validators
51from pydantic.v1.config import BaseConfig, ConfigDict, Extra, get_config
52from pydantic.v1.error_wrappers import ValidationError
53from pydantic.v1.errors import DataclassTypeError
54from pydantic.v1.fields import Field, FieldInfo, Required, Undefined
55from pydantic.v1.main import create_model, validate_model
56from pydantic.v1.utils import ClassAttribute
57
58if TYPE_CHECKING:
59 from pydantic.v1.main import BaseModel
60 from pydantic.v1.typing import CallableGenerator, NoArgAnyCallable
61
62 DataclassT = TypeVar('DataclassT', bound='Dataclass')
63
64 DataclassClassOrWrapper = Union[Type['Dataclass'], 'DataclassProxy']
65
66 class Dataclass:
67 # stdlib attributes
68 __dataclass_fields__: ClassVar[Dict[str, Any]]
69 __dataclass_params__: ClassVar[Any] # in reality `dataclasses._DataclassParams`
70 __post_init__: ClassVar[Callable[..., None]]
71
72 # Added by pydantic
73 __pydantic_run_validation__: ClassVar[bool]
74 __post_init_post_parse__: ClassVar[Callable[..., None]]
75 __pydantic_initialised__: ClassVar[bool]
76 __pydantic_model__: ClassVar[Type[BaseModel]]
77 __pydantic_validate_values__: ClassVar[Callable[['Dataclass'], None]]
78 __pydantic_has_field_info_default__: ClassVar[bool] # whether a `pydantic.Field` is used as default value
79
80 def __init__(self, *args: object, **kwargs: object) -> None:
81 pass
82
83 @classmethod
84 def __get_validators__(cls: Type['Dataclass']) -> 'CallableGenerator':
85 pass
86
87 @classmethod
88 def __validate__(cls: Type['DataclassT'], v: Any) -> 'DataclassT':
89 pass
90
91
92__all__ = [
93 'dataclass',
94 'set_validation',
95 'create_pydantic_model_from_dataclass',
96 'is_builtin_dataclass',
97 'make_dataclass_validator',
98]
99
100_T = TypeVar('_T')
101
102if sys.version_info >= (3, 10):
103
104 @dataclass_transform(field_specifiers=(dataclasses.field, Field))
105 @overload
106 def dataclass(
107 *,
108 init: bool = True,
109 repr: bool = True,
110 eq: bool = True,
111 order: bool = False,
112 unsafe_hash: bool = False,
113 frozen: bool = False,
114 config: Union[ConfigDict, Type[object], None] = None,
115 validate_on_init: Optional[bool] = None,
116 use_proxy: Optional[bool] = None,
117 kw_only: bool = ...,
118 ) -> Callable[[Type[_T]], 'DataclassClassOrWrapper']:
119 ...
120
121 @dataclass_transform(field_specifiers=(dataclasses.field, Field))
122 @overload
123 def dataclass(
124 _cls: Type[_T],
125 *,
126 init: bool = True,
127 repr: bool = True,
128 eq: bool = True,
129 order: bool = False,
130 unsafe_hash: bool = False,
131 frozen: bool = False,
132 config: Union[ConfigDict, Type[object], None] = None,
133 validate_on_init: Optional[bool] = None,
134 use_proxy: Optional[bool] = None,
135 kw_only: bool = ...,
136 ) -> 'DataclassClassOrWrapper':
137 ...
138
139else:
140
141 @dataclass_transform(field_specifiers=(dataclasses.field, Field))
142 @overload
143 def dataclass(
144 *,
145 init: bool = True,
146 repr: bool = True,
147 eq: bool = True,
148 order: bool = False,
149 unsafe_hash: bool = False,
150 frozen: bool = False,
151 config: Union[ConfigDict, Type[object], None] = None,
152 validate_on_init: Optional[bool] = None,
153 use_proxy: Optional[bool] = None,
154 ) -> Callable[[Type[_T]], 'DataclassClassOrWrapper']:
155 ...
156
157 @dataclass_transform(field_specifiers=(dataclasses.field, Field))
158 @overload
159 def dataclass(
160 _cls: Type[_T],
161 *,
162 init: bool = True,
163 repr: bool = True,
164 eq: bool = True,
165 order: bool = False,
166 unsafe_hash: bool = False,
167 frozen: bool = False,
168 config: Union[ConfigDict, Type[object], None] = None,
169 validate_on_init: Optional[bool] = None,
170 use_proxy: Optional[bool] = None,
171 ) -> 'DataclassClassOrWrapper':
172 ...
173
174
175@dataclass_transform(field_specifiers=(dataclasses.field, Field))
176def dataclass(
177 _cls: Optional[Type[_T]] = None,
178 *,
179 init: bool = True,
180 repr: bool = True,
181 eq: bool = True,
182 order: bool = False,
183 unsafe_hash: bool = False,
184 frozen: bool = False,
185 config: Union[ConfigDict, Type[object], None] = None,
186 validate_on_init: Optional[bool] = None,
187 use_proxy: Optional[bool] = None,
188 kw_only: bool = False,
189) -> Union[Callable[[Type[_T]], 'DataclassClassOrWrapper'], 'DataclassClassOrWrapper']:
190 """
191 Like the python standard lib dataclasses but with type validation.
192 The result is either a pydantic dataclass that will validate input data
193 or a wrapper that will trigger validation around a stdlib dataclass
194 to avoid modifying it directly
195 """
196 the_config = get_config(config)
197
198 def wrap(cls: Type[Any]) -> 'DataclassClassOrWrapper':
199 should_use_proxy = (
200 use_proxy
201 if use_proxy is not None
202 else (
203 is_builtin_dataclass(cls)
204 and (cls.__bases__[0] is object or set(dir(cls)) == set(dir(cls.__bases__[0])))
205 )
206 )
207 if should_use_proxy:
208 dc_cls_doc = ''
209 dc_cls = DataclassProxy(cls)
210 default_validate_on_init = False
211 else:
212 dc_cls_doc = cls.__doc__ or '' # needs to be done before generating dataclass
213 if sys.version_info >= (3, 10):
214 dc_cls = dataclasses.dataclass(
215 cls,
216 init=init,
217 repr=repr,
218 eq=eq,
219 order=order,
220 unsafe_hash=unsafe_hash,
221 frozen=frozen,
222 kw_only=kw_only,
223 )
224 else:
225 dc_cls = dataclasses.dataclass( # type: ignore
226 cls, init=init, repr=repr, eq=eq, order=order, unsafe_hash=unsafe_hash, frozen=frozen
227 )
228 default_validate_on_init = True
229
230 should_validate_on_init = default_validate_on_init if validate_on_init is None else validate_on_init
231 _add_pydantic_validation_attributes(cls, the_config, should_validate_on_init, dc_cls_doc)
232 localns = {cls.__name__: cls}
233 dc_cls.__pydantic_model__.__try_update_forward_refs__(**localns)
234 return dc_cls
235
236 if _cls is None:
237 return wrap
238
239 return wrap(_cls)
240
241
242@contextmanager
243def set_validation(cls: Type['DataclassT'], value: bool) -> Generator[Type['DataclassT'], None, None]:
244 original_run_validation = cls.__pydantic_run_validation__
245 try:
246 cls.__pydantic_run_validation__ = value
247 yield cls
248 finally:
249 cls.__pydantic_run_validation__ = original_run_validation
250
251
252class DataclassProxy:
253 __slots__ = '__dataclass__'
254
255 def __init__(self, dc_cls: Type['Dataclass']) -> None:
256 object.__setattr__(self, '__dataclass__', dc_cls)
257
258 def __call__(self, *args: Any, **kwargs: Any) -> Any:
259 with set_validation(self.__dataclass__, True):
260 return self.__dataclass__(*args, **kwargs)
261
262 def __getattr__(self, name: str) -> Any:
263 return getattr(self.__dataclass__, name)
264
265 def __setattr__(self, __name: str, __value: Any) -> None:
266 return setattr(self.__dataclass__, __name, __value)
267
268 def __instancecheck__(self, instance: Any) -> bool:
269 return isinstance(instance, self.__dataclass__)
270
271 def __copy__(self) -> 'DataclassProxy':
272 return DataclassProxy(copy.copy(self.__dataclass__))
273
274 def __deepcopy__(self, memo: Any) -> 'DataclassProxy':
275 return DataclassProxy(copy.deepcopy(self.__dataclass__, memo))
276
277
278def _add_pydantic_validation_attributes( # noqa: C901 (ignore complexity)
279 dc_cls: Type['Dataclass'],
280 config: Type[BaseConfig],
281 validate_on_init: bool,
282 dc_cls_doc: str,
283) -> None:
284 """
285 We need to replace the right method. If no `__post_init__` has been set in the stdlib dataclass
286 it won't even exist (code is generated on the fly by `dataclasses`)
287 By default, we run validation after `__init__` or `__post_init__` if defined
288 """
289 init = dc_cls.__init__
290
291 @wraps(init)
292 def handle_extra_init(self: 'Dataclass', *args: Any, **kwargs: Any) -> None:
293 if config.extra == Extra.ignore:
294 init(self, *args, **{k: v for k, v in kwargs.items() if k in self.__dataclass_fields__})
295
296 elif config.extra == Extra.allow:
297 for k, v in kwargs.items():
298 # internal attributes are not valid extra fields, and should not be set from user input:
299 if not k.startswith('__pydantic'):
300 self.__dict__.setdefault(k, v)
301 init(self, *args, **{k: v for k, v in kwargs.items() if k in self.__dataclass_fields__})
302
303 else:
304 init(self, *args, **kwargs)
305
306 if hasattr(dc_cls, '__post_init__'):
307 try:
308 post_init = dc_cls.__post_init__.__wrapped__ # type: ignore[attr-defined]
309 except AttributeError:
310 post_init = dc_cls.__post_init__
311
312 @wraps(post_init)
313 def new_post_init(self: 'Dataclass', *args: Any, **kwargs: Any) -> None:
314 if config.post_init_call == 'before_validation':
315 post_init(self, *args, **kwargs)
316
317 if self.__class__.__pydantic_run_validation__:
318 self.__pydantic_validate_values__()
319 if hasattr(self, '__post_init_post_parse__'):
320 self.__post_init_post_parse__(*args, **kwargs)
321
322 if config.post_init_call == 'after_validation':
323 post_init(self, *args, **kwargs)
324
325 setattr(dc_cls, '__init__', handle_extra_init)
326 setattr(dc_cls, '__post_init__', new_post_init)
327
328 else:
329
330 @wraps(init)
331 def new_init(self: 'Dataclass', *args: Any, **kwargs: Any) -> None:
332 handle_extra_init(self, *args, **kwargs)
333
334 if self.__class__.__pydantic_run_validation__:
335 self.__pydantic_validate_values__()
336
337 if hasattr(self, '__post_init_post_parse__'):
338 # We need to find again the initvars. To do that we use `__dataclass_fields__` instead of
339 # public method `dataclasses.fields`
340
341 # get all initvars and their default values
342 initvars_and_values: Dict[str, Any] = {}
343 for i, f in enumerate(self.__class__.__dataclass_fields__.values()):
344 if f._field_type is dataclasses._FIELD_INITVAR: # type: ignore[attr-defined]
345 try:
346 # set arg value by default
347 initvars_and_values[f.name] = args[i]
348 except IndexError:
349 initvars_and_values[f.name] = kwargs.get(f.name, f.default)
350
351 self.__post_init_post_parse__(**initvars_and_values)
352
353 setattr(dc_cls, '__init__', new_init)
354
355 setattr(dc_cls, '__pydantic_run_validation__', ClassAttribute('__pydantic_run_validation__', validate_on_init))
356 setattr(dc_cls, '__pydantic_initialised__', False)
357 setattr(dc_cls, '__pydantic_model__', create_pydantic_model_from_dataclass(dc_cls, config, dc_cls_doc))
358 setattr(dc_cls, '__pydantic_validate_values__', _dataclass_validate_values)
359 setattr(dc_cls, '__validate__', classmethod(_validate_dataclass))
360 setattr(dc_cls, '__get_validators__', classmethod(_get_validators))
361
362 if dc_cls.__pydantic_model__.__config__.validate_assignment and not dc_cls.__dataclass_params__.frozen:
363 setattr(dc_cls, '__setattr__', _dataclass_validate_assignment_setattr)
364
365
366def _get_validators(cls: 'DataclassClassOrWrapper') -> 'CallableGenerator':
367 yield cls.__validate__
368
369
370def _validate_dataclass(cls: Type['DataclassT'], v: Any) -> 'DataclassT':
371 with set_validation(cls, True):
372 if isinstance(v, cls):
373 v.__pydantic_validate_values__()
374 return v
375 elif isinstance(v, (list, tuple)):
376 return cls(*v)
377 elif isinstance(v, dict):
378 return cls(**v)
379 else:
380 raise DataclassTypeError(class_name=cls.__name__)
381
382
383def create_pydantic_model_from_dataclass(
384 dc_cls: Type['Dataclass'],
385 config: Type[Any] = BaseConfig,
386 dc_cls_doc: Optional[str] = None,
387) -> Type['BaseModel']:
388 field_definitions: Dict[str, Any] = {}
389 for field in dataclasses.fields(dc_cls):
390 default: Any = Undefined
391 default_factory: Optional['NoArgAnyCallable'] = None
392 field_info: FieldInfo
393
394 if field.default is not dataclasses.MISSING:
395 default = field.default
396 elif field.default_factory is not dataclasses.MISSING:
397 default_factory = field.default_factory
398 else:
399 default = Required
400
401 if isinstance(default, FieldInfo):
402 field_info = default
403 dc_cls.__pydantic_has_field_info_default__ = True
404 else:
405 field_info = Field(default=default, default_factory=default_factory, **field.metadata)
406
407 field_definitions[field.name] = (field.type, field_info)
408
409 validators = gather_all_validators(dc_cls)
410 model: Type['BaseModel'] = create_model(
411 dc_cls.__name__,
412 __config__=config,
413 __module__=dc_cls.__module__,
414 __validators__=validators,
415 __cls_kwargs__={'__resolve_forward_refs__': False},
416 **field_definitions,
417 )
418 model.__doc__ = dc_cls_doc if dc_cls_doc is not None else dc_cls.__doc__ or ''
419 return model
420
421
422if sys.version_info >= (3, 8):
423
424 def _is_field_cached_property(obj: 'Dataclass', k: str) -> bool:
425 return isinstance(getattr(type(obj), k, None), cached_property)
426
427else:
428
429 def _is_field_cached_property(obj: 'Dataclass', k: str) -> bool:
430 return False
431
432
433def _dataclass_validate_values(self: 'Dataclass') -> None:
434 # validation errors can occur if this function is called twice on an already initialised dataclass.
435 # for example if Extra.forbid is enabled, it would consider __pydantic_initialised__ an invalid extra property
436 if getattr(self, '__pydantic_initialised__'):
437 return
438 if getattr(self, '__pydantic_has_field_info_default__', False):
439 # We need to remove `FieldInfo` values since they are not valid as input
440 # It's ok to do that because they are obviously the default values!
441 input_data = {
442 k: v
443 for k, v in self.__dict__.items()
444 if not (isinstance(v, FieldInfo) or _is_field_cached_property(self, k))
445 }
446 else:
447 input_data = {k: v for k, v in self.__dict__.items() if not _is_field_cached_property(self, k)}
448 d, _, validation_error = validate_model(self.__pydantic_model__, input_data, cls=self.__class__)
449 if validation_error:
450 raise validation_error
451 self.__dict__.update(d)
452 object.__setattr__(self, '__pydantic_initialised__', True)
453
454
455def _dataclass_validate_assignment_setattr(self: 'Dataclass', name: str, value: Any) -> None:
456 if self.__pydantic_initialised__:
457 d = dict(self.__dict__)
458 d.pop(name, None)
459 known_field = self.__pydantic_model__.__fields__.get(name, None)
460 if known_field:
461 value, error_ = known_field.validate(value, d, loc=name, cls=self.__class__)
462 if error_:
463 raise ValidationError([error_], self.__class__)
464
465 object.__setattr__(self, name, value)
466
467
468def is_builtin_dataclass(_cls: Type[Any]) -> bool:
469 """
470 Whether a class is a stdlib dataclass
471 (useful to discriminated a pydantic dataclass that is actually a wrapper around a stdlib dataclass)
472
473 we check that
474 - `_cls` is a dataclass
475 - `_cls` is not a processed pydantic dataclass (with a basemodel attached)
476 - `_cls` is not a pydantic dataclass inheriting directly from a stdlib dataclass
477 e.g.
478 ```
479 @dataclasses.dataclass
480 class A:
481 x: int
482
483 @pydantic.dataclasses.dataclass
484 class B(A):
485 y: int
486 ```
487 In this case, when we first check `B`, we make an extra check and look at the annotations ('y'),
488 which won't be a superset of all the dataclass fields (only the stdlib fields i.e. 'x')
489 """
490 return (
491 dataclasses.is_dataclass(_cls)
492 and not hasattr(_cls, '__pydantic_model__')
493 and set(_cls.__dataclass_fields__).issuperset(set(getattr(_cls, '__annotations__', {})))
494 )
495
496
497def make_dataclass_validator(dc_cls: Type['Dataclass'], config: Type[BaseConfig]) -> 'CallableGenerator':
498 """
499 Create a pydantic.dataclass from a builtin dataclass to add type validation
500 and yield the validators
501 It retrieves the parameters of the dataclass and forwards them to the newly created dataclass
502 """
503 yield from _get_validators(dataclass(dc_cls, config=config, use_proxy=True))