Daily Python - Descriptors

Daily Python - Descriptors

By Maximus Meadowcroft | December 29, 2024


Demystifying Python Descriptors

Have you ever wondered how @property works in Python? Or how Django models automatically validate and manage data fields?

Python uses descriptors for this. Learning them well allows you to write better classes that are also more powerful.

What Are Descriptors?

Descriptors are objects that define the behavior of attribute access (getting, setting, or deleting) in a class. To create a descriptor, you implement one or more of these special methods into a class:

  • __get__(self, instance, owner): Defines behavior for attribute access.
  • __set__(self, instance, value): Defines behavior for attribute assignment.
  • __delete__(self, instance): Defines behavior for attribute deletion.

In simpler terms, descriptors allow you to intercept and customize what happens when attributes of a class are accessed or modified.

A Simple Read-Only Descriptor

Here’s an example of a descriptor that allows only reading a constant value:

class ReadOnly:  
    def __init__(self, value):  
        self.value = value  

    def __get__(self, instance, owner):  
        return self.value  

    def __set__(self, instance, value):  
        raise AttributeError  

class Example:  
    constant = ReadOnly(42)  

obj = Example()  
print(obj.constant)  
obj.constant = 100   
print(obj.constant)

Output:

42

Traceback (most recent call last):
  File "...", line 16, in <module>
    obj.constant = 100   # Raises AttributeError
    ^^^^^^^^^^^^
  File "...", line 9, in __set__
    raise AttributeError
AttributeError

This descriptor prevents changes to the constant attribute by raising an error in the __set__ method.

Why Use Descriptors?

Attribute validation

Ensure values meet specific criteria.

class PositiveNumber:  
    def __get__(self, instance, owner):  
        return instance.__dict__.get(self.name, 0)  

    def __set__(self, instance, value):  
        if value < 0:  
            raise ValueError("Value must be positive")  
        instance.__dict__[self.name] = value  

    def __set_name__(self, owner, name):  
        self.name = name  

class Account:  
    balance = PositiveNumber()  

account = Account()  
account.balance = 100 
print(f"{account.balance = }")  
account.balance = -50

Output:

Traceback (most recent call last):
  File "...", line 19, in <module>
    account.balance = -50  # Raises ValueError
    ^^^^^^^^^^^^^^^
  File "...", line 7, in __set__
    raise ValueError("Value must be positive")
ValueError: Value must be positive

account.balance = 100

Lazy evaluation

Compute values only when accessed.

class LazyAttribute:
    def __get__(self, instance, owner):
        print("Computing value...")
        return 42

class Example:
    attribute = LazyAttribute()

obj = Example()
print(obj.attribute)

Output:

Computing value...
42

Reusability

Share logic across multiple classes.

class NonEmptyString:  
    def __get__(self, instance, owner):  
        return instance.__dict__.get(self.name, "")  

    def __set__(self, instance, value):  
        if not value:  
            raise ValueError("String cannot be empty")  
        instance.__dict__[self.name] = value  

    def __set_name__(self, owner, name):  
        self.name = name  

class Person:  
    name = NonEmptyString()  

class Animal:  
    name = NonEmptyString()  

person = Person()  
person.name = "Alice"  # Works fine  
person.name = ""       # Raises ValueError

Encapsulation

Customize access while hiding implementation details.

Example - Numeric Range Validation

Here is an example of a descriptor that ensures values fall within a certain range when an attribute is set:

class RangeValidator:  
    def __init__(self, min_value, max_value):  
        self.min_value = min_value  
        self.max_value = max_value  

    def __get__(self, instance, owner):  
        return instance.__dict__.get(self.name)  

    def __set__(self, instance, value):  
        if not (self.min_value <= value <= self.max_value):  
            raise ValueError(f"Not between {self.min_value} and {self.max_value}")  
        instance.__dict__[self.name] = value  

    def __set_name__(self, owner, name):  
        self.name = name  

class Product:  
    price = RangeValidator(0, 1000)  

product = Product()  
product.price = 500  # Works fine  
product.price = -10  # Raises ValueError

Real World Applications

  • @property and @staticmethod: Both are implemented using descriptors.
  • ORMs like Django Models: Fields like IntegerField and CharField use descriptors to validate and manage database records.

Example - Django Models

class CharField:
    def __init__(self, max_length):
        self.max_length = max_length

    def __get__(self, instance, owner):
        return instance.__dict__.get(self.name, "")

    def __set__(self, instance, value):
        if len(value) > self.max_length:
            raise ValueError(f"Value exceeds {self.max_length} characters")
        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name):
        self.name = name

class User:
    username = CharField(max_length=10)

user = User()
user.username = "short"     # Works fine
user.username = "toolongname"  # Raises ValueError

This is how Django's ORM works.

Example - Cached Property

Here’s an example of a descriptor that caches the result of an expensive computation:

class CachedProperty:
    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = self.func(instance)
        instance.__dict__[self.name] = value
        return value

class Data:
    @CachedProperty
    def expensive_computation(self):
        print("Computing...")
        return sum(i * i for i in range(1000000))

data = Data()
print(data.expensive_computation)  # Computes and caches the result
print(data.expensive_computation)  # Returns cached result

Example - Logging Attribute Access

Here’s how to implement a logger using descriptors:

class Logger:
    def __get__(self, instance, owner):
        print(f"Accessing attribute in {owner}")
        return instance.__dict__.get(self.name)

    def __set__(self, instance, value):
        print(f"Setting {self.name} to {value}")
        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name):
        self.name = name

class Example:
    attribute = Logger()

obj = Example()
obj.attribute = 10  # Logs: Setting attribute to 10
print(obj.attribute)  # Logs: Accessing attribute

Conclusion

Descriptors are a powerful tool in Python that allow control over attribute behavior. Whether you’re validating data, caching expensive computations, or enhancing attribute access, descriptors provide a clean and reusable way to handle complex logic. By understanding and leveraging descriptors, you can write more Pythonic and maintainable code.