Source code for sidekick.properties

from .functions import always, to_callable
from .typing import Union, Type, Func

_property = property

__all__ = ["lazy", "delegate_to", "alias", "property"]

ATTR_ERROR_MSG = """An AttributeError was raised when evaluating a lazy property.

This is often an error and the default behavior is to prevent such errors to
cascade to let Python think that the attribute does not exist. If you really
want to signal that the attribute is missing, consider using the option
"lazy(..., attr_error=True)" of the lazy property decorator.
"""


[docs]def lazy( func=None, *, shared: bool = False, name: str = None, attr_error: Union[Type[Exception], bool] = False, ): """ Mark attribute that is initialized at first access rather than during instance creation. Usage is similar to ``@property``, although lazy attributes do not override *setter* and *deleter* methods, allowing instances to write to the attribute. Optional Args: shared: A shared attribute behaves as a lazy class variable that is shared among all classes and instances. It differs from a simple class attribute in that it is initialized lazily from a function. This can help to break import cycles and delay expensive global initializations to when they are required. name: By default, a lazy attribute can infer the name of the attribute it refers to. In some exceptional cases (when creating classes dynamically), the inference algorithm might fail and the name attribute must be set explicitly. attr_error (Exception, bool): If False or an exception class, re-raise attribute errors as the given error. This prevent erroneous code that raises AttributeError being mistakenly interpreted as if the attribute does not exist. Examples: .. testcode:: import math class Vec: @sk.lazy def magnitude(self): print('computing...') return math.sqrt(self.x**2 + self.y**2) def __init__(self, x, y): self.x, self.y = x, y Now the ``magnitude`` attribute is initialized and cached upon first use: >>> v = Vec(3, 4) >>> v.magnitude computing... 5.0 The attribute is writable and apart from the deferred initialization, it behaves just like any regular Python attribute. >>> v.magnitude = 42 >>> v.magnitude 42 Lazy attributes can be useful either to simplify the implementation of ``__init__`` or as an optimization technique that delays potentially expensive computations that are not always necessary in the object's lifecycle. Lazy attributes can be used together with quick lambdas for very compact definitions: .. testcode:: import math from sidekick import placeholder as _ class Square: area = sk.lazy(_.width * _.height) perimeter = sk.lazy(2 * (_.width + _.height)) """ if func is None: return lambda f: lazy(f, shared=shared, name=name, attr_error=attr_error) if attr_error is False: attr_error = always(RuntimeError(ATTR_ERROR_MSG)) if shared: return _SharedLazy(func, name=name, attr_error=attr_error) else: return _Lazy(func, name=name, attr_error=attr_error)
[docs]def property(fget=None, fset=None, fdel=None, doc=None): """ A Sidekick-enabled property descriptor. It is a drop-in replacement for Python's builtin properties. It behaves similarly to Python's builtin, but also accepts quick lambdas as input functions. This allows very terse declarations: .. testcode:: from sidekick.api import placeholder as _ class Vector: sqr_radius = sk.property(_.x**2 + _.y**2) :func:`lazy` is very similar to property. The main difference between both is that properties are not cached and hence the function is re-executed at each attribute access. The desired behavior will depend a lot on what you want to do. """ return _Property(fget, fset, fdel, doc)
[docs]def delegate_to(attr: str, mutable: bool = False): """ Delegate access to an inner variable. A delegate is an alias for an attribute of the same name that lives in an inner object of an instance. This is useful when the inner object contains the implementation (remember the "composition over inheritance mantra"), but we want to expose specific interfaces of the inner object. Args: attr: Name of the inner variable that receives delegation. It can be a dotted name with one level of nesting. In that case, it associates the property with the sub-attribute of the delegate object. mutable: If True, makes the the delegation read-write. It writes new values to attributes of the delegated object. Examples: .. testcode:: class Queue: pop = sk.delegate_to('_data') push = sk.delegate_to('_data.append') def __init__(self, data=()): self._data = list(data) def __repr__(self): return f'Queue({self._data})' Now ``Queue.pop`` simply redirects to the pop method of the ``._data`` attribute, and ``Queue.push`` searches for ``._data.append`` >>> q = Queue([1, 2, 3]) >>> q.pop() 3 >>> q.push(4); q Queue([1, 2, 4]) """ attr, _, name = attr.partition(".") if mutable: return _MutableDelegate(attr, name or None) else: return _ReadOnlyDelegate(attr, name or None)
[docs]def alias( attr: str, *, mutable: bool = False, transform: Func = None, prepare: Func = None ): """ An alias to another attribute. Aliasing is another simple form of self-delegation. Aliases are views over other attributes in the instance itself: Args: attr: Name of aliased attribute. mutable: If True, makes the alias mutable. transform: If given, transform output by this function before returning. prepare: If given, prepare input value before saving. Examples: .. testcode:: class Queue(list): push = sk.alias('pop') This exposes two additional properties: "abs_value" and "origin". The first is just a read-only view on the "magnitude" property. The second exposes read and write access to the "start" attribute. """ if transform is not None or prepare is not None: return _TransformingAlias(attr, transform, prepare) elif mutable: return _MutableAlias(attr) else: return _ReadOnlyAlias(attr)
# # Helper classes # class DescriptorMixin: """ Functionality and interfaces shared between all descriptor classes. """ is_property = True is_mutable = False is_lazy = False is_alias = False is_delegate = False fget = fset = fdel = None name = None def __set_name__(self, owner, name): if self.name is None: self.name = name class _Property(DescriptorMixin, _property): """ Sidekick property descriptor. """ def __init__(self, fget=None, fset=None, fdel=None, doc=None): fget = fget if fget is None else to_callable(fget) fset = fset if fset is None else to_callable(fset) fdel = fdel if fdel is None else to_callable(fdel) super().__init__(fget, fset, fdel, doc) def getter(self, fget): return super().getter(fget if fget is None else to_callable(fget)) def setter(self, fset): return super().setter(fset if fset is None else to_callable(fset)) class _Lazy(DescriptorMixin): """ Lazy attribute of an object """ __slots__ = ("fget", "name", "attr_error") is_lazy = True is_mutable = True def __init__(self, func, name=None, attr_error=True): self.fget = to_callable(func) self.name = name self.attr_error = attr_error or Exception def __get__(self, obj, cls=None): if obj is None: return self name = self.name or self._init_name(cls) try: value = self.fget(obj) except AttributeError as ex: if self.attr_error is True: raise raise self.attr_error(ex) from ex setattr(obj, name, value) return value def _init_name(self, cls): function_name = self.fget.__name__ name = find_descriptor_name(self, cls, hint=function_name) self.name = name return name class _SharedLazy(_Lazy): """ Lazy attribute of a class and all its instances. """ __slots__ = ("value",) def __get__(self, obj, cls=None): try: return self.value except AttributeError: return self._init_value(cls) def _init_value(self, cls): try: self.value = self.fget(cls) except AttributeError as ex: if self.attr_error is True: raise raise self.attr_error(ex) from ex return self.value class _ReadOnlyDelegate(DescriptorMixin): """ Delegate attribute to another attribute. """ __slots__ = ("attr", "name") is_delegate = True def __init__(self, attr, name=None): self.attr = attr self.name = name def __get__(self, obj, cls=None): if obj is None: return self name = self.name or find_descriptor_name(self, cls) owner = getattr(obj, self.attr) return getattr(owner, name) def __set__(self, obj, value): raise AttributeError(self.name or find_descriptor_name(self, type(obj))) def fget(self, instance): return self.__get__(instance) class _MutableDelegate(_ReadOnlyDelegate): """ Mutable version of Delegate. """ __slots__ = () is_mutable = True def __set__(self, obj, value): owner = getattr(obj, self.attr) name = self.name or find_descriptor_name(self, type(obj)) setattr(owner, name, value) class _ReadOnlyAlias(DescriptorMixin): """ Like alias, but read-only. """ __slots__ = ("attr",) is_alias = True def __init__(self, attr): self.attr = attr def __get__(self, obj, cls=None): if obj is not None: return getattr(obj, self.attr) return self def __set__(self, key, value): raise AttributeError(self.attr) def fget(self, obj): return self.__get__(obj) class _MutableAlias(_ReadOnlyAlias): """ Alias to another attribute/method in class. """ __slots__ = () is_mutable = True def __set__(self, obj, value): setattr(obj, self.attr, value) def fset(self, obj, value): return self.__set__(obj, value) class _TransformingAlias(_MutableAlias): """ A bijection to another attribute in class. """ __slots__ = ("transform", "prepare") is_mutable = property(lambda self: self.prepare is not None) def __init__(self, attr, transform=lambda x: x, prepare=None): super().__init__(attr) self.attr = attr self.transform = to_callable(transform) self.prepare = None if prepare is None else to_callable(prepare) def __get__(self, obj, cls=None): if obj is not None: return self.transform(getattr(obj, self.attr)) return self def __set__(self, obj, value): if self.prepare is None: raise AttributeError(self.attr) else: value = self.prepare(value) setattr(obj, self.attr, value) def fget(self, obj): return self.transform(super().fget(obj)) def fset(self, obj, value): if self.prepare is None: raise AttributeError("cannot change attribute") super().fset(obj, self.prepare(value)) # # Utility functions # def find_descriptor_name(descriptor, cls: type, hint=None): """ Finds the name of the descriptor in the given class. """ if hint is not None and getattr(cls, hint, None) is descriptor: return hint for attr in dir(cls): value = getattr(cls, attr, None) if value is descriptor: return attr raise RuntimeError("%r is not a member of class" % descriptor) def find_descriptor_owner(descriptor, cls: type, name=None): """ Find the class that owns the descriptor. """ name = name or find_descriptor_name(descriptor, cls) owner = None for super_class in cls.mro(): # We use dict to avoid recursion caused by the descriptor protocol value = super_class.__dict__.get(name, None) if value is descriptor: owner = super_class if owner is None: raise RuntimeError("%r is not a member of %s" % descriptor) return owner