Skip to content

descriptors

Descriptors

Everything defined in a class body are class-attributes (except the docstring). Descriptors are class attributes in Python that manage the attributes in instances. They're essentially reusable properties and serve as a Pythonic way to achieve something similar to setter and getter methods in other programming languages.

An attribute is considered a descriptor when the attribute get, set or delete methods are overwritten. This is similar to using a @property decorator; descriptors are re-usable properties.

For example, consider a custom Decimal class that defines custom get and `set methods for the x and y attributes. The Decimal class simply returns the value in decimal:

class Decimal:

    def __init__(self,  decimals=2):
        self.decimals= decimals

    def __set_name__(self, owner, name):
        self.property_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        d = f".{self.decimals}f"
        return f'{getattr(obj, self.property_name):{d}}'

    def __set__(self, obj, value):
        setattr(obj, self.property_name, value)

    def __delete__(self, obj):
        '''do something '''

class Coordinate:
    x = Decimal(decimals=3)
    y = Decimal()

    def __init__(self, x, y):
         self.x= x
         self.y= y

c = Coordinate(x=1, y=2)

print(c.x, c.y)   #  (1.000, 2.00)

Unfortunately, many examples and tutorials online about Python descriptors are incorrect. Like this one:

class Age:
    def __init__(self, age = 16):
        self.age = age

    def __set__(self, obj, age):
        if not 10 <= age <= 20:
            raise ValueError('Valid age must be in [10, 20]')
        self.age = age

    def __get__(self, obj, type = None):
        return self.age

class Student:
    age = Age()

    def __init__(self, name, age):
        self.name = name
        self.age = age
        print(f'Student Name: {self.name}, Age:{self.age}')

s1 = Student("John", 13)
s2 = Student("Mike", 15)

It may be hard to spot what is wrong the code, but that happens when we check the instances?

print(s1.age, s1.name)   # 15 John
print(s2.age, s2.name)   # 15 Mike

Creating s2 overwrites the value of s1 because it is a class attribute and not an instance attribute.

Validation

Validation descriptors can work with __set__ only, and do not require the __get__ method. We should store the attribute value directly into __dict__, because calling setattr(instance, self.name) could trigger the __set__ method again, leading to infinite recursion if the name was defined in __init__

instance.__dict__[self.name] = value
Cache

Caching can be done efficiently with __get__ only. The idea is to simply store the output of __get__ for later use.

class Book:
    bookentry = BookEntry()

    def __init__(self, isbn, title, author, year):
        self.isbn = isbn
        self.title = title
        self.author = author
        self.year = year

class BookEntry:
    def __init__(self):
        self.cache = {}

    def __get__(self, instance, owner=None):
        entry = self.cache.get(id(instance), None)
        if entry is not None:
            return entry
        entry = self.cache.get(id(instance), None)
        if entry is not None:
            return entry
        entry =  {"bookentry": instance.title,
                   "isbn": instance.isbn,
                   "author": instance.author,
                   "year": instance.year
                    }
        entry = json.dumps(entry)
        self.cache[id(instance)] = entry
        return entry
Lazy descriptor

The following returns the value directly when it is called a second time. The reason why this works is that the name is not found in the object dict, so it looks in the descriptor. The second time is hits the object dict directly.

class LazyProperty:
    def __init__(self, func):
        self._func = func
        self.__name__ = func.__name__

    def __get__(self, obj, cls):
        print("function called")
        result = self._func(obj)
        obj.__dict__[self.__name__] = result
        return result

class MyClass:
    @LazyProperty
    def x(self):
        return 1243567
template
class MyDescriptor:
    def __init__(self, field=""):
        self.field= field
    def __get__(self, obj, owner):           # owner is the class to which instance belongs
        return obj.__dict__.get(self.field)
    def __set__(self, obj, val):             # self is descriptor instance.    
        obj.__dict__[self.field] = val

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

More about descriptors: https://docs.python.org/3/howto/descriptor.html Encapsulation with descriptors https://www.youtube.com/watch?v=5GG4jBxj4Ys