Crafting Custom Python Descriptors

Python descriptors are a powerful, yet often underutilized, feature that provides a way to customize attribute access in your classes. They are the magic behind many of Python's elegant features, such as properties, static methods, and class methods. Understanding and leveraging descriptors can lead to more robust, reusable, and maintainable code. This post will guide you through the descriptor protocol, how to implement your own descriptors, and explore practical use cases that can enhance your Python projects.

The Descriptor Protocol

At its core, a descriptor is any object that defines one or more of the following special methods: __get__(), __set__(), or __delete__(). When a class attribute is an instance of a descriptor, its special binding behavior is triggered upon attribute lookup. Let's break down each of these methods.

__get__(self, instance, owner)

The __get__() method is called when you access an attribute. It can return the value of the attribute, or it can raise an AttributeError. The parameters are:

  • self: The instance of the descriptor itself.
  • instance: The instance of the object the attribute was accessed from.
  • owner: The owner class.

__set__(self, instance, value)

The __set__() method is called when an attribute is set. It doesn't return anything. The parameters are:

  • self: The instance of the descriptor itself.
  • instance: The instance of the object the attribute was set on.
  • value: The value to set the attribute to.

__delete__(self, instance)

The __delete__() method is called when an attribute is deleted. It doesn't return anything. The parameters are:

  • self: The instance of the descriptor itself.
  • instance: The instance of the object the attribute was deleted from.

Implementing a Custom Descriptor

Now that we understand the protocol, let's create a simple descriptor that logs attribute access.

class LoggedAttribute:
    def __init__(self, name):
        self.name = name

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

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

    def __delete__(self, instance):
        print(f"Deleting attribute {self.name}")
        del instance.__dict__[self.name]

class MyClass:
    x = LoggedAttribute("x")

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

obj = MyClass(10)
print(obj.x)
obj.x = 20
del obj.x

In this example, the LoggedAttribute class is a descriptor. When you access, set, or delete the x attribute of a MyClass instance, the corresponding methods in LoggedAttribute are called.

Practical Use Cases and Examples

Descriptors are not just a theoretical concept; they have many practical applications.

Validation

Descriptors can be used to validate the values of attributes. For example, you can create a descriptor that ensures an attribute is always a positive number.

class PositiveNumber:
    def __init__(self, name):
        self.name = name

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

    def __set__(self, instance, value):
        if not isinstance(value, (int, float)) or value <= 0:
            raise ValueError(f"{self.name} must be a positive number")
        instance.__dict__[self.name] = value

class Product:
    price = PositiveNumber("price")
    quantity = PositiveNumber("quantity")

    def __init__(self, price, quantity):
        self.price = price
        self.quantity = quantity

try:
    product = Product(10, 5)
    print(product.price, product.quantity)
    product.price = -10
except ValueError as e:
    print(e)

Lazy Loading

Descriptors can be used to defer the loading of expensive resources until they are actually needed. This is known as lazy loading.

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = self.func(instance)
        setattr(instance, self.func.__name__, value)
        return value

class Website:
    def __init__(self, url):
        self.url = url

    @LazyProperty
    def content(self):
        print("Fetching content...")
        # In a real-world scenario, this would be a network request
        return f"Content of {self.url}"

website = Website("https://example.com")
print(website.content)
print(website.content) # The content is fetched only once

Type Checking

Descriptors can be used to enforce type checking for attributes.

class Typed:
    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type

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

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"{self.name} must be of type {self.expected_type}")
        instance.__dict__[self.name] = value

class Person:
    name = Typed("name", str)
    age = Typed("age", int)

    def __init__(self, name, age):
        self.name = name
        self.age = age

try:
    person = Person("Alice", 30)
    print(person.name, person.age)
    person.age = "thirty"
except TypeError as e:
    print(e)

Conclusion

Python descriptors are a powerful tool for creating more robust and maintainable code. By understanding the descriptor protocol and its practical applications, you can write more elegant and efficient Python code. While they may seem complex at first, the benefits of using descriptors for tasks like validation, lazy loading, and type checking are well worth the investment in learning them.

Resources

Author

Efe Omoregie

Efe Omoregie

Software engineer with a passion for computer science, programming and cloud computing