Introduction
In this lab, you will learn about class decorators in Python and revisit and extend Python descriptors. By combining these concepts, you can create powerful and clean code structures.
In this lab, you will build on previous descriptor concepts and extend them using class decorators. This combination enables you to create cleaner, more maintainable code with enhanced validation capabilities. The files to be modified are validate.py and structure.py.
Implementing Type-Checking with Descriptors
In this step, we're going to create a Stock class that uses descriptors for type checking. But first, let's understand what descriptors are. Descriptors are a really powerful feature in Python. They give you control over how attributes are accessed in classes.
Descriptors are objects that define how attributes are accessed on other objects. They do this by implementing special methods like __get__, __set__, and __delete__. These methods allow descriptors to manage how attributes are retrieved, set, and deleted. Descriptors are very useful for implementing validation, type checking, and computed properties. For example, you can use a descriptor to make sure that an attribute is always a positive number or a string of a certain format.
The validate.py file already has validator classes (String, PositiveInteger, PositiveFloat). We can use these classes to validate the attributes of our Stock class.
Now, let's create our Stock class with descriptors.
First, open the
stock.pyfile in your editor.Once the file is open, replace the placeholder content with the following code:
## stock.py
from structure import Structure
from validate import String, PositiveInteger, PositiveFloat
class Stock(Structure):
_fields = ('name', 'shares', 'price')
name = String()
shares = PositiveInteger()
price = PositiveFloat()
@property
def cost(self):
return self.shares * self.price
def sell(self, nshares):
self.shares -= nshares
## Create an __init__ method based on _fields
Stock.create_init()
Let's break down what this code does. The _fields tuple defines the attributes of the Stock class. These are the names of the attributes that our Stock objects will have.
The name, shares, and price attributes are defined as descriptor objects. The String() descriptor ensures that the name attribute is a string. The PositiveInteger() descriptor makes sure that the shares attribute is a positive integer. And the PositiveFloat() descriptor guarantees that the price attribute is a positive floating-point number.
The cost property is a computed property. It calculates the total cost of the stock based on the number of shares and the price per share.
The sell method is used to reduce the number of shares. When you call this method with a number of shares to sell, it subtracts that number from the shares attribute.
The Stock.create_init() line dynamically creates an __init__ method for our class. This method allows us to create Stock objects by passing in the values for the name, shares, and price attributes.
After you've added the code, save the file. This will make sure that your changes are saved and can be used when you run the tests.
Now, let's run the tests to verify your implementation. First, change the directory to the
~/projectdirectory by running the following command:
cd ~/project
Then, run the tests using the following command:
python3 teststock.py
If your implementation is correct, you should see output similar to this:
.........
----------------------------------------------------------------------
Ran 9 tests in 0.001s
OK
This output means that all the tests are passing. The descriptors are successfully validating the types of each attribute!
Let's try creating a Stock object in the Python interpreter. First, make sure you're in the ~/project directory. Then, run the following command:
cd ~/project
python3 -c "from stock import Stock; s = Stock('GOOG', 100, 490.1); print(s); print(f'Cost: {s.cost}')"
You should see the following output:
Stock('GOOG', 100, 490.1)
Cost: 49010.0
You've successfully implemented descriptors for type-checking! Now, let's improve this code further.
Creating a Class Decorator for Validation
In the previous step, our implementation worked, but there was a redundancy. We had to specify both the _fields tuple and the descriptor attributes. This is not very efficient, and we can improve it. In Python, class decorators are a powerful tool that can help us simplify this process. A class decorator is a function that takes a class as an argument, modifies it in some way, and then returns the modified class. By using a class decorator, we can automatically extract field information from the descriptors, which will make our code cleaner and more maintainable.
Let's create a class decorator to simplify our code. Here are the steps you need to follow:
First, open the
structure.pyfile in your editor.Next, add the following code at the top of the
structure.pyfile, right after any import statements. This code defines our class decorator:
from validate import Validator
def validate_attributes(cls):
"""
Class decorator that extracts Validator instances
and builds the _fields list automatically
"""
validators = []
for name, val in vars(cls).items():
if isinstance(val, Validator):
validators.append(val)
## Set _fields based on validator names
cls._fields = [val.name for val in validators]
## Create initialization method
cls.create_init()
return cls
Let's break down what this decorator does:
- It first creates an empty list called
validators. Then, it iterates over all the attributes of the class usingvars(cls).items(). If an attribute is an instance of theValidatorclass, it adds that attribute to thevalidatorslist. - After that, it sets the
_fieldsattribute of the class. It creates a list of names from the validators in thevalidatorslist and assigns it tocls._fields. - Finally, it calls the
create_init()method of the class to generate the__init__method, and then returns the modified class.
Once you've added the code, save the
structure.pyfile. Saving the file ensures that your changes are preserved.Now, we need to modify our
stock.pyfile to use this new decorator. Open thestock.pyfile in your editor.Update the
stock.pyfile to use thevalidate_attributesdecorator. Replace the existing code with the following:
## stock.py
from structure import Structure, validate_attributes
from validate import String, PositiveInteger, PositiveFloat
@validate_attributes
class Stock(Structure):
name = String()
shares = PositiveInteger()
price = PositiveFloat()
@property
def cost(self):
return self.shares * self.price
def sell(self, nshares):
self.shares -= nshares
Notice the changes we've made:
- We added the
@validate_attributesdecorator right above theStockclass definition. This tells Python to apply thevalidate_attributesdecorator to theStockclass. - We removed the explicit
_fieldsdeclaration because the decorator will handle it automatically. - We also removed the call to
Stock.create_init()because the decorator takes care of creating the__init__method.
As a result, the class is now simpler and cleaner. The decorator takes care of all the details that we used to handle manually.
- After making these changes, we need to verify that everything still works as expected. Run the tests again using the following commands:
cd ~/project
python3 teststock.py
If everything is working correctly, you should see the following output:
.........
----------------------------------------------------------------------
Ran 9 tests in 0.001s
OK
This output indicates that all the tests have passed successfully.
Let's also test our Stock class interactively. Run the following command in the terminal:
cd ~/project
python3 -c "from stock import Stock; s = Stock('GOOG', 100, 490.1); print(s); print(f'Cost: {s.cost}')"
You should see the following output:
Stock('GOOG', 100, 490.1)
Cost: 49010.0
Great! You've successfully implemented a class decorator that simplifies our code by automatically handling field declarations and initialization. This makes our code more efficient and easier to maintain.
Applying Decorators via Inheritance
In Step 2, we created a class decorator that simplifies our code. A class decorator is a special type of function that takes a class as an argument and returns a modified class. It's a useful tool in Python for adding functionality to classes without modifying their original code. However, we still need to explicitly apply the @validate_attributes decorator to each class. This means that every time we create a new class that needs validation, we have to remember to add this decorator, which can be a bit cumbersome.
We can improve this further by applying the decorator automatically through inheritance. Inheritance is a fundamental concept in object - oriented programming where a subclass can inherit attributes and methods from a parent class. Python's __init_subclass__ method was introduced in Python 3.6 to allow parent classes to customize the initialization of subclasses. This means that when a subclass is created, the parent class can perform some actions on it. We can use this feature to automatically apply our decorator to any class that inherits from Structure.
Let's implement this:
Open the
structure.pyfile in your editor. This file contains the definition of theStructureclass, and we are going to modify it to use the__init_subclass__method.Add the
__init_subclass__method to theStructureclass:
class Structure:
_fields = ()
_types = ()
def __init__(self, *args):
if len(args) != len(self._fields):
raise TypeError(f'Expected {len(self._fields)} arguments')
for name, val in zip(self._fields, args):
setattr(self, name, val)
def __repr__(self):
values = ', '.join(repr(getattr(self, name)) for name in self._fields)
return f'{type(self).__name__}({values})'
@classmethod
def create_init(cls):
'''
Create an __init__ method from _fields
'''
body = 'def __init__(self, %s):\n' % ', '.join(cls._fields)
for name in cls._fields:
body += f' self.{name} = {name}\n'
## Execute the function creation code
namespace = {}
exec(body, namespace)
setattr(cls, '__init__', namespace['__init__'])
@classmethod
def __init_subclass__(cls):
validate_attributes(cls)
The __init_subclass__ method is a class method, which means it can be called on the class itself rather than an instance of the class. When a subclass of Structure is created, this method will be automatically called. Inside this method, we call the validate_attributes decorator on the subclass cls. This way, every subclass of Structure will automatically have the validation behavior.
- Save the file.
After making changes to the structure.py file, we need to save it so that the changes are applied.
Now, let's update our
stock.pyfile to take advantage of this new feature. Open thestock.pyfile in your editor to modify it. This file contains the definition of theStockclass, and we are going to make it inherit from theStructureclass to use the automatic decorator application.Modify the
stock.pyfile to remove the explicit decorator:
## stock.py
from structure import Structure
from validate import String, PositiveInteger, PositiveFloat
class Stock(Structure):
name = String()
shares = PositiveInteger()
price = PositiveFloat()
@property
def cost(self):
return self.shares * self.price
def sell(self, nshares):
self.shares -= nshares
Note that we:
- Removed the
validate_attributesimport because we no longer need to import it explicitly since the decorator is applied automatically through inheritance. - Removed the
@validate_attributesdecorator because the__init_subclass__method in theStructureclass will take care of applying it. - The code now relies solely on inheritance from
Structureto get the validation behavior.
- Run the tests again to verify everything still works:
cd ~/project
python3 teststock.py
Running the tests is important to make sure that our changes haven't broken anything. If all the tests pass, it means that the automatic decorator application through inheritance is working correctly.
You should see all tests passing:
.........
----------------------------------------------------------------------
Ran 9 tests in 0.001s
OK
Let's test our Stock class again to make sure it works as expected:
cd ~/project
python3 -c "from stock import Stock; s = Stock('GOOG', 100, 490.1); print(s); print(f'Cost: {s.cost}')"
This command creates an instance of the Stock class and prints its representation and the cost. If the output is as expected, it means that the Stock class is working correctly with the automatic decorator application.
Output:
Stock('GOOG', 100, 490.1)
Cost: 49010.0
This implementation is even cleaner! By using __init_subclass__, we've eliminated the need to explicitly apply decorators. Any class that inherits from Structure automatically gets the validation behavior.
Adding Row Conversion Functionality
In programming, it's often useful to create instances of a class from data rows, especially when dealing with data from sources like CSV files. In this section, we'll add the ability to create instances of the Structure class from data rows. We'll do this by implementing a from_row class method in the Structure class.
First, open the
structure.pyfile in your editor. This is where we'll make our code changes.Next, we'll modify the
validate_attributesfunction. This function is a class decorator that extractsValidatorinstances and builds the_fieldsand_typeslists automatically. We'll update it to also collect type information.
def validate_attributes(cls):
"""
Class decorator that extracts Validator instances
and builds the _fields and _types lists automatically
"""
validators = []
for name, val in vars(cls).items():
if isinstance(val, Validator):
validators.append(val)
## Set _fields based on validator names
cls._fields = [val.name for val in validators]
## Set _types based on validator expected_types
cls._types = [getattr(val, 'expected_type', lambda x: x) for val in validators]
## Create initialization method
cls.create_init()
return cls
In this updated function, we're collecting the expected_type attribute from each validator and storing it in the _types class variable. This will be useful later when we convert data from rows to the correct types.
- Now, we'll add the
from_rowclass method to theStructureclass. This method will allow us to create an instance of the class from a data row, which could be a list or a tuple.
@classmethod
def from_row(cls, row):
"""
Create an instance from a data row (list or tuple)
"""
rowdata = [func(val) for func, val in zip(cls._types, row)]
return cls(*rowdata)
Here's how this method works:
- It takes a row of data, which can be in the form of a list or a tuple.
- It converts each value in the row to the expected type using the corresponding function from the
_typeslist. - It then creates and returns a new instance of the class using the converted values.
After making these changes, save the
structure.pyfile. This ensures that your code changes are preserved.Let's test our
from_rowmethod to make sure it works as expected. We'll create a simple test using theStockclass. Run the following command in your terminal:
cd ~/project
python3 -c "from stock import Stock; s = Stock.from_row(['GOOG', '100', '490.1']); print(s); print(f'Cost: {s.cost}')"
You should see output similar to this:
Stock('GOOG', 100, 490.1)
Cost: 49010.0
Notice that the string values '100' and '490.1' were automatically converted to the correct types (integer and float). This shows that our from_row method is working correctly.
- Finally, let's try reading data from a CSV file using our
reader.pymodule. Run the following command in your terminal:
cd ~/project
python3 -c "from stock import Stock; import reader; portfolio = reader.read_csv_as_instances('portfolio.csv', Stock); print(portfolio); print(f'Total value: {sum(s.cost for s in portfolio)}')"
You should see output showing the stocks from the CSV file:
[Stock('GOOG', 100, 490.1), Stock('AAPL', 50, 545.75), Stock('MSFT', 200, 30.47)]
Total value: 82391.5
The from_row method allows us to easily convert CSV data into instances of the Stock class. When combined with the read_csv_as_instances function, we have a powerful way to load and work with structured data.
Adding Method Argument Validation
In Python, validating data is an important part of writing robust code. In this section, we'll take our validation one step further by automatically validating method arguments. The validate.py file already includes a @validated decorator. A decorator in Python is a special function that can modify another function. The @validated decorator here can check function arguments against their annotations. Annotations in Python are a way to add metadata to function parameters and return values.
Let's modify our code to apply this decorator to methods with annotations:
- First, we need to understand how the
validateddecorator works. Open thevalidate.pyfile in your editor to review it.
The validated decorator uses function annotations to validate arguments. Before allowing the function to run, it creates an instance of the validator class for each annotated parameter and calls the validate method to check the argument. For example, if an argument is annotated with PositiveInteger, the decorator will create a PositiveInteger instance and validate that the passed value is indeed a positive integer. If validation fails, it collects all errors and raises a TypeError with detailed error messages.
Now, we'll modify the
validate_attributesfunction instructure.pyto wrap annotated methods with thevalidateddecorator. This means that any method with annotations in the class will have its arguments automatically validated. Open thestructure.pyfile in your editor.Update the
validate_attributesfunction:
def validate_attributes(cls):
"""
Class decorator that:
1. Extracts Validator instances and builds _fields and _types lists
2. Applies @validated decorator to methods with annotations
"""
## Import the validated decorator
from validate import validated
## Process validator descriptors
validators = []
for name, val in vars(cls).items():
if isinstance(val, Validator):
validators.append(val)
## Set _fields based on validator names
cls._fields = [val.name for val in validators]
## Set _types based on validator expected_types
cls._types = [getattr(val, 'expected_type', lambda x: x) for val in validators]
## Apply @validated decorator to methods with annotations
for name, val in vars(cls).items():
if callable(val) and hasattr(val, '__annotations__'):
setattr(cls, name, validated(val))
## Create initialization method
cls.create_init()
return cls
This updated function now does the following:
It processes validator descriptors as before. Validator descriptors are used to define validation rules for class attributes.
It finds all methods with annotations in the class. Annotations are added to method parameters to specify the expected type of the argument.
It applies the
@validateddecorator to those methods. This ensures that the arguments passed to these methods are validated according to their annotations.Save the file after making these changes. Saving the file is important because it makes sure that our modifications are stored and can be used later.
Now, let's update the
sellmethod in theStockclass to include an annotation. Annotations help in specifying the expected type of the argument, which will be used by the@validateddecorator for validation. Open thestock.pyfile in your editor.Modify the
sellmethod to include a type annotation:
## stock.py
from structure import Structure
from validate import String, PositiveInteger, PositiveFloat
class Stock(Structure):
name = String()
shares = PositiveInteger()
price = PositiveFloat()
@property
def cost(self):
return self.shares * self.price
def sell(self, nshares: PositiveInteger):
self.shares -= nshares
The important change is adding : PositiveInteger to the nshares parameter. This tells Python (and our @validated decorator) to validate this argument using the PositiveInteger validator. So, when we call the sell method, the nshares argument must be a positive integer.
- Run the tests again to verify everything still works. Running tests is a good way to make sure that our changes haven't broken any existing functionality.
cd ~/project
python3 teststock.py
You should see all tests passing:
.........
----------------------------------------------------------------------
Ran 9 tests in 0.001s
OK
- Let's test our new argument validation. We'll try to call the
sellmethod with valid and invalid arguments to see if the validation works as expected.
cd ~/project
python3 -c "
from stock import Stock
s = Stock('GOOG', 100, 490.1)
s.sell(25)
print(s)
try:
s.sell(-25)
except Exception as e:
print(f'Error: {e}')
"
You should see output similar to:
Stock('GOOG', 75, 490.1)
Error: Bad Arguments
nshares: nshares must be >= 0
This shows that our method argument validation is working! The first call to sell(25) succeeds because 25 is a positive integer. But the second call to sell(-25) fails because -25 is not a positive integer.
You've now implemented a complete system for:
- Validating class attributes using descriptors. Descriptors are used to define validation rules for class attributes.
- Automatically collecting field information using class decorators. Class decorators can modify the behavior of a class, like collecting field information.
- Converting row data to instances. This is useful when working with data from external sources.
- Validating method arguments using annotations. Annotations help in specifying the expected type of the argument for validation.
This demonstrates the power of combining descriptors and decorators in Python to create expressive, self-validating classes.
Summary
In this lab, you have learned how to combine powerful Python features to create clean, self - validating code. You've mastered key concepts such as using descriptors for attribute validation, creating class decorators for code generation automation, and applying decorators automatically through inheritance.
These techniques are powerful tools for creating robust and maintainable Python code. They enable you to clearly express validation requirements and enforce them throughout your codebase. You can now apply these patterns in your own Python projects to enhance code quality and reduce boilerplate code.