Python Descriptors: A Complete Tutorial with Code Examples

Apr 25, 2024
/
13 min read
Python Descriptors: A Complete Tutorial with Code Examples
Oleksandr Zaiets
Oleksandr Zaiets
Senior Python Backend Engineer

If you’re new to using Python descriptors or want to know more about them, this short and straightforward article will help you start implementing and understanding their powerful capabilities.

Descriptors in Python are useful for managing how class attributes are accessed, modified, and deleted. They form the foundation for features like properties, class methods, and static methods.

This article provides a solid understanding of what they are, how they work, and when and why you should use them. By the end of this Python descriptors tutorial, you will know:

  • What Python descriptors are
  • How descriptors work in Python
  • When to use Python descriptors
  • Code examples that you can copy and run
  • Bounded Descriptors with their Usage Scenario
  • How descriptors simplify API development

Services
Django: The Best Quality-Value Ratio.
 

You’ll see how DjangoStars Python developers utilize descriptors to solve common problems in software development, with insights into bounded descriptors and their applications in frameworks like Django.

What Are Descriptors in Python?

Descriptors are tools in Python used to manage how you can access, change, or remove properties. They work through three main methods:

  • __get__(self, instance, owner). Accesses the attribute. It returns the value
  • __set__(self, instance, value). Sets the attribute. Does not return anything
  • __delete__(self, instance). Deletes the attribute. Does not return anything

Python descriptors examples

A Python descriptor can implement any combination of these methods. Using these methods, you can control and customize how properties behave in your code. Uoi can add specific actions with descriptors whenever properties are accessed, changed, or deleted.

Let’s create a simple descriptor in Python that only implements __get__ method. This descriptor will handle retrieving a value. Make a new file and insert this code:

class NameDescriptor:
    def __get__(self, obj, objtype=None):
        return "John Doe"

class Person:
    name = NameDescriptor()

# Usage
person = Person()
print(f"Name: {person.name}")  # Outputs "Name: John Doe"

To execute Python code, you can use the command python filename.py in your terminal or use any online editor.

Add a __set__ method to extend your descriptor’s functionality to modify the attribute value:

class NameDescriptor:
    def __init__(self):
        self._name = "John Doe"
    
    def __get__(self, obj, objtype=None):
        return self._name
    
    def __set__(self, obj, value):
        if isinstance(value, str):
            self._name = value
        else:
            raise ValueError("Name must be a string")

class Person:
    name = NameDescriptor()

# Usage
person = Person()
print(f"Name: {person.name}")  # Outputs "Name: John Doe"
person.name = "Jane Doe"
print(f"Name: {person.name}")  # Outputs "Name: Jane Doe"

Python Descriptor Protocol

__get__ method is called to retrieve the value of an attribute. It takes two parameters besides self:

  • instance: the instance of the class from which the attribute is accessed
  • owner: the class that owns the attribute

If __get__ is accessed through the class, the instance will be None.

class UpperCaseAttribute:
    def __init__(self, initial_value):
        self.value = initial_value.upper()

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

class Person:
    name = UpperCaseAttribute('John')

person = Person()
print(person.name)  # Outputs 'JOHN'

In this example, the UpperCaseAttribute ensures that the string value assigned is always converted to uppercase. When the person’s name is accessed, __get__ returns the uppercase version of the initial value.

__set__ method is called to set the value of an attribute. It takes two parameters besides self:

  • instance: the instance of the class on which the attribute is set
  • value: the new value for the attribute
class ValidatedAge:
    def __set__(self, instance, value):
        if not isinstance(value, int) or not (0 <= value <= 120):
            raise ValueError("Please enter a valid age")
        instance.__dict__['age'] = value

class Person:
    age = ValidatedAge()

person = Person()
person.age = 30
print(person.age)  # Outputs 30
person.age = -5  # Raises ValueError: Please enter a valid age

This ValidatedAge Python class descriptor ensures that the age attribute always receives a valid integer within an acceptable range.

__delete__ method is invoked when the attribute is deleted. It takes one parameter besides self:

  • instance: the instance from which the attribute is being deleted
class NonDeletableAttribute:
    def __init__(self, value):
        self.value = value

    def __delete__(self, instance):
        raise AttributeError("This attribute cannot be deleted")

class Person:
    name = NonDeletableAttribute('John')

person = Person()
del person.name  # Raises AttributeError: This attribute cannot be deleted

In this example, the NonDeletableAttribute warns the name attribute from being deleted, protecting its presence in the class instance.

How Descriptors in Python Work

Class’s Method Resolution Order (MRO) rules the process and the nature of the attributes (whether they are data descriptors or not). We’ll explore the detailed process of how Python uses descriptors and decides which attribute to use when multiple possibilities exist.

Services
Python: Battle-tested solutions for any needs.
 

How Attribute Lookup Works with Descriptors

When Python needs to resolve an attribute reference on an object, it follows a precise series of steps. These steps ensure the language’s behavior remains predictable and consistent.

Starting with the class of the instance. Python first identifies the instance’s class where the attribute lookup is initiated.

Calling __getattribute__ method. It’s automatically called. This method is responsible for managing how attributes are accessed from instances.

Scanning the MRO. __getattribute__ uses the MRO to check if the attribute exists on the current and base classes. The MRO is a list that Python creates at the time of class definition, which orders the class and its parents in a way that respects their linearization (following the C3 linearization algorithm).

def find_class_attribute(cls, name):
    for c in cls.__mro__:
        if name in c.__dict__:
            return c.__dict__[name]

Handling Data Descriptors. If the attribute is a data descriptor (an object defining __set__ and/or __delete__), it takes priority, and its __get__ method is invoked if it exists. The lookup process stops here because data descriptors are designed to manage both setting and getting values.

Checking Instance Dictionary. If no data descriptor is found, Python checks the instance’s own __dict__ (if it exists) for the attribute. If the attribute is found here, its value is used.

Handling Non-Data Descriptors and Other Attributes. If the attribute is not in the instance’s __dict__, and a non-data descriptor (only has a __get__ method) or regular method is found in the class or its parents, that is used.

Fallback to __getattr__. If the attribute hasn’t been located yet, and the class defines a __getattr__ method, this method is called to handle the missing attribute.

Returning AttributeError. If none of the above steps resolves the attribute, an AttributeError is returned, indicating that the attribute was not found.

Let’s consider an example:

class A:
    def __getattr__(self, name):
        return f"{name} not found in A, but handled by __getattr__"

class B(A):
    dd_1 = 123  # Regular class attribute

    def __init__(self):
        self.instance_attr = "Instance attribute in B"

b = B()
print(b.dd_1)  # Directly from B's class dictionary
print(b.instance_attr)  # From instance dictionary of b
print(b.some_random_attr)  # Handled by __getattr__ of class A

In this example:

  • dd_1 is found directly in B’s class dictionary.
  • instance_attr is found in the instance dictionary of b.
  • some_random_attr is not found in either B’s class dictionary or b’s instance dictionary, so it triggers A’s __getattr__ method.

Here’s a simple flow diagram to visualize the process:


   +----------------+
   | Attribute Call |
   +----------------+
           |
           V
   +------------------------------+
   | __getattribute__ Method Call |
   +------------------------------+
           |
           V
   +------------------------+
   | Scan MRO for Attribute |
   +------------------------+
           |
           V
   +---------------------------+
   | Check for Data Descriptor |
   +---------------------------+
           |
           |-------------------------+
           |                         |
   Yes     V                         | No
   +---------------------+   +-------------------------+
   | Execute __get__ of  |   | Check Instance __dict__ |
   | Data Descriptor     |   +-------------------------+
   +---------------------+           |
           |                         |
           |                         V
   +---------------------+   +----------------------+
   | Return Value from   |   | Check for Non-data   |
   | Data Descriptor     |   | Descriptor or Method |
   +---------------------+   +----------------------+
           |                         |
           |-------------------------+
           |                         |
           |                         V
           |                 +---------------------+
           |                 | Execute __get__ of  |
           |                 | Non-data Descriptor |
           |                 +---------------------+
           |                         |
           |                         V
           |                 +---------------------+
           |                 | Return Value from   |
           |                 | Non-data Descriptor |
           |                 +---------------------+
           |                         |
           V                         V
   +---------------------+   +---------------------+
   | End of Attribute    |   | If Attribute not    |
   | Lookup with Value   |   | Found, Check for    |
   +---------------------+   | __getattr__         |
                             +---------------------+
                                     |
                                     V
                             +---------------------+
                             | Execute __getattr__ |
                             +---------------------+
                                     |
                                     V
                             +---------------------+
                             | Return Value from   |
                             | __getattr__         |
                             +---------------------+
                                     |
                                     V
                             +----------------------+
                             | AttributeError |
                             | if __getattr__ is    |
                             | not defined          |
                             +----------------------+

When Python Descriptors are Needed

Descriptors can enhance the design and functionality of your software. Below are a few scenarios where using Python descriptors can be beneficial.

Services
Elevate your web development.
 

Managing Shared Attributes Across Instances

When you want a consistent method for accessing or setting values that need to be shared or behave the same way across different instances of a class, descriptors provide a good solution. They encapsulate the logic for attribute access in a single place, so that all interactions with a given property follow predefined rules.

Example Without Descriptors:

class Account:
    def __init__(self, name, balance):
        self.name = name
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

# Every instance needs to replicate validation logic, which is inefficient and error-prone.

Example With Descriptors:

class BalanceDescriptor:
    def __init__(self, balance):
        self._balance = balance

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

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

class Account:
    balance = BalanceDescriptor(0)

# The descriptor handles the logic, improving maintainability and consistency.

In this case, a descriptor simplifies the management of the balance attribute by centralizing the validation logic. It enhances code reusability and consistency across different parts of the application.

Lazy Property Evaluation

Descriptors are good for implementing lazy properties where the value is hard to compute and should only be done when needed. This improves efficiency and saves resources.

Example Without Descriptors:

class DataAnalysis:
    def __init__(self, data):
        self.data = data
        self._result = self._analyze_data()

    def _analyze_data(self):
        # Simulate a time-consuming analysis
        return sum(self.data) / len(self.data)

# The result is computed at instantiation, regardless of whether it is used.

Example With Descriptors:

class LazyProperty:
    def __init__(self, function):
        self.function = function
        self.attribute_name = f"_{function.__name__}"

    def __get__(self, obj, objtype=None):
        if not hasattr(obj, self.attribute_name):
            setattr(obj, self.attribute_name, self.function(obj))
        return getattr(obj, self.attribute_name)

class DataAnalysis:
    def __init__(self, data):
        self.data = data

    @LazyProperty
    def result(self):
        # Simulate a time-consuming analysis
        return sum(self.data) / len(self.data)

analysis = DataAnalysis([10, 20, 30, 40])
print(analysis.result)  # The result property is accessed, triggering the computation
print(analysis.result)  # The result property is accessed again, but this time it returns the cached value without recomputation

Enforcing Type and Value Constraints

When attributes need to meet specific type or value constraints, descriptors make it easy to enforce these requirements.

Example With Descriptors:

class TypeChecked:
    def __init__(self, expected_type, attribute_name):
        self.expected_type = expected_type
        self.attribute_name = attribute_name

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

class Person:
    name = TypeChecked(str, 'name')
    age = TypeChecked(int, 'age')

# Attributes are guaranteed to have correct types, reducing bugs.

What You Lose Without Descriptors:

If you choose not to use descriptors, you may see several issues:

  • Code Duplication. Each class must independently implement and maintain its own logic for handling properties.
  • Increased Risk of Errors. Inconsistencies in handling properties can lead to bugs, especially in larger applications with many developers.
  • Performance Inefficiencies. Without lazy evaluation or caching, applications may perform unnecessary calculations and consume more resources than needed.

How The Django Stars Developers Use Descriptors

At Django Stars company, we have adopted Python descriptors to streamline and enhance various aspects of our projects. Here, I offer an insider look at how our engineers apply descriptors tips and best Python web development practices.

The Concept of Bounded Descriptors

One use of descriptors within our development team is “bounded descriptors.” Typically, a descriptor doesn’t maintain a reference to any specific instance. There’s only one descriptor instance per class that is shared among all instances of that class.

This is memory-efficient as it avoids needing separate descriptor instances for each class instance. However, sometimes a descriptor must interact with a specific instance, especially when extending or customizing behavior at the instance level without using inheritance.

Here’s an example of bounded descriptors:

class BoundableDescriptor:
    def __init__(self, **kwargs):
        self._kwargs = kwargs
        self.instance = None

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        if self.name not in instance.__dict__:
            bounded_descriptor = type(self)(**self._kwargs)
            bounded_descriptor.set_instance(instance)
            instance.__dict__[self.name] = bounded_descriptor
        return instance.__dict__[self.name]

    def set_instance(self, instance):
        self.instance = instance

    def __repr__(self):
        if self.instance is not None:
            return f"<{self.__class__.__name__} object at {id(self)}> bounded to {self.instance}"
        return f"<{self.__class__.__name__} object at {id(self)}>"

Usage Scenario:

class Example:
 descriptor = BoundableDescriptor()

print(Example.descriptor)
example1 = Example()
print(example1.descriptor)

Output:

<BoundableDescriptor object at 127062133300128>
<BoundableDescriptor object at 127062133300272> bounded to <__main__.Example object at 0x738ff551dae0>

This method of using bounded descriptors allows each instance to have a personalized version of the descriptor, which can maintain an instance-specific state.

Simplifying API Development

We can see it in our approach to extending functionalities of frameworks or third-party libraries without complex inheritance hierarchies, particularly within the context of the Django REST framework.

Example:

from rest_framework.generics import CreateAPIView
from rest_framework import serializers
from rest_framework.response import Response

class CreateEndpoint(BoundableDescriptor):
    def __call__(self, request, *args, **kwargs):
        serializer = self.input_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.instance.get_success_headers(serializer.data)
        output_serializer = self.output_serializer(serializer.instance)
        return Response(
            output_serializer.data,
            status=201,
            headers=headers,
        )

class YourCreateApiView(CreateAPIView):
    create = CreateEndpoint(input_serializer=InputSerializer, output_serializer=OutputSerializer)
    
class YourOtherCreateApiView(CreateAPIView):
    create = CreateEndpoint(input_serializer=OtherInputSerializer, output_serializer=OtherOutputSerializer)

CreateEndpoint descriptor replaces the typical create method of a Django view class. This setup allows developers to define the behavior once and then apply it to different views as needed without repeating code or creating a complex inheritance tree.

This approach using bounded descriptors simplifies the code and promotes reusability and modularity. The bounded descriptor has a direct reference to the view’s instance. Thus, it can access instance-specific data and methods, making it a nice tool for customizing behavior on a per-instance basis.

Benefits:

  • Flexibility. Easily adapt and extend the behavior of classes without modifying the original classes.
  • Reusability. Define behavior once and apply it across multiple classes.
  • Memory Efficiency. Maintain a single instance of the descriptor for shared behavior, with instance-specific customization when needed.

Through these techniques and best practices, Django Stars developers have found descriptors valuable for creating cleaner, more maintainable, and scalable code in various Python applications.

Conclusion

Python descriptors help you manage object attributes with custom behavior by providing a robust, low-level descriptor protocol in Python for attribute access. 

Descriptors can enhance code efficiency and maintainability, leading to cleaner and more robust code. 

With our Python development company, you’ll be well-equipped to integrate descriptors into your Python projects and take your programming skills to the next level.

Thank you for your message. We’ll contact you shortly.
Frequently Asked Questions
How Can Python Descriptors Enhance Our Software Development Lifecycle?
Descriptors streamline attribute management, promoting code consistency and reducing errors. This leads to smoother development, stable products, and timely project completions.
What Are the Cost Benefits of Using Python Descriptors in Large-Scale Projects?
Descriptors centralize attribute behavior control, reducing redundancy and errors, which cuts development and maintenance costs across extensive codebases.
Can Python Descriptors Help in Improving the Performance of Our Applications?
Yes, descriptors improve application performance by enabling efficient data loading and caching, which is especially beneficial in high-loaded systems.
How Do Python Descriptors Contribute to Data Security and Integrity in Our Applications?
Descriptors enforce strict validation and type checks before data assignment, enhancing security, preventing vulnerability, and maintaining data integrity.
What Support Do You Provide for Teams New to Implementing Python Descriptors?
We offer detailed documentation, best practices of coding and debugging, training sessions, and expert consultations to use descriptors effectively.

Have an idea? Let's discuss!

Contact Us
Rate this article!
0 ratings, average: 0 out of 5
Very bad
Very good
Subscribe us

Latest articles right in
your inbox

Thanks for
subscribing.
We've sent a confirmation email to your inbox.

Subscribe to our newsletter

Thanks for joining us! 💚

Your email address *
By clicking “Subscribe” I allow Django Stars process my data for marketing purposes, including sending emails. To learn more about how we use your data, read our Privacy Policy .
We’ll let you know, when we got something for you.