from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Collection, Iterable, Sequence
from dataclasses import MISSING, Field, is_dataclass
from datetime import date, datetime, time
from enum import Enum
from json import JSONEncoder, dumps
from typing import Any, Callable, ClassVar
from uuid import UUID
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]
_SafeEncoder = None
[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,
)
def _get_safe_encoder() -> type[JSONEncoder]:
from ._dumpers import asdict
global _SafeEncoder
if _SafeEncoder is not None:
return _SafeEncoder
class _LocalSafeEncoder(JSONEncoder):
def default(self, o: Any) -> Any:
if is_dataclass(o):
return asdict(o)
if isinstance(o, Enum):
return o.value
if isinstance(o, UUID):
return o.hex
if isinstance(o, (datetime, time)):
return o.isoformat().replace('+00:00', 'Z', 1)
if isinstance(o, date):
return o.isoformat()
return str(o)
_SafeEncoder = _LocalSafeEncoder
return _SafeEncoder
[docs]
def safe_dumps(o: Any, **kwargs: Any) -> str:
# never let callers override cls; this is for errors, not a general API
try:
return dumps(o, cls=_get_safe_encoder(), **kwargs)
except TypeError:
# returning `o` here is inconsistent; callers expect str
return str(o)
[docs]
class JSONWizardError(ABC, Exception):
"""
Base error class, for errors raised by this library.
"""
_TEMPLATE: ClassVar[str]
@property
def class_name(self) -> str | None:
return self._class_name or self._default_class_name
@class_name.setter
def class_name(self, cls: str | type | None) -> None:
# 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) -> type | None:
return self._parent_cls
@parent_cls.setter
def parent_cls(self, cls: type | None):
# 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 = ('Failed to {p} field `{field}` in class `{cls}`.{expectation}\n'
' phase: {p}\n'
'{value}'
' error: {e!s}')
def __init__(self, base_err: Exception,
obj: Any,
ann_type: type | Iterable | None,
phase: str,
_default_class: type | None = None,
_field_name: str | None = None,
_json_object: Any = None,
**kwargs):
super().__init__()
self.phase = phase
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) -> str | None:
return self._field_name
@field_name.setter
def field_name(self, name: str | None):
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:
if self.obj_type is type:
obj_type = self.name(self.obj)
expectation = ''
value = f' value_type: {obj_type}\n'
else:
obj_type = self.name(self.obj_type)
expectation = f' Expected a type {self.ann_type}, got {obj_type}.'
value = f' value: {self.obj!r}\n'
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(
expectation=expectation,
cls=self.class_name, field=self.field_name,
e=self.base_error, value=value, p=self.phase,
ann_type=ann_type)
if self.json_object:
self.kwargs['json_object'] = safe_dumps(self.json_object)
if self.kwargs:
sep = '\n '
parts = sep.join(f'{k}: {v if isinstance(v, str) else repr(v)}'
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:
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 (is_dataclass(self.parent_cls) and
next((f for f in self.missing_fields if normalize(f) in normalized_json_keys), None)):
from ._loaders import get_loader
from .enums import KeyCase
key_transform = get_loader(self.parent_cls).transform_json_field
if isinstance(key_transform, 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'
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 if isinstance(v, str) else repr(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
`on_unknown_key='RAISE'` 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:
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 if isinstance(v, str) else repr(v)}'
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, 'load', **kwargs)
self.nested_class_name: str = self.name(nested_cls)
# self.nested_class_name: str = type_name(nested_cls)
@property
def message(self) -> str:
msg = self._TEMPLATE.format(
cls=self.class_name,
nested_cls=self.nested_class_name,
json_string=safe_dumps(self.obj),
field=self.field_name,
)
if self.kwargs:
sep = '\n '
parts = sep.join(f'{k}: {v if isinstance(v, str) else repr(v)}'
for k, v in self.kwargs.items())
msg = f'{msg}{sep}{parts}'
return msg
[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