# Descriptor class with advanced (inspect/metaclass) functionality

Having answered this question over on Programmers.SE, I found myself wondering how much effort it would be to write a descriptor that can automatically figure out what the 'destination' attribute would be, e.g.:

class Demo(object):

foo = Something()


would redirect access to the foo attribute to _foo by default. To do this, I looked into inspect to determine the name to which the descriptor is being assigned (see BaseDescriptor._get_name).

Given that the BaseDescriptor on its own is pretty pointless, I also created a metaclass to enforce that at least one of the descriptor protocol methods is implemented in any sub-classes of it (which required borrowing part of six to keep it 2.x-and-3.x-compliant).

I've included some basic doctests and a demo sub-class to enforce attribute types; what do you think? Using inspect makes the auto-naming somewhat fragile (it will probably only work with CPython, and assumes that the descriptor is assigned on a single line), but what else haven't I thought of?

from inspect import currentframe, getouterframes

def with_metaclass(meta, *bases):
"""Create a base class with a metaclass.

Source:
https://pypi.python.org/pypi/six

"""
class metaclass(meta):
def __new__(cls, name, this_bases, d):
return meta(name, bases, d)
return type.__new__(metaclass, 'temporary_class', (), {})

class EnforceDescriptor(type):
"""Ensures that at least one descriptor method is implemented.

Notes:
Requires at least one of the three descriptor protocol methods
(__get__, __set__ and __delete__) to be implemented.

Attributes:
REQUIRED (tuple): The names of the required methods.

Raises:
TypeError: If none of the three REQUIRED methods are implemented.

Example:

>>> class TestClass(with_metaclass(EnforceDescriptor)):
...     pass
Traceback (most recent call last):
...
TypeError: 'TestClass' must implement at least one descriptor method

"""

REQUIRED = ('__get__', '__set__', '__delete__')

def __new__(cls, name, bases, attrs):
if all(attrs.get(name) is None for name in cls.REQUIRED):
msg = '{!r} must implement at least one descriptor method'
raise TypeError(msg.format(name))
return super(EnforceDescriptor, cls).__new__(cls, name, bases, attrs)

class BaseDescriptor(with_metaclass(EnforceDescriptor)):
"""Descriptor base class, providing basic set, get and del methods.

Notes:
Attempts to determine an appropriate name for the destination
attribute if one is not explicitly supplied, defaulting to the
name to which the descriptor is assigned with a leading
underscore. This uses inspect and is somewhat fragile.

Arguments:
name (str, optional): The internal 'destination' attribute to

Raises:
ValueError: If the name isn't explicitly supplied and can't be
determined by inspection.

Example:

>>> class TestClass(object):
...     foo = BaseDescriptor()

>>> inst = TestClass()
>>> inst.foo = 'bar'
>>> inst._foo
'bar'
>>> inst.foo
'bar'
>>> del inst.foo
>>> inst.foo
Traceback (most recent call last):
...
AttributeError: 'TestClass' object has no attribute '_foo'

"""

def __init__(self, name=None):
self.name = self._get_name() if name is None else name

def _get_name(self):
"""Attempt to determine an appropriate name by inspection."""
try:
code = next(frame for frame in getouterframes(currentframe())
if frame[3] not in ('_get_name', '__init__'))[4]
name = '_{}'.format(code[0].split('=')[0].strip())
except (IndexError, OSError, TypeError):
raise ValueError('name could not be determined by inspection '
'so must be supplied')
return name

def __get__(self, obj, typ=None):
return getattr(obj, self.name)

def __set__(self, obj, val):
setattr(obj, self.name, val)

def __delete__(self, obj):
delattr(obj, self.name)

class TypedAttribute(BaseDescriptor):
"""Descriptor to limit attribute values to a specified type.

Arguments:
type_ (type or tuple, optional): Valid type of the attribute, or
a tuple of valid types. Defaults to object.

Example:

>>> class TestClass(object):
...     num = TypedAttribute(type_=(int, float))

>>> inst = TestClass()
>>> inst.num = 50
>>> inst.num
50
>>> inst.num = 'bar'
Traceback (most recent call last):
...
TypeError: value must be one of (int, float)
>>> inst.num
50

"""

def __init__(self, name=None, type_=object):
super(TypedAttribute, self).__init__(name)
self.type_ = type_

def __set__(self, obj, val):
if not isinstance(val, self.type_):
if isinstance(self.type_, tuple):
types = ', '.join(typ.__name__ for typ in self.type_)
msg = 'value must be one of ({})'.format(types)
else:
msg = 'value must be {}'.format(self.type_.__name__)
raise TypeError(msg)
super(TypedAttribute, self).__set__(obj, val)

if __name__ == '__main__':
import doctest
doctest.testmod(verbose=True)


I'll talk about the little stuff first

Personal preference, I'm not a huge fan of using the same variable name in your generator expression as you did in the parameters to __new__ in your EnforceDescriptor metaclass. I'd prefer something like

if all(attrs.get(func_name) is None for func_name in cls.REQUIRED):


Your method of getting the name is a little confusing - I had to read it about three times and check the inspect docs to fully understand how it works (of course, anything involving inspection is usually a little tricky). I, and presumably anyone who wanted to touch this in the future, would appreciate some comments as to why things happen why they do. For example,

code = next(frame for frame in getouterframes(currentframe())
if frame[3] not in ('_get_name', '__init__'))[4]


could use a comment indicating that you're finding the first frame that wasn't this function or the __init__ function of the descriptor. Then maybe an explanation about why the 4th and 5th items in that frame are important.

### Name unused variables with _

I'd rather see

def __get__(self, obj, _):
return getattr(obj, self.name)


than

def __get__(self, obj, typ=None):
return getattr(obj, self.name)


My IDE highlights typ=None so I know it's not used, and this is a simple function, but using _ makes it even easier to see that parameter can be ignored.

Now here's the more interesting things, imo.

## Better way of naming the descriptor's attribute

Like you said, using inspect is pretty fragile. It'll likely fail on non-CPython implementations, and it's pretty hard to read. I think a better way to do this is with a metaclass that enforces naming of all descriptors, like so.

class EnforceNamedDescriptors(type):
"""Ensures that every instance of a BaseDescriptor has a name for
its attribute.
"""

def __new__(cls, name, bases, attrs):
for attr_name, attr_value in attrs.items():
if (isinstance(attr_value, BaseDescriptor) and
not hasattr(attr_value, "name")):
attr_value.name = "_{}".format(attr_name)
return super(EnforceNamedDescriptors, cls).__new__(cls, name, bases, attrs)


then just make all classes that are going to use this functionality like so

class TestClass(with_metaclass(EnforceNamedDescriptors):
...


or even make a mixin class like that so you don't have to do that every time. You could still include the _get_name function as a fallback, in case you don't want this auto-naming to work every time. Then you'd probably change BaseDescriptor to look sort of like

def __init__(self, name=None):
if name is not None:
self.name = name
# Otherwise fall back to _get_name and EnforceNamedDescriptors

def __get__(self, obj, typ=None):
if not hasattr(self, 'name'):
self.name = self._get_name()
return getattr(obj, self.name)


One possible issue with this is if, for some ungodly reason, someone wanted to do

class TestClass(with_metaclass(EnforceNamedDescriptors)):
__mangled = TypedAttribute(type=(int, float))


you might run into some weird issues... but I figure if someone is doing this much weirdness they probably deserve whatever is coming to them.

## Other ways of avoiding using inspection

I think that you might also be able to avoid using inspection AND metaclasses if you don't mind a little overhead on the first time access of the attribute's value. If you pull the code from the meta class into the __get__ of BaseDescriptor you could do something like

def __get__(self, obj, typ=None):
if not hasattr(self, 'name'):
for name, value in obj.__dict__.items():
if (isinstance(value, BaseDescriptor) and not
hasattr(value, 'name')):
value.name = "_{}".format(name)
return getattr(obj, self.name)


which would add names to every descriptor the first time you access one. This might get a little unwieldy if there were a ton of these in a given class, but I suspect that this would be okay in most use cases.