Back to Posts
A programmer working on dual monitors while writing code related to Python descriptors for property management and attribute access in a modern office environment.

Master Python Descriptors for Property and Attribute Access

By Alyce Osbourne

Have you ever been curious about how Python’s @property actually works? How does @classmethod recognize that the first argument is a class? And what about how dataclasses.field transforms the given field into a property? Let’s dive in and take a look at what makes descriptors tick!

What is a descriptor?

Python offers numerous ways to interact with and modify its data model. One notable feature is operator overloading, which allows us to define custom behaviour for standard operators, such as addition, when used with our instances. However, the dot operator (.) used for accessing variables or methods does not have a corresponding dunder method for overloading. To customize the behaviour of attribute access via the dot operator, we need to use descriptors. Descriptors are objects that mediate how attributes are retrieved, stored, or computed.

Properties

A descriptor that many folks might be familiar with is the @property decorator, and it’s a great one to start with when explaining how descriptors work.

The @property allows us to define a getter, setter, and deleter for some property we wish to define. Let’s take temperature conversion as an example.

class Temperature:
    def __init__(self, kelvin: float):
        self.kelvin = kelvin

    @property
    def celsius(self):
        return self.kelvin - 273.15

    @celsius.setter
    def celsius(self, value: float):
        self.kelvin = value + 273.15

    @property
    def fahrenheit(self):
        return self.kelvin * 9/5 - 459.67

    @fahrenheit.setter
    def fahrenheit(self, value: float):
        self.kelvin = (value + 459.67) * 5/9

Here we have properties that can convert our kelvin into other units of measurement. But do you see a pattern? Every conversion follows the same pattern: they each collect from the same attribute and perform a set of operations to convert the target attribute to and from a unit of measurement. This feels like a lot of repetition, and it will only get worse if we add more conversions.

So, let’s see how we might approach this problem using descriptors.

from typing import Callable

class UnitConversion[I, T, B]:
    # This can be used to convert temperature, volume, length etc
    def __init__(
            self,
            converter_to_base: Callable[[T], B], # takes a given value and converts to the base
            converter_from_base: Callable[[B], T], # takes the base and converts the value
            target_attr: str = 'base'
    ):
        self.converter_to_base = converter_to_base
        self.converter_from_base = converter_from_base
        self.target_attr = target_attr

    def __get__(self, instance: I, owner: type[I]) -> B:
        if instance is None:
            return self
        return self.converter_from_base(getattr(instance, self.target_attr))

    def __set__(self, instance: I, value: B):
        if instance is None:
            raise AttributeError("Cannot set property on class")
        setattr(instance, self.target_attr, self.converter_to_base(value))

    def __delete__(self, instance: I):
        raise AttributeError("Cannot delete a generated property.")

class Temperature:
    def __init__(self, base: float):
        self.base = base

    celsius = UnitConversion(
        lambda c: c + 273.15,
        lambda k: k - 273.15
    )
    fahrenheit = UnitConversion(
        lambda f: (f - 32) * 5/9 + 273.15,
        lambda k: (k - 273.15) * 9/5 + 32
    )
    rankine = UnitConversion(
        lambda r: r * 5/9,
        lambda k: k * 9/5
    )
    reaumur = UnitConversion(
        lambda re: re * 5/4 + 273.15,
        lambda k: (k - 273.15) * 4/5
    )
    newton = UnitConversion(
        lambda n: n * 100/33 + 273.15,
        lambda k: (k - 273.15) * 33/100
    )

We have abstracted our conversion mechanism into its own descriptor and, by doing so, created a reusable way to define the same behaviour as a property. If we wished, we could use it to define other units of measurement, such as length or volume, since the core behaviour applies to any type of unit conversion.

The getter and setter allow us to convert to and from our chosen conversion, while the deleter prevents deletion attempts.

This would also provide a prime opportunity to make use of a Strategy pattern, perhaps using some of the enum methods I described in my post here.

Other examples of descriptors in the standard library

There are many descriptors in Python that magically do their work quietly in the background. For instance, @classmethod passes the owner to the method as cls, but instance methods themselves are also descriptors (BoundMethod), with instance passed as self.

dataclasses.field is also a fairly handy and commonly used descriptor, allowing us to define if the attached attribute is part of the __init__ method, can be used for comparisons, etc.

Descriptors in the wild

You can also find interesting and often quite clever uses of them in libraries such as Pydantic and SQLAlchemy.

With pydantic.Field, they allow for inlined data validation, and in their setters, they will raise an error if invalid data is passed.

SQLAlchemy uses them for mapping out the schema for tables. These include relationships, columns, etc.

When should you use a descriptor?

If you find you are writing properties to map one value to another or to grab data from an external source, and this pattern repeats throughout your application, you might find abstracting them into a descriptor can make it easier to update and maintain since the core logic is located in a single class. They can help DRY up your code and help you adhere to the open/closed principle.

When should you avoid them?

If you have a single property or if a method is better suited to the task. A downside of using descriptors is that they hide implementation details and can break the rule of least surprise, especially if setting an attribute raises an error. It’s uncommon to think you have to wrap attribute access within a try-except block in those cases. If used incorrectly, they can lead to unnecessary complications and make it harder to understand the execution flow, especially when classes containing descriptors are subclassed.

Final thoughts

Descriptors are mediators that allow us to overload the dot operator, thus giving us control over attribute access for the given property. They can enable us to abstract common property-based logic in a clean, maintainable way, at the risk of being just a little bit magical.

Improve your code with my 3-part code diagnosis framework

Watch my free 30 minutes code diagnosis workshop on how to quickly detect problems in your code and review your code more effectively.

When you sign up, you'll get an email from me regularly with additional free content. You can unsubscribe at any time.

Recent posts