from typing import Any, ClassVar, Self, Callable
from abc import ABC, abstractmethod
from .utils.docstrings import inherit_docstring
from .utils.diff import iter_diffs, IDiffType
from .utils.meta import init_class_attrs, get_cname, get_ppath
from .utils.misc import stringify
[docs]
def labelled(label: str) -> Callable:
"""Assign ``label`` as attribute ``attr`` to a function."""
def decorator(func: Callable) -> Callable:
if isinstance(func, property):
target = func.fget
else:
target = func
setattr(target, "__group_label__", label)
return func
return decorator
[docs]
class SegramABC(ABC):
"""Abstract base class for specialized :mod:`segram` classes."""
__slots__ = ("_hashdata",)
slot_names: ClassVar[tuple[str, ...]] = ()
differ: type["Differ"]
def __init__(self) -> None:
self._hashdata = None
def __hash__(self) -> int:
return hash(self.hashdata)
def __init_subclass__(cls) -> None:
if "__slots__" not in cls.__dict__:
raise TypeError(f"'{cls.__name__}' does not define '__slots__'")
cls.init_class_attrs({
"__slots__": "slot_names",
}, check_slots=True)
if len(cls.slot_names) != len(set(cls.slot_names)):
raise TypeError(f"'__slots__' are not unique: {cls.slot_names}")
# Handle labelled methods
for name, attr in vars(cls).items():
target = attr.fget if isinstance(attr, property) else attr
if (label := getattr(target, "__group_label__", None)):
names_attr = f"{label}_names"
names = getattr(cls, names_attr, ())
setattr(cls, names_attr, (*names, name))
inherit_docstring(cls)
# Abstract methods --------------------------------------------------------
[docs]
@abstractmethod
def is_comparable_with(self, other: Any) -> None:
"""Are ``self`` and ``other`` comparable."""
raise NotImplementedError
# Properties --------------------------------------------------------------
@property
def hashdata(self) -> tuple[Any, ...]:
"""Tuple with hashable objects used for calculating instance hash."""
if self._hashdata is None:
self._hashdata = self.get_hashdata()
return self._hashdata
@property
def data(self) -> dict[str, Any]:
"""Dictionary mapping names and values for main slots."""
return {
n: getattr(self, n)
for n in self.slot_names if not n.startswith("_")
}
# Methods -----------------------------------------------------------------
[docs]
def get_hashdata(self) -> None:
"""Get data used for generating object hash."""
raise NotImplementedError(f"'{self.cname()}' is not hashable")
[docs]
@classmethod
def cname(cls, obj: Any | None = None) -> str:
"""Get class name."""
return get_cname(obj if obj is not None else cls)
[docs]
@classmethod
def ppath(cls, obj: Any | None = None) -> str:
"""Get full python path of the class."""
return get_ppath(obj if obj is not None else cls)
[docs]
@classmethod
def init_class_attrs(cls, attrs: dict[str, str], **kwds: Any) -> None:
"""Initialize special class attributes if they are not
already defined and set final values.
See :func:`init_class_attrs` for details.
"""
init_class_attrs(cls, attrs, **kwds)
[docs]
def copy(self, **kwds: Any) -> Self:
"""Copy self and modify attributes with ``**kwds``."""
return self.__class__(**{ **self.data, **kwds })
[docs]
def check_comparable(self, other: Any) -> None:
"""Raise :class:`TypeError` if ``self`` and ``other``
are not comparable.
"""
if not self.is_comparable_with(other):
raise TypeError(
f"'{self.cname()}' and '{self.cname(other)}'"
" objects are not comparable"
)
[docs]
@staticmethod
def are_equal(obj: Any, other: Any, *, strict: bool = True) -> bool:
"""Are ``obj`` and ``other`` equal.
Parameters
----------
strict
Should exact match on class be required.
It also means that NLP tokens can be equal
only when they live in the same document.
"""
return not any(iter_diffs(obj, other, strict=strict))
[docs]
@classmethod
def stringify(cls, obj: Any, **kwds: Any) -> str:
"""Convert ``obj`` to string.
If ``obj`` exposes ``to_str()`` then it is used
with keyword arguments passed in ``**kwds``.
Otherwise the plain ``__repr__()`` is used.
"""
return stringify(obj, **kwds)
[docs]
def equal(self, other: Any, *, strict: bool = True) -> bool:
"""Are ``self`` and ``other`` equal.
See :meth:`iter_diffs` for details.
"""
return not any(self.iter_diffs(other, strict=strict))
[docs]
def iter_diffs(self, other: Any, *, strict: bool = True) -> IDiffType:
"""Iterate over differences between ``self`` and ``other``.
Parameters
----------
strict
Should exact match on class be required.
It also means that NLP tokens can be equal
only when they live in the same document.
"""
yield from iter_diffs(self, other, strict=strict)
# Register diffing methods on the differ class --------------------------------
@iter_diffs.register
def _(obj: SegramABC, other: Any, *, strict: bool = True) -> IDiffType:
yield from iter_diffs(obj.to_data(), other.to_data())
[docs]
class SegramWithDocABC(SegramABC):
"""Abstract base class for :mod:`segram` classes
with NLP document objects.
"""
__slots__ = ()
def __hash__(self) -> int:
return super().__hash__()
def __eq__(self, other: Any) -> bool:
if self.is_comparable_with(other):
return id(self.doc) == id(other.doc)
return NotImplemented
def __contains__(self, other: Any) -> bool:
raise TypeError(
f"'{self.cname(other)}' objects cannot "
f"be contained in '{self.cname()}' instances"
)
# Abstract methods --------------------------------------------------------
@property
@abstractmethod
def doc(self) -> "Doc":
raise NotImplementedError
# Properties --------------------------------------------------------------
@property
def lang(self) -> str:
"""Language code of the document."""
return self.doc.lang
# Methods -----------------------------------------------------------------
[docs]
def get_hashdata(self) -> tuple[Any, ...]:
return (hash(self.ppath()), id(self.doc))