"""A class with a number of generic pre-defined (magic) methods inspired by dataclass of Python 3.7.
Index
-----
.. currentmodule:: assertionlib.dataclass
.. autosummary::
AbstractDataClass
AbstractDataClass.__repr__
AbstractDataClass.__eq__
AbstractDataClass.__hash__
AbstractDataClass.__copy__
AbstractDataClass.__deepcopy__
AbstractDataClass.copy
AbstractDataClass.as_dict
AbstractDataClass.from_dict
AbstractDataClass.inherit_annotations
API
---
.. autoclass:: AbstractDataClass
.. automethod:: AbstractDataClass.__repr__
.. automethod:: AbstractDataClass.__eq__
.. automethod:: AbstractDataClass.__hash__
.. automethod:: AbstractDataClass.__copy__
.. automethod:: AbstractDataClass.__deepcopy__
.. automethod:: AbstractDataClass.copy
.. automethod:: AbstractDataClass.as_dict
.. automethod:: AbstractDataClass.from_dict
.. automethod:: AbstractDataClass.inherit_annotations
""" # noqa: E501
import textwrap
import copy
from abc import ABCMeta
from functools import wraps
from threading import get_ident
from typing import (
Any, Dict, Set, Iterable, Tuple, ClassVar, NoReturn, cast, Iterator, Union,
Callable, Optional, Mapping, TypeVar, Type
)
__all__ = ['AbstractDataClass']
T = TypeVar('T')
TT = TypeVar('TT', bound='_MetaADC')
AT = TypeVar('AT', bound='AbstractDataClass')
FT = TypeVar('FT', bound=Callable[..., Any])
def recursion_safeguard(fallback: FT) -> Callable[[FT], FT]:
"""Decorate a function such that it calls **fallback** in case of recursive calls.
Implementation based on :func:`reprlib.recursive_repr`.
"""
def decorating_function(user_function: FT) -> FT:
running: Set[Tuple[int, int]] = set()
@wraps(user_function)
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
key = id(self), get_ident()
if key in running:
return fallback(self, *args, **kwargs)
running.add(key)
try:
result = user_function(self, *args, **kwargs)
finally:
running.discard(key)
return result
return cast(FT, wrapper)
return decorating_function
class _MetaADC(ABCMeta):
_HASHABLE: bool = NotImplemented
def __new__(mcls: Type[TT], name: str, bases: Tuple[type, ...],
namespace: Dict[str, Any]) -> TT:
cls = super().__new__(mcls, name, bases, namespace)
if not cls._HASHABLE:
setattr(cls, '__hash__', mcls._hash_template1)
else:
func = recursion_safeguard(cls._repr_fallback)(mcls._hash_template2) # type: ignore
setattr(cls, '__hash__', func)
return cls
@staticmethod
def _hash_template1(self: 'AbstractDataClass') -> NoReturn:
"""Unhashable type; raise a :exc:`TypeError`."""
raise TypeError(f"Unhashable type: {self.__class__.__name__!r}")
@staticmethod
def _hash_template2(self: 'AbstractDataClass') -> int:
"""Return the hash of this instance.
The returned hash is constructed from two components:
* The hash of this instances' class type.
* The hashes of all key/value pairs in this instances' (non-private) attributes.
If an unhashable instance variable is encountered, *e.g.* a :class:`list`,
then its :func:`id` is used for hashing.
This method will raise a :exc:`TypeError` if the class attribute
:attr:`AbstractDataClass._HASHABLE` is :data:`False`.
See Also
--------
:attr:`AbstractDataClass._PRIVATE_ATTR`
A set with the names of private instance variables.
:attr:`AbstractDataClass._HASHABLE`
Whether or not this class is hashable.
:attr:`AbstractDataClass._hash_fallback`
Fallback function for :meth:`AbstractDataClass.__hash__` incase of recursive calls.
:attr:`AbstractDataClass._hash`
An instance variable for caching the :func:`hash` of this instance.
"""
# Return a cached hash
if self._hash:
return self._hash
ret = hash(type(self))
for k, v in self._iter_attrs():
if k in self._PRIVATE_ATTR:
continue
try:
ret ^= hash((k, v))
except TypeError:
ret ^= hash((k, id(v)))
# Cache the hash and return
self._hash = ret
return ret
[docs]class AbstractDataClass(metaclass=_MetaADC):
"""A dataclass with a number of generic pre-defined (magic) methods.
Provides methods for:
* String conversion: :meth:`AbstractDataClass.__repr__`.
* Object comparisons: :meth:`AbstractDataClass.__eq__`.
* Hash construction: :meth:`AbstractDataClass.__hash__`.
* Copying: :meth:`AbstractDataClass.copy`, :meth:`AbstractDataClass.__copy__` and
:meth:`AbstractDataClass.__deepcopy__`.
* Dictionary interconversion: :meth:`AbstractDataClass.as_dict` and
:meth:`AbstractDataClass.from_dict`.
* Inherting method docstrings and annotations: :meth:`AbstractDataClass.inherit_annotations`.
Attributes
----------
_PRIVATE_ATTR : :class:`frozenset` [:class:`str`] or :class:`set` [:class:`str`]
A class variable with the names of private instance variable.
These attributes will be excluded whenever calling :meth:`AbstractDataClass.as_dict`,
printing or comparing objects.
The set is unfrozen (and added as instance variables)
the moment a class instance is initiated.
_HASHABLE : :class:`bool`
A class variable denoting whether or not class instances are hashable.
The :attr:`AbstractDataClass.__hash__` method will be unavailable if :data:`False`.
_hash : :class:`int`
An attribute for caching the :func:`hash` of this instance.
Only available if :attr:`AbstractDataClass._HASHABLE` is :data:`True`.
"""
#: A :class:`frozenset` with the names of private instance variables.
#: These attributes will be excluded whenever calling :meth:`AbstractDataClass.as_dict`,
#: printing or comparing objects.
_PRIVATE_ATTR: Set[str] = frozenset() # type: ignore
#: Whether or not this class is hashable.
#: If :data:`False`, raise a :exc:`TypeError` when calling :meth:`AbstractDataClass.__hash__`.
_HASHABLE: ClassVar[bool] = True
#: Empty slots which can be filled by subclasses.
__slots__: Union[str, Iterable[str]] = ('__dict__',)
__hash__: Callable[[type], Union[int, NoReturn]]
def __init__(self) -> None:
"""Initialize a :class:`AbstractDataClass` instance."""
# Assign cls._PRIVATE_ATTR as a (unfrozen) set to this instance as attribute
cls = type(self)
self._PRIVATE_ATTR = {'_PRIVATE_ATTR'}.union(cls._PRIVATE_ATTR)
# Extra attributes in case the class is hashable
if cls._HASHABLE:
self._PRIVATE_ATTR.add('_hash')
self._hash: int = 0
def _iter_attrs(self) -> Iterator[Tuple[str, Any]]:
"""Return an iterator which iterates over this instance's attributes as key/value pairs.
If all attributes are stored in the instance :attr:`~object.__dict__` then
further alterations to this method are not necessary.
If :attr:`~object.__slots__` are utilized for defining attributes then alterations
will have to be made to this method, *e.g.*:
.. code:: python
>>> def _iter_attrs(self):
... yield 'a', self.a
... yield 'b', self.b
... yield 'c', self.c
"""
return iter(vars(self).items())
def _hash_fallback(self) -> int:
"""Fallback function for :meth:`AbstractDataClass.__hash__` incase of recursive calls."""
return id(self)
def _repr_fallback(self) -> str:
"""Fallback function for :meth:`AbstractDataClass.__repr__` incase of recursive calls."""
return object.__repr__(self).rstrip('>').rsplit(maxsplit=1)[1]
[docs] @recursion_safeguard(fallback=_repr_fallback) # type: ignore
def __repr__(self) -> str:
"""Return a (machine readable) string representation of this instance.
The string representation consists of this instances' class name in addition
to all (non-private) instance variables.
Returns
-------
:class:`str`
A string representation of this instance.
See Also
--------
:attr:`AbstractDataClass._PRIVATE_ATTR`
A set with the names of private instance variables.
:attr:`AbstractDataClass._repr_fallback`
Fallback function for :meth:`AbstractDataClass.__repr__` incase of recursive calls.
:meth:`AbstractDataClass._str_iterator`
Return an iterable for the iterating over this instances' attributes.
:meth:`AbstractDataClass._str`
Returns a string representation of a single **key**/**value** pair.
"""
try:
width = max(len(k) for k, _ in self._str_iterator())
except ValueError: # Raised if this instance has no instance variables
return f'{self.__class__.__name__}()'
ret = ',\n'.join(self._str(k, v, width, 3+width) for k, v in self._str_iterator())
indent = ' ' * 4
return f'{self.__class__.__name__}(\n{textwrap.indent(ret, indent)}\n)'
def _str_iterator(self) -> Iterable[Tuple[str, Any]]:
"""Return an iterable for the :meth:`AbstractDataClass.__repr__` method."""
return ((k, v) for k, v in sorted(self._iter_attrs()) if k not in self._PRIVATE_ATTR)
@staticmethod
def _str(key: str, value: Any,
width: Optional[int] = None,
indent: Optional[int] = None) -> str:
"""Return a string representation of a single **key**/**value** pair."""
key_str = f'{key} = ' if width is None else f'{key:{width}} = '
if indent is not None:
value_str = textwrap.indent(repr(value), ' ' * indent)[indent:]
else:
value_str = repr(value)
return f'{key_str}{value_str}' # e.g.: "key = 'value'"
def _eq_fallback(self, value: Any) -> bool:
"""Fallback function for :meth:`AbstractDataClass.__eq__` incase of recursive calls."""
return self is value
[docs] @recursion_safeguard(fallback=_eq_fallback) # type: ignore
def __eq__(self, value: Any) -> bool:
"""Check if this instance is equivalent to **value**.
The comparison checks if the class type of this instance and **value** are identical
and if all (non-private) instance variables are equivalent.
Returns
-------
:class:`bool`
Whether or not this instance and **value** are equivalent.
See Also
--------
:attr:`AbstractDataClass._PRIVATE_ATTR`
A set with the names of private instance variables.
:attr:`AbstractDataClass._eq`
Return if **v1** and **v2** are equivalent.
:attr:`AbstractDataClass._eq_fallback`
Fallback function for :meth:`AbstractDataClass.__eq__` incase of recursive calls.
"""
# Compare instance types
if type(self) is not type(value):
return False
# Compare instance variables
try:
for k, v1 in self._iter_attrs():
if k in self._PRIVATE_ATTR:
continue
v2 = getattr(value, k)
assert self._eq(v1, v2)
except (AttributeError, AssertionError):
return False
else:
return True
@staticmethod
def _eq(v1: Any, v2: Any) -> bool:
"""Return if **v1** and **v2** are equivalent."""
return cast(bool, v1 == v2)
[docs] def copy(self: AT, deep: bool = False) -> AT:
"""Return a shallow or deep copy of this instance.
Parameters
----------
deep : :class:`bool`
Whether or not to return a deep or shallow copy.
Returns
-------
:class:`AbstractDataClass`
A new instance constructed from this instance.
"""
def return_arg(arg: T) -> T:
return arg
copy_func = cast(Callable[[T], T], copy.deepcopy if deep else return_arg)
cls = type(self)
ret: AT = cls.__new__(cls)
for k, v in self._iter_attrs():
setattr(ret, k, copy_func(v))
return ret
[docs] def __copy__(self: AT) -> AT:
"""Return a shallow copy of this instance; see :meth:`AbstractDataClass.copy`."""
return self.copy(deep=False)
[docs] def __deepcopy__(self: AT, memo: Optional[Dict[int, Any]] = None) -> AT:
"""Return a deep copy of this instance; see :meth:`AbstractDataClass.copy`."."""
return self.copy(deep=True)
[docs] def as_dict(self, return_private: bool = False) -> Dict[str, Any]:
"""Construct a dictionary from this instance with all non-private instance variables.
The returned dictionary values are shallow copies.
Parameters
----------
return_private : :class:`bool`
If :data:`True`, return both public and private instance variables.
Private instance variables are defined in :data:`AbstractDataClass._PRIVATE_ATTR`.
Returns
-------
:class:`dict` [:class:`str`, :data:`~typing.Any`]
A dictionary with keyword arguments for initializing a new
instance of this class.
See Also
--------
:meth:`AbstractDataClass.from_dict`:
Construct a instance of this objects' class from a dictionary with keyword arguments.
:attr:`AbstractDataClass._PRIVATE_ATTR`:
A set with the names of private instance variables.
"""
if return_private:
return {k: copy.copy(v) for k, v in self._iter_attrs()}
else:
return {k: copy.copy(v) for k, v in self._iter_attrs() if k not in self._PRIVATE_ATTR}
[docs] @classmethod
def from_dict(cls: Type[AT], dct: Mapping[str, Any]) -> AT:
"""Construct a instance of this objects' class from a dictionary with keyword arguments.
Parameters
----------
dct : :class:`~collections.abc.Mapping` [:class:`str`, :data:`~typing.Any`]
A dictionary with keyword arguments for constructing a new
:class:`AbstractDataClass` instance.
Returns
-------
:class:`AbstractDataClass`
A new instance of this object's class constructed from **dct**.
See Also
--------
:meth:`AbstractDataClass.as_dict`
Construct a dictionary from this instance with all non-private instance variables.
"""
return cls(**dct)
[docs] @classmethod
def inherit_annotations(cls) -> Callable[[FT], FT]:
"""A decorator for inheriting annotations and docstrings.
Can be applied to methods of :class:`AbstractDataClass` subclasses to automatically
inherit the docstring and annotations of identical-named functions of its superclass.
References to :class:`AbstractDataClass` are replaced with ones pointing to the
respective subclass.
Returns
-------
:class:`type`
A decorator for updating the annotations and docstring of a callable.
Examples
--------
.. code:: python
>>> class SubClass(AbstractDataClass):
...
... @AbstractDataClass.inherit_annotations()
... def __copy__(self): pass
>>> print(SubClass.__copy__.__doc__)
Return a shallow copy of this instance; see :meth:`SubClass.copy`.
>>> print(SubClass.__copy__.__annotations__)
{'self': ~AT, 'return': ~AT}
"""
def decorator(func: FT) -> FT:
cls_func: str = getattr(cls, func.__name__)
sub_cls_name: str = func.__qualname__.split('.')[0]
# Update annotations
if not getattr(func, '__annotations__', None):
func.__annotations__ = dct = getattr(cls_func, '__annotations__', {}).copy()
if dct.get('return') in {cls, cls.__name__}:
dct['return'] = sub_cls_name
# Update docstring
if func.__doc__ is None and cls_func.__doc__ is not None:
doc_new = cls_func.__doc__.replace(cls.__name__, sub_cls_name)
func.__doc__ = doc_new
return func
return decorator