"""
Descriptors/properties for classes
"""
import copy
from typing import Optional, Tuple
from .subclass import argumented_subclass, attributed_subclass
[docs]class FactoryProperty:
"""
A descriptor that returns an object related to the owner class
and created by ``factory`` when accessed.
:param factory: the object factory. Should have the following signature:
``factory(cls, name, *args, **kwargs)``.
For example, :func:`~classical.subclass.argumented_subclass`
can be used here
:param args: positional arguments for ``factory``
:param kwargs: keyword arguments for ``factory``
``FactoryProperty`` is the base class for:
- :class:`~classical.descriptors.ArgumentedSubclass`
- :class:`~classical.descriptors.AttributedSubclass`
- :class:`~classical.descriptors.AutoProperty`
- :class:`~classical.descriptors.DummySubclass`
"""
def __init__(self, factory, *args, **kwargs):
self.factory = factory
self.args = args
self.kwargs = kwargs
self._cls_map = {}
self._name = None # type: str
self._owner = None # type: type
self._is_terminal = False
def __set_name__(self, owner: type, name: str):
self._name = name
self._owner = owner
def _get_owner_and_name(self, owner: type) -> Tuple[Optional[type], Optional[str]]:
"""Resolve the original owner and name of the attribute"""
try:
own_name = [
name for name, attr in owner.__dict__.items()
if attr is self
][0]
return owner, own_name
except IndexError:
for base in owner.__bases__:
original_owner, own_name = self._get_owner_and_name(base)
if own_name:
return original_owner, own_name
return None, None
def __get__(self, instance, owner):
if self._name is None: # has not been set yet
# get the name of the attribute - it will serve as the name
# of the new partial class
original_owner, own_name = self._get_owner_and_name(owner)
if not own_name:
raise RuntimeError('Property is not bound to any class')
self.__set_name__(owner=original_owner, name=own_name)
if self._is_terminal:
# always return subclass of the original owner
owner = self._owner
if owner not in self._cls_map:
self._cls_map[owner] = self.factory(owner, self._name, *self.args, **self.kwargs)
return self._cls_map[owner]
@property
def terminal(self) -> 'FactoryProperty':
"""
Return a "terminal" version of the property:
if the owner class is subclassed, the object factory will be applied
to the original owner of the property instead of the subclass
Consider:
::
class Thing:
my_thing = AutoProperty()
terminal_thing = AutoProperty().terminal
class ClassyThing(Thing):
pass
isinstance(ClassyThing.my_thing, ClassyThing) # True
isinstance(ClassyThing.terminal_thing, ClassyThing) # False
ClassyThing.my_thing.__class__ # ClassyThing
ClassyThing.terminal_thing.__class__ # Thing
"""
self_copy = copy.copy(self)
self_copy._is_terminal = True
return self_copy
[docs]class DummySubclass(FactoryProperty):
"""
A descriptor that returns a copy of the owner class when accessed
(but with a new name equal to the attribute's name).
"""
def __init__(self):
super().__init__(argumented_subclass)
[docs]class ArgumentedSubclass(FactoryProperty):
"""
A descriptor that returns an :func:`~classical.subclass.argumented_subclass`
of the owner class when accessed.
It allows a class to have attributes that are its own subclasses
with additional arguments passed to ``__init__``:
::
class Tree:
Peach = ArgumentedSubclass(fruit='peach')
Pine = ArgumentedSubclass(fruit='cone')
# both will return subclasses of Tree when accessed
def __init__(self, fruit):
self.fruit = fruit
issubclass(Tree.Pine, Tree) # True
Tree.Pine().fruit # 'cone'
These properties can be used recursively in combination with each other:
::
class Polygon:
Blue = ArgumentedSubclass(color='blue')
Pentagon = ArgumentedSubclass(sides=5)
def __init__(self, color=None, sides=3):
self.color = color
self.sides = sides
blue_pentagon = Polygon.Pentagon.Blue()
# blue_pentagon.color == 'blue'
# blue_pentagon.sides == 5
"""
def __init__(self, *args, **kwargs):
super().__init__(argumented_subclass, *args, **kwargs)
[docs]class AttributedSubclass(FactoryProperty):
"""
A descriptor that returns an :func:`~classical.subclass.attributed_subclass`
of the owner class when accessed.
It allows a class to have attributes that are its own subclasses
with additional or redefined class attributes:
::
class Paint:
solvent = None
Oil = AttributedSubclass(solvent='turpentine')
Watercolor = AttributedSubclass(solvent='water')
issubclass(Paint.Oil, Paint) # True
Paint.Oil.solvent # 'turpentine'
These properties can be used recursively and in combination with one another.
"""
def __init__(self, **attributes):
super().__init__(attributed_subclass, **attributes)
def _instance_factory(cls, name, *args, **kwargs):
return cls(*args, **kwargs)
[docs]class AutoProperty(FactoryProperty):
"""
A descriptor that returns an instance of the owner class when accessed.
The instance is created with the custom arguments
that are passed to the property's constructor.
Acts somewhat like an ``Enum``
::
class Thing:
book = AutoProperty(color='brown', size=5)
pencil = AutoProperty(color='green', size=1)
# both will return instances of Thing when accessed
def __init__(self, color, size):
self.color = color
self.size = size
isinstance(Thing.book, Thing) # True
Thing.book.color # 'brown'
Thing.book is Thing.book # True (the same instance is returned every time)
These properties can be used in a subclass to produce instances of the subclass:
::
class ClassyThing(Thing):
pass
isinstance(ClassyThing.book, ClassyThing) # True
Use ``AutoProperty(...).terminal`` to produce instances
of the **original owner** class inside a subclass:
::
class Thing:
terminal_thing = AutoProperty().terminal
class ClassyThing(Thing):
pass
isinstance(ClassyThing.terminal_thing, ClassyThing) # False
ClassyThing.terminal_thing.__class__ # Thing
"""
def __init__(self, *args, **kwargs):
super().__init__(_instance_factory, *args, **kwargs)