Source code for readycheck

"""Run custom checks on classes attributes when accessing them.

Does one thing, does it well.
"""

__title__ = "readycheck"
__author__ = "Loïc Simon"
__license__ = "MIT"
__copyright__ = "Copyright 2022 Loïc Simon"
__version__ = "1.0.1"
__all__ = ["ReadyCheck", "NotReadyError"]


from typing import Any, Callable, Iterator


[docs]class NotReadyError(RuntimeError): """An attribute is tried to be accessed before it is ready. Inherits from :exc:`RuntimeError`. Attributes: class_(type): The class holding the attribute to access. attr(str): The name of the attribute we tried to access. """ def __init__(self, msg: str, class_: type, attr: str) -> None: super().__init__(msg) self.class_ = class_ self.attr = attr
class _RCDict(dict): def __init__( self, _is_ready: Callable[[Any], bool] | None = None, _class: type | None = None, **kwargs: Any ) -> None: super().__init__(**kwargs) if _is_ready is None: def _is_ready(item): return (item is not None) self._is_ready = _is_ready self._class = _class def __getitem__(self, name: str) -> Any: """Proxy item access""" val = self._get_raw(name) if self._is_ready(val): return val raise NotReadyError(f"'{name}' is not ready yet!", self._class, name) def _get_raw(self, name: str) -> Any: """Raw item access""" try: val = super().__getitem__(name) except KeyError: if self._class: raise AttributeError( f"'{self._class.__qualname__}' has no attribute '{name}'" ) from None else: raise AttributeError(f"No attribute '{name}'") from None return val class _RCMeta(type): def __new__( metacls, name: str, bases: tuple[type], dict: dict[str, Any], check: Callable[[Any], bool] | None = None, check_type: type | None = None, ) -> type: # register directly private/magic names only _prv_dict = {name: dict[name] for name in dict if name.startswith("_")} return super().__new__(metacls, name, bases, _prv_dict) def __init__( cls, name: str, bases: tuple[type], dic: dict[str, Any], check: Callable[[Any], bool] | None = None, check_type: type | None = None, ) -> None: _prv_dict = {name: dic[name] for name in dic if name.startswith("_")} _pub_dict = {name: dic[name] for name in dic if name not in _prv_dict} super().__init__(name, bases, _prv_dict) _is_ready = check if check_type: if check: def _is_ready(item): return isinstance(item, check_type) and check(item) else: def _is_ready(item): return isinstance(item, check_type) cls._rc_dict = _RCDict(_is_ready=_is_ready, _class=cls, **_pub_dict) def __getattr__(cls, name: str) -> Any: if name.startswith("_"): # Private/magic name: dont search in ._rc_dict (infinite recursion) raise AttributeError(f"'{cls.__name__}' has no attribute '{name}'") return cls._rc_dict[name] def __setattr__(cls, name: str, value: Any) -> None: if name.startswith("_"): super().__setattr__(name, value) else: cls._rc_dict[name] = value def __delattr__(cls, name: str) -> None: if name.startswith("_"): super().__delattr__(name) else: del cls._rc_dict[name] def __iter__(cls) -> Iterator: return iter(cls._rc_dict) def get_raw(cls, attr: str) -> Any: return cls._rc_dict._get_raw(attr)
[docs]class ReadyCheck(metaclass=_RCMeta): """Proxy class to prevent accessing not initialized objects. When accessing a class attribute, this class: - returns its value (classic behavior) if it is evaluated *ready* (see below); - raises a :exc:`NotReadyError` exception otherwise. Subclass this class to implement *readiness* check on class attributes and define *readiness* as needed. By default, attributes are considered *not ready* only if their value is ``None``:: class NotNone(ReadyCheck): a = None # NotNone.a will raise a NotReadyError b = <any object> # NotNone.b will be the given object Use ``check_type`` class-definition argument to define readiness based on attributes types (using :func:`isinstance()`):: class MustBeList(ReadyCheck, check_type=list): a = "TBD" # MustBeList.a will raise a NotReadyError b = [1, 2, 3] # MustBeList.b will be the given list Use ``check`` class-definition argument to define custom readiness check (``value -> bool`` function):: class MustBePositive(ReadyCheck, check=lambda val: val > 0): a = 0 # MustBePositive.a will raise a NotReadyError b = 37 # MustBePositive.b will be 37 If both arguments are provided, attribute type will be checked first and custom check will be called only for suitable attributes. Attributes can be set, updated and deleted the normal way. *Readiness* is evaluated at access time, so changing an attribute's value will change its readiness with no additional work required, and attributes set after class definition also benefit the checking proxy. Note: Attributes whose name start with ``"_"`` (private and magic attributes) are not affected and will be returned even if *not ready*. These classes also implement the iterating protocol to provide access to protected attributes **names** (order preserved):: for name in NotNone: print(name) # Will print "a" then "b" Warning: Classes deriving from this class are not meant to be instantiated. Due to the checking proxy on class attributes, instances will not see attributes defined at class level, and attributes defined in ``__init__`` or after construction will **not** be ready-checked. This class relies on a custom metaclass: you will not be able to create mixin classes from this one and a custom-metaclass one. .. py:classmethod:: get_raw(name) Access to an attribute bypassing ready check. :param str name: name of the attribute. This must be a **public** attribute (no leading underscore). :returns: The attribute value, whatever it is. :raises AttributeError: if the attribute dosent exist. """ def __init__(self, *args: Any, **kwargs: Any) -> None: raise RuntimeError( "ReadyCheck-derived classes are not meant to be instantiated!" )
# Everything is in the metaclass!