Python Descriptors: A Complete Tutorial with Code Examples
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
You’ll see how we utilize descriptors to solve common problems in software development, with insights into bounded descriptors and their applications in frameworks like Django. For further expertise and to enhance your projects, consider contacting us to hire Python programmers.
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 accessedowner
: 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 setvalue
: 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.
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.
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.
- 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.