Python Descriptors: Building Reusable Attribute Logic

buddha gautambuddha gautam
5 min read

Python Descriptors: Building Reusable Attribute Logic (Or, How I Learned to Stop Worrying and Love the __get__)

Alright, settle in, folks. Today we're diving into Python descriptors. Not because some LinkedIn guru told me it's the "next big thing" (eye roll), but because they're genuinely useful for building reusable, elegant, and frankly, kinda badass attribute logic. If you're tired of copy-pasting validation code across your classes, or you're just curious about the magic behind @property, then buckle up. We're going deep.

What in the Holy Hell is a Descriptor?

In essence, a descriptor is a class that manages attribute access. Woah, slow down! Think of it as a gatekeeper for your object's attributes. Instead of directly accessing the attribute, you go through the descriptor, which can then perform extra logic like validation, lazy loading, or even tracking changes.

Descriptors are defined by implementing one or more of the following special methods (the descriptor protocol):

  • __get__(self, instance, owner): Called when the descriptor's attribute value is accessed.
  • __set__(self, instance, value): Called when the descriptor's attribute value is assigned.
  • __delete__(self, instance): Called when the descriptor's attribute is deleted.
  • __set_name__(self, owner, name): Called when the owning class is created, giving the descriptor a chance to know its attribute name. (Python 3.6+)

These methods let you intercept attribute access, assignment, and deletion, giving you fine-grained control over how your objects behave.

The @property Decorator: Descriptors in Disguise

You've probably used @property before, right? It's that handy decorator that lets you define getter, setter, and deleter methods for an attribute. Guess what? It's built on descriptors!

Let's break it down:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        """I'm the 'radius' property."""
        print("Getting radius")
        return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive")
        print("Setting radius")
        self._radius = value

    @radius.deleter
    def radius(self):
        print("Deleting radius")
        del self._radius

Under the hood, @property creates a property object, which is a descriptor. When you access circle.radius, Python calls the __get__ method of the property object, which in turn calls your getter method. The same goes for setting and deleting the attribute.

@radius.setter and @radius.deleter are just syntactic sugar to create new property objects with the setter and deleter methods attached, respectively. They essentially replace the original property object associated with radius.

Roll Your Own: Custom Descriptor Examples

Okay, enough theory. Let's get our hands dirty with some custom descriptors.

Example 1: Validating Attributes

Tired of writing the same validation logic over and over? A descriptor can help.

class ValidatedString:
    def __init__(self, min_length=0, max_length=None):
        self.min_length = min_length
        self.max_length = max_length
        self.name = None # Assigned by __set_name__

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

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

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} must be a string")
        if len(value) < self.min_length:
            raise ValueError(f"{self.name} must be at least {self.min_length} characters long")
        if self.max_length is not None and len(value) > self.max_length:
            raise ValueError(f"{self.name} must be at most {self.max_length} characters long")
        instance.__dict__[self.name] = value


class User:
    name = ValidatedString(min_length=3, max_length=50)
    email = ValidatedString()

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

Now, whenever you try to assign an invalid value to user.name or user.email, the ValidatedString descriptor will raise an exception. Boom. Reusable validation.

Example 2: Lazy Loading

Want to defer loading an expensive attribute until it's actually needed? Descriptors to the rescue!

import time

class LazyLoad:
    def __init__(self, func):
        self.func = func
        self.name = None # Assigned by __set_name__

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        print(f"Loading {self.name}...")
        value = self.func(instance)
        instance.__dict__[self.name] = value  # Cache the result
        return value


class DataProcessor:
    @LazyLoad
    def large_dataset(self):
        # Simulate loading a large dataset
        print("Actually loading...")
        time.sleep(2)
        return list(range(1000000))

    def __init__(self):
        pass

The first time you access data_processor.large_dataset, the LazyLoad descriptor will call the large_dataset method, load the data, and cache it in the instance's __dict__. Subsequent accesses will retrieve the cached value directly, avoiding the expensive loading process. Efficiency!

Real-World Applications: Beyond the Hype

Descriptors aren't just academic exercises. They're used in real-world applications all the time.

  • ORMs (Object-Relational Mappers): ORMs like SQLAlchemy use descriptors to map database columns to object attributes, handle data validation, and track changes.
  • Validation Libraries: Libraries like attrs and marshmallow leverage descriptors for defining and validating object attributes.
  • Frameworks: Django uses descriptors in its model fields to handle data conversion and validation.

Basically, if you're working on a complex Python project, chances are you're already using descriptors, even if you don't realize it.

Common Pitfalls and Best Practices

Descriptors can be powerful, but they can also be tricky. Here are a few things to keep in mind:

  • Descriptor Types: There are two main types of descriptors: data descriptors (with both __get__ and __set__) and non-data descriptors (with only __get__). Data descriptors take precedence over instance attributes, while non-data descriptors are shadowed by instance attributes. Understand the difference!
  • __set_name__: Don't forget to use __set_name__ (Python 3.6+) to get the attribute name. It makes your descriptors more reusable and less prone to errors.
  • Avoid Infinite Recursion: Be careful when accessing or setting attributes within your descriptor methods. You can easily create infinite recursion if you're not careful. Use the instance's __dict__ directly to avoid triggering the descriptor again.
  • Keep it Simple: Don't overcomplicate your descriptors. If you find yourself writing a ton of code, consider breaking it down into smaller, more manageable functions or classes.

Conclusion: Embrace the Descriptor

Python descriptors are a powerful tool for building reusable and maintainable code. They might seem a bit intimidating at first, but once you understand the basics, you'll be able to write more elegant and efficient Python code. So, go forth and embrace the descriptor! And remember, don't let the LinkedIn influencers tell you what to do. Think for yourself, write good code, and stay slightly unhinged. You'll go far.

0
Subscribe to my newsletter

Read articles from buddha gautam directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

buddha gautam
buddha gautam

Python, Django, DevOps(can use ec2 and docker lol).