5
\$\begingroup\$

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)
\$\endgroup\$
5
\$\begingroup\$

I'll talk about the little stuff first

Name shadowing

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):

Code comments

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.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.