from __future__ import annotations
import copy
import enum
import functools
import logging
import types
import typing as t
from izulu import _utils
from izulu import tools
_IMPORT_ERROR_TEXTS = (
"",
"You have early version of Python.",
" Extra compatibility dependency required.",
" Please add 'izulu[compatibility]' to your project dependencies.",
"",
"Pip: `pip install izulu[compatibility]`",
)
if hasattr(t, "dataclass_transform"):
t_ext = t
else:
try:
import typing_extensions as t_ext # type: ignore[no-redef]
except ImportError:
for message in _IMPORT_ERROR_TEXTS:
logging.error(message) # noqa: LOG015,TRY400
raise
FactoryReturnType = t.TypeVar("FactoryReturnType")
@t.overload
def factory(
*,
default_factory: t.Callable[[], FactoryReturnType],
self: t.Literal[False] = False,
) -> FactoryReturnType: ...
@t.overload
def factory(
*,
default_factory: t.Callable[[Error], FactoryReturnType],
self: t.Literal[True],
) -> FactoryReturnType: ...
[docs]
def factory(
*,
default_factory: t.Callable[..., t.Any],
self: bool = False,
) -> t.Any:
"""
Attaches factory for dynamic default values.
Args:
default_factory: callable factory receiving 0 or 1 argument
(see ``self`` param)
self: controls callable factory argument
if ``True`` factory will receive single argument of error instance
otherwise factory will be invoked without argument
"""
target = default_factory if self else (lambda _: default_factory())
return functools.cached_property(target)
[docs]
class Toggles(enum.Flag):
FORBID_MISSING_FIELDS = enum.auto()
FORBID_UNDECLARED_FIELDS = enum.auto()
FORBID_KWARG_CONSTS = enum.auto()
FORBID_NON_NAMED_FIELDS = enum.auto()
FORBID_UNANNOTATED_FIELDS = enum.auto()
NONE = 0
DEFAULT = (
FORBID_MISSING_FIELDS
| FORBID_UNDECLARED_FIELDS
| FORBID_KWARG_CONSTS
| FORBID_NON_NAMED_FIELDS
| FORBID_UNANNOTATED_FIELDS
)
[docs]
@t_ext.dataclass_transform(
eq_default=False,
order_default=False,
kw_only_default=True,
frozen_default=False,
field_specifiers=(factory,),
)
class Error(Exception):
"""
Base class for your exception trees.
Example::
class MyError(root.Error):
__template__ = "{smth} has happened at {ts}"
smth: str
ts: root.factory(datetime.now)
Provides 4 main features:
* Instead of manual error message formatting (and copying it all over
the codebase) provide just ``kwargs``:
- before: ``raise MyError(f"{smth} has happened at {datetime.now()}")``
- after: ``raise MyError(smth=smth)``
Provide ``__template__`` class attribute with your error message
template string. New style formatting is used:
- ``str.format()``
- https://pyformat.info/
- https://docs.python.org/3/library/string.html#formatspec
* Automatic ``kwargs`` conversion into error instance attributes
* You can attach static and dynamic default values:
this is why ``datetime.now()`` was omitted above
* Out-of-box validation for provided ``kwargs``
(individually enable/disable checks with ``__toggles__`` attribute)
"""
__template__: t.ClassVar[str] = "Unspecified error"
__toggles__: t.ClassVar[Toggles] = Toggles.DEFAULT
__cls_store: t.ClassVar[_utils.Store] = _utils.Store(
fields=frozenset(),
const_hints=types.MappingProxyType(dict()),
inst_hints=types.MappingProxyType(dict()),
consts=types.MappingProxyType(dict()),
defaults=frozenset(),
)
def __init_subclass__(cls, **kwargs: t.Any) -> None: # noqa: ANN401
super().__init_subclass__(**kwargs)
fields = frozenset(_utils.iter_fields(cls.__template__))
const_hints, inst_hints = _utils.split_cls_hints(cls)
consts = _utils.get_cls_defaults(cls, const_hints)
defaults = _utils.get_cls_defaults(cls, inst_hints)
cls.__cls_store = _utils.Store(
fields=fields,
const_hints=types.MappingProxyType(const_hints),
inst_hints=types.MappingProxyType(inst_hints),
consts=types.MappingProxyType(consts),
defaults=frozenset(defaults),
)
if Toggles.FORBID_NON_NAMED_FIELDS in cls.__toggles__:
_utils.check_non_named_fields(cls.__cls_store)
if Toggles.FORBID_UNANNOTATED_FIELDS in cls.__toggles__:
_utils.check_unannotated_fields(cls.__cls_store)
def __init__(self, **kwargs: t.Any) -> None: # noqa: ANN401
self.__iter = None
self.__kwargs = kwargs.copy()
self.__process_toggles()
self.__populate_attrs()
msg = self.__process_template(self.as_dict())
msg = self._override_message(self.__cls_store, kwargs, msg)
super().__init__(msg)
def __iter__(self) -> t.Iterator[BaseException]:
"""Return iterator over the whole exception chain."""
return tools.error_chain(self)
def __reduce__(self) -> t.Tuple[t.Any, ...]:
return functools.partial(self.__class__, **self.as_dict()), tuple()
def __copy__(self) -> Error:
return type(self)(**self.as_dict())
def __deepcopy__(self, memo: t.Dict[int, t.Any]) -> Error:
id_ = id(self)
if id_ not in memo:
kwargs = {
k: copy.deepcopy(v, memo) for k, v in self.as_dict().items()
}
new = type(self)(**kwargs)
new.__cause__ = copy.deepcopy(self.__cause__, memo)
memo[id_] = new
return t.cast("Error", memo[id_])
def __repr__(self) -> str:
kwargs = _utils.join_kwargs(**self.as_dict())
return f"{self.__module__}.{self.__class__.__qualname__}({kwargs})"
def __process_toggles(self) -> None:
"""Trigger toggles."""
store = self.__cls_store
kws = frozenset(self.__kwargs)
if Toggles.FORBID_MISSING_FIELDS in self.__toggles__:
_utils.check_missing_fields(store, kws)
if Toggles.FORBID_UNDECLARED_FIELDS in self.__toggles__:
_utils.check_undeclared_fields(store, kws)
if Toggles.FORBID_KWARG_CONSTS in self.__toggles__:
_utils.check_kwarg_consts(store, kws)
def __populate_attrs(self) -> None:
"""Set hinted kwargs as exception attributes."""
for k, v in self.__kwargs.items():
if k in self.__cls_store.inst_hints:
setattr(self, k, v)
def __process_template(self, data: t.Dict[str, t.Any]) -> str:
"""Format the error template from provided data (kwargs & defaults)."""
kwargs = self.__cls_store.consts.copy()
kwargs.update(data)
return _utils.format_template(self.__template__, kwargs)
def _override_message( # noqa: PLR6301
self,
store: _utils.Store, # noqa: ARG002
kwargs: t.Dict[str, t.Any], # noqa: ARG002
msg: str,
) -> str:
"""
Adapter method to wedge user logic into izulu machinery.
This is the place to override message/formatting if regular mechanics
don't work for you. It has to return original or your flavored message.
The method is invoked between izulu preparations and original
``Exception`` constructor receiving the result of this hook.
You can also do any other logic here. You will be provided with
a complete set of prepared data from izulu. But it's recommended
to use classic OOP inheritance for ordinary behavior extension.
Args:
store: dataclass containing inner error class specifications
kwargs: original kwargs from user
msg: formatted message from the error template
"""
return msg
[docs]
def as_str(self) -> str:
"""Represent error as an exception type with message."""
return f"{self.__class__.__qualname__}: {self}"
[docs]
def as_kwargs(self) -> t.Dict[str, t.Any]:
"""Return the copy of original kwargs used to initialize the error."""
return self.__kwargs.copy()
[docs]
def as_dict(self, *, wide: bool = False) -> t.Dict[str, t.Any]:
"""
Represent error as dict of fields including default values.
By default, only *instance* data and defaults are provided.
Args:
wide: if ``True`` *class* defaults will be included in result
"""
d = self.__kwargs.copy()
for field in self.__cls_store.defaults:
d.setdefault(field, getattr(self, field))
if wide:
for field, const in self.__cls_store.consts.items():
d.setdefault(field, const)
return d