Python Descriptors

Descriptors are often overlooked part of Python. We are all aware of @property. Descriptors are what power it. So lets take a look at them.

Why Descriptors

Look at this code:

class Person:
  def __init__(self, name):
      self._name = name
  
  @property
  def name(self):
    return self._name.capitalize()
  
  @name.setter
  def name(self, value):
    self._name = value

  @name.deleter
  def name(self):
    del self._name


p1 = Person('tek') 
print(f'name:{p1.name}') 

In this toy example, the getter always capitalizes the name before returning. Now if we wanted to implement exactly same functionality in another class, say Pet, where the class also has a name property and it whould always be capitalized when returned, we will have to implement the code again for the getter.

Here is where Descriptors come in handy. You can setup a descriptor to do ecxactly this, capitalize the name attribute. And then use it in whichever class you want.

Formal Explanation

Descriptors are implemented as classes, which means that they are self contained and reusable.

class MyClass(object):
    myAtrribute = MyDescriptor()

To implement a descriptor, there are 4 different methods that we can implement:

  • __get__ is called when accessing the attribute. This customizes the retrieval of attribute.
    __get(self, instance, owner) -> here self is the descriptor object itself. instance is where the attribute is present. owner is the class that instance belongs to. instance.myAttribute will invoke __get__, returning a value.
  • __set__ is called when we try to set an instance attribute. This customizes the setting of instance attribute.
    __set(self, instance, value) -> here self is the descriptor object itself. instance is where the attribute is being set. Notice, the attribute name is not required as Python does nto really care. instance.myAttribute = value will invoke __set__, returning None.
  • __delete__ is called when the attribute is being deleted. This customizes the deletion of attribute.
    __delete__(self, instance) -> del instance.myAttribute will invoke __delete__, returning None.
  • __set_name__ is called once when the class object is created (Python 3.6+). This provides functionality that was earlier only possible through metaclasses.

Types of Descriptors:

Data Descriptor: Any descriptor class that implements either __set__ or __delete__ methods or both is a Data descriptor.
Non Data Descriptor: Any descriptor class that DOES NOT implement __set__ AND __delete__ methods is a Non Data descriptor. So essentially, this only implements __get__ method.

Descriptor Precedence

Descriptors are not all created equal. They follow a descriptor precedence. So when you try to access an attribute in Python, it goes through a look-up process. The actual look-up is a book more detailed, but here is the simplified version.

  1. The first thing it looks at is if this attribute is a data descriptor. If it is a data descriptor, then it is going to call the corresponding method for whatever you are trying to do, be it accessing, retrieving or deleting.
  2. If it is not a data-descriptor, then it is going to check if this attribute is in the instance’s dictionary or __dict__. If the attribute is present in __dict__, it will return or change the value or do whatever it is trying to do.
  3. Finally, if the attribute is not in __dict__ and the attribute is a non-data-descriptor, then it will call the non-data-descriptor for you.

The consequence of this lookup order is that Non-Data-Descriptors like functions/methods can be overridden by instances.

Non Data Descriptors

Non Data Descriptors:

  • staticmethod
  • classmethod
  • abc.abstractmethod
  • functools.partialmethod

All the above 4 things can be implemented using non-data-descriptors. All we need to do is implement the __get__ and implement the functionality for each of these.

Data Descriptors

So when we want to create descriptors, we do like so:

class MyClass(object):
    myAtrribute = MyDescriptor()

note that we created a class attribute to create a descriptor. And we use myAttribute to initialize an instance of the descriptor MyDescriptor.

class Celsius(object):
    def __init__(self, value=0.0):
        self.value = float(value)
    def __get__(self, instance, owner):
        return self.value
    def __set__(self, instance, value):
        self.value = float(value)

class Temperature(object):
    celsius = Celsius()

So, why do I need descriptor class?

Your descriptor ensures you always have a float for this class attribute of Temperature, and that you can’t use del to delete the attribute:

>>> t1 = Temperature()
>>> del t1.celsius
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: __delete__

Equivalence with @property:
The above implemntation using descriptor is exactly equivalent to:

class Temperature(object):
    _celsius = 0.0
    @property
    def celsius(self):
        return type(self)._celsius
    @celsius.setter
    def celsius(self, value):
        type(self)._celsius = float(value)

You can only get or set (only floats). If you try to delete, you will get attribute error.

>>> t1 = Temperature()
>>> t1.celsius
0.0
>>> t1.celsius = 1
>>> 
>>> t1.celsius
1.0
>>> t2 = Temperature()
>>> t2.celsius
1.0
>>> del t2.celsius
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: __delete__
>>> t1.celsius = '0x02'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in __set__
ValueError: invalid literal for float(): 0x02



No Comments


You can leave the first : )



Leave a Reply

Your email address will not be published. Required fields are marked *