from abc import ABC, abstractmethod
from dataclasses import Field, MISSING
from typing import (Any, Type, Dict, Tuple, ClassVar,
Optional, Union, Iterable, Callable, Collection, Sequence)
from .constants import PACKAGE_NAME
from .utils.string_conv import normalize
# added as we can't import from `type_def`, as we run into a circular import.
JSONObject = Dict[str, Any]
[docs]
def type_name(obj: type) -> str:
"""Return the type or class name of an object"""
from .utils.typing_compat import is_generic
# for type generics like `dict[str, float]`, we want to return
# the subscripted value as is, rather than simply accessing the
# `__name__` property, which in this case would be `dict` instead.
if is_generic(obj):
return str(obj)
return getattr(obj, '__qualname__', getattr(obj, '__name__', repr(obj)))
[docs]
def show_deprecation_warning(
fn: 'Callable | str',
reason: str,
fmt: str = "Deprecated function {name} ({reason})."
) -> None:
"""
Display a deprecation warning for a given function.
@param fn: Function which is deprecated.
@param reason: Reason for the deprecation.
@param fmt: Format string for the name/reason.
"""
import warnings
warnings.simplefilter('always', DeprecationWarning)
warnings.warn(
fmt.format(name=getattr(fn, '__name__', fn), reason=reason),
category=DeprecationWarning,
stacklevel=2,
)
[docs]
class JSONWizardError(ABC, Exception):
"""
Base error class, for errors raised by this library.
"""
_TEMPLATE: ClassVar[str]
@property
def class_name(self) -> Optional[str]:
return self._class_name or self._default_class_name
@class_name.setter
def class_name(self, cls: Optional[Type]):
# Set parent class for errors
self.parent_cls = cls
# Set class name
if getattr(self, '_class_name', None) is None:
# noinspection PyAttributeOutsideInit
self._class_name = self.name(cls)
@property
def parent_cls(self) -> Optional[type]:
return self._parent_cls
@parent_cls.setter
def parent_cls(self, cls: Optional[type]):
# noinspection PyAttributeOutsideInit
self._parent_cls = cls
[docs]
@staticmethod
def name(obj) -> str:
"""Return the type or class name of an object"""
# Uses short-circuiting with `or` to efficiently
# return the first valid name.
return (getattr(obj, '__qualname__', None)
or getattr(obj, '__name__', None)
or str(obj))
@property
@abstractmethod
def message(self) -> str:
"""
Format and return an error message.
"""
def __str__(self):
return self.message
[docs]
class ParseError(JSONWizardError):
"""
Base error when an error occurs during the JSON load process.
"""
_TEMPLATE = ('Failure parsing field `{field}` in class `{cls}`. Expected '
'a type {ann_type}, got {obj_type}.\n'
' value: {o!r}\n'
' error: {e!s}')
def __init__(self, base_err: Exception,
obj: Any,
ann_type: Optional[Union[Type, Iterable]],
_default_class: Optional[type] = None,
_field_name: Optional[str] = None,
_json_object: Any = None,
**kwargs):
super().__init__()
self.obj = obj
self.obj_type = type(obj)
self.ann_type = ann_type
self.base_error = base_err
self.kwargs = kwargs
self._class_name = None
self._default_class_name = self.name(_default_class) \
if _default_class else None
self._field_name = _field_name
self._json_object = _json_object
self.fields = None
@property
def field_name(self) -> Optional[str]:
return self._field_name
@field_name.setter
def field_name(self, name: Optional[str]):
if self._field_name is None:
self._field_name = name
@property
def json_object(self):
return self._json_object
@json_object.setter
def json_object(self, json_obj):
if self._json_object is None:
self._json_object = json_obj
@property
def message(self) -> str:
ann_type = self.name(
self.ann_type if self.ann_type is not None
else next((f.type for f in self.fields
if f.name == self._field_name), None))
msg = self._TEMPLATE.format(
cls=self.class_name, field=self.field_name,
e=self.base_error, o=self.obj,
ann_type=ann_type,
obj_type=self.name(self.obj_type))
if self.json_object:
from .utils.json_util import safe_dumps
self.kwargs['json_object'] = safe_dumps(self.json_object)
if self.kwargs:
sep = '\n '
parts = sep.join(f'{k}: {v!r}' for k, v in self.kwargs.items())
msg = f'{msg}{sep}{parts}'
return msg
[docs]
class MissingFields(JSONWizardError):
"""
Error raised when unable to create a class instance (most likely due to
missing arguments)
"""
_TEMPLATE = ('`{cls}.__init__()` missing required fields.\n'
' Provided: {fields!r}\n'
' Missing: {missing_fields!r}\n'
'{expected_keys}'
' Input JSON: {json_string}'
'{e}')
def __init__(self, base_err: 'Exception | None',
obj: JSONObject,
cls: Type,
cls_fields: Tuple[Field, ...],
cls_kwargs: 'JSONObject | None' = None,
missing_fields: 'Collection[str] | None' = None,
missing_keys: 'Collection[str] | None' = None,
**kwargs):
super().__init__()
self.obj = obj
if missing_fields:
self.fields = [f.name for f in cls_fields
if f.name not in missing_fields
and f.default is MISSING
and f.default_factory is MISSING]
self.missing_fields = missing_fields
else:
self.fields = list(cls_kwargs.keys())
self.missing_fields = [f.name for f in cls_fields
if f.name not in self.fields
and f.default is MISSING
and f.default_factory is MISSING]
self.base_error = base_err
self.missing_keys = missing_keys
self.kwargs = kwargs
self.class_name: str = self.name(cls)
self.parent_cls = cls
self.all_fields = cls_fields
@property
def message(self) -> str:
from .class_helper import get_meta
from .utils.json_util import safe_dumps
# need to determine this, as we can't
# directly import `class_helper.py`
meta = get_meta(self.parent_cls)
v1 = meta.v1
if isinstance(self.obj, list):
keys = [f.name for f in self.all_fields]
obj = dict(zip(keys, self.obj))
else:
obj = self.obj
# check if any field names match, and where the key transform could be the cause
# see https://github.com/rnag/dataclass-wizard/issues/54 for more info
normalized_json_keys = [normalize(key) for key in obj]
if next((f for f in self.missing_fields if normalize(f) in normalized_json_keys), None):
from .enums import LetterCase
from .v1.enums import KeyCase
from .loader_selection import get_loader
key_transform = get_loader(self.parent_cls).transform_json_field
if isinstance(key_transform, (LetterCase, KeyCase)):
if key_transform.value is None:
key_transform = f'{key_transform.name}'
else:
key_transform = f'{key_transform.value.f.__name__}()'
elif key_transform is not None:
key_transform = f'{getattr(key_transform, "__name__", key_transform)}()'
self.kwargs['Key Transform'] = key_transform
self.kwargs['Resolution'] = 'For more details, please see https://github.com/rnag/dataclass-wizard/issues/54'
if v1:
self.kwargs['Resolution'] = ('Ensure that all required fields are provided in the input. '
'For more details, see:\n'
' https://github.com/rnag/dataclass-wizard/discussions/167')
if self.base_error is not None:
e = f'\n error: {self.base_error!s}'
else:
e = ''
if self.missing_keys is not None:
expected_keys = f' Expected Keys: {self.missing_keys!r}\n'
else:
expected_keys = ''
msg = self._TEMPLATE.format(
cls=self.class_name,
json_string=safe_dumps(self.obj),
e=e,
fields=self.fields,
expected_keys=expected_keys,
missing_fields=self.missing_fields)
if self.kwargs:
sep = '\n '
parts = sep.join(f'{k}: {v}' for k, v in self.kwargs.items())
msg = f'{msg}{sep}{parts}'
return msg
[docs]
class UnknownKeysError(JSONWizardError):
"""
Error raised when unknown JSON key(s) are
encountered in the JSON load process.
Note that this error class is only raised when the
`raise_on_unknown_json_key` flag is enabled in
the :class:`Meta` class.
"""
_TEMPLATE = ('One or more JSON keys are not mapped to the dataclass schema for class `{cls}`.\n'
' Unknown key{s}: {unknown_keys!r}\n'
' Dataclass fields: {fields!r}\n'
' Input JSON object: {json_string}')
def __init__(self,
unknown_keys: 'list[str] | str',
obj: JSONObject,
cls: Type,
cls_fields: Tuple[Field, ...], **kwargs):
super().__init__()
self.unknown_keys = unknown_keys
self.obj = obj
self.fields = [f.name for f in cls_fields]
self.kwargs = kwargs
self.class_name: str = self.name(cls)
@property
def json_key(self):
show_deprecation_warning(
UnknownKeysError.json_key.fget,
'use `unknown_keys` instead',
)
return self.unknown_keys
@property
def message(self) -> str:
from .utils.json_util import safe_dumps
if not isinstance(self.unknown_keys, str) and len(self.unknown_keys) > 1:
s = 's'
else:
s = ''
msg = self._TEMPLATE.format(
cls=self.class_name,
s=s,
json_string=safe_dumps(self.obj),
fields=self.fields,
unknown_keys=self.unknown_keys)
if self.kwargs:
sep = '\n '
parts = sep.join(f'{k}: {v!r}' for k, v in self.kwargs.items())
msg = f'{msg}{sep}{parts}'
return msg
# Alias for backwards-compatibility.
UnknownJSONKey = UnknownKeysError
[docs]
class MissingData(ParseError):
"""
Error raised when unable to create a class instance, as the JSON object
is None.
"""
_TEMPLATE = ('Failure loading class `{cls}`. '
'Missing value for field (expected a dict, got None)\n'
' dataclass field: {field!r}\n'
' resolution: annotate the field as '
'`Optional[{nested_cls}]` or `{nested_cls} | None`')
def __init__(self, nested_cls: Type, **kwargs):
super().__init__(self, None, nested_cls, **kwargs)
self.nested_class_name: str = self.name(nested_cls)
# self.nested_class_name: str = type_name(nested_cls)
@property
def message(self) -> str:
from .utils.json_util import safe_dumps
msg = self._TEMPLATE.format(
cls=self.class_name,
nested_cls=self.nested_class_name,
json_string=safe_dumps(self.obj),
field=self.field_name,
o=self.obj,
)
if self.kwargs:
sep = '\n '
parts = sep.join(f'{k}: {v!r}' for k, v in self.kwargs.items())
msg = f'{msg}{sep}{parts}'
return msg
[docs]
class RecursiveClassError(JSONWizardError):
"""
Error raised when we encounter a `RecursionError` due to cyclic
or self-referential dataclasses.
"""
_TEMPLATE = ('Failure parsing class `{cls}`. '
'Consider updating the Meta config to enable '
'the `recursive_classes` flag.\n\n'
f'Example with `{PACKAGE_NAME}.LoadMeta`:\n'
' >>> LoadMeta(recursive_classes=True).bind_to({cls})\n\n'
'For more info, please see:\n'
' https://github.com/rnag/dataclass-wizard/issues/62')
def __init__(self, cls: Type):
super().__init__()
self.class_name: str = self.name(cls)
@property
def message(self) -> str:
return self._TEMPLATE.format(cls=self.class_name)
[docs]
class InvalidConditionError(JSONWizardError):
"""
Error raised when a condition is not wrapped in ``SkipIf``.
"""
_TEMPLATE = ('Failure parsing annotations for class `{cls}`. '
'Field has an invalid condition.\n'
' dataclass field: {field!r}\n'
' resolution: Wrap conditions inside SkipIf().`')
def __init__(self, cls: Type, field_name: str):
super().__init__()
self.class_name: str = self.name(cls)
self.field_name: str = field_name
@property
def message(self) -> str:
return self._TEMPLATE.format(cls=self.class_name,
field=self.field_name)
[docs]
class MissingVars(JSONWizardError):
"""
Error raised when unable to create an instance of a EnvWizard subclass
(most likely due to missing environment variables in the Environment)
"""
_TEMPLATE = ('\n`{cls}` has {prefix} missing in the environment:\n'
'{fields}\n\n'
'**Resolution options**\n\n'
'1. Set a default value for the field:\n\n'
'{def_resolution}'
'\n\n'
'2. Provide the value during initialization:\n\n'
' {init_resolution}')
def __init__(self,
cls: Type,
missing_vars: Sequence[Tuple[str, 'str | None', str, Any]]):
super().__init__()
indent = ' ' * 4
# - `name` (mapped to `CUSTOM_A_NAME`)
self.class_name: str = type_name(cls)
self.fields = '\n'.join([f'{indent}- {f[0]} -> {f[1]}' for f in missing_vars])
self.def_resolution = '\n'.join([f'{indent}class {self.class_name}:'] +
[f'{indent * 2}{f}: {typ} = {default!r}'
for (f, _, typ, default) in missing_vars])
init_vars = ', '.join([f'{f}={default!r}' for (f, _, typ, default) in missing_vars])
self.init_resolution = f'instance = {self.class_name}({init_vars})'
num_fields = len(missing_vars)
self.prefix = f'{len(missing_vars)} required field{"s" if num_fields > 1 else ""}'
@property
def message(self) -> str:
msg = self._TEMPLATE.format(
cls=self.class_name,
prefix=self.prefix,
fields=self.fields,
def_resolution=self.def_resolution,
init_resolution=self.init_resolution,
)
return msg