![A programmer working on dual monitors while writing code related to Python descriptors for property management and attribute access in a modern office environment.](/images/blog/python-descriptors-for-property-management-and-attribute-access-leading.jpeg)
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.