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
License:
Copyright (c) 2010-2015 Benjamin Peterson
Released under http://opensource.org/licenses/MIT
"""
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
redirect access to. Defaults to None.
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)