Learn About Class Decorators

PythonPythonBeginner
Practice Now

This tutorial is from open-source community. Access the source code

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.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python(("Python")) -.-> python/AdvancedTopicsGroup(["Advanced Topics"]) python(("Python")) -.-> python/BasicConceptsGroup(["Basic Concepts"]) python(("Python")) -.-> python/ControlFlowGroup(["Control Flow"]) python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python/BasicConceptsGroup -.-> python/type_conversion("Type Conversion") python/ControlFlowGroup -.-> python/conditional_statements("Conditional Statements") python/FunctionsGroup -.-> python/function_definition("Function Definition") python/ObjectOrientedProgrammingGroup -.-> python/classes_objects("Classes and Objects") python/ObjectOrientedProgrammingGroup -.-> python/inheritance("Inheritance") python/ObjectOrientedProgrammingGroup -.-> python/encapsulation("Encapsulation") python/ObjectOrientedProgrammingGroup -.-> python/class_static_methods("Class Methods and Static Methods") python/AdvancedTopicsGroup -.-> python/decorators("Decorators") subgraph Lab Skills python/type_conversion -.-> lab-132516{{"Learn About Class Decorators"}} python/conditional_statements -.-> lab-132516{{"Learn About Class Decorators"}} python/function_definition -.-> lab-132516{{"Learn About Class Decorators"}} python/classes_objects -.-> lab-132516{{"Learn About Class Decorators"}} python/inheritance -.-> lab-132516{{"Learn About Class Decorators"}} python/encapsulation -.-> lab-132516{{"Learn About Class Decorators"}} python/class_static_methods -.-> lab-132516{{"Learn About Class Decorators"}} python/decorators -.-> lab-132516{{"Learn About Class Decorators"}} end

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.

  1. First, open the stock.py file in the editor. You can do this by running the following command in your terminal:
code ~/project/stock.py

This command uses the code editor to open the stock.py file located in the ~/project directory.

  1. 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.

  1. 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.

  2. Now, let's run the tests to verify your implementation. First, change the directory to the ~/project directory 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.

โœจ Check Solution and Practice

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:

  1. First, open the structure.py file. You can use the following command in the terminal:
code ~/project/structure.py

This command will open the structure.py file in your code editor.

  1. Next, add the following code at the top of the structure.py file, 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 using vars(cls).items(). If an attribute is an instance of the Validator class, it adds that attribute to the validators list.
  • After that, it sets the _fields attribute of the class. It creates a list of names from the validators in the validators list and assigns it to cls._fields.
  • Finally, it calls the create_init() method of the class to generate the __init__ method, and then returns the modified class.
  1. Once you've added the code, save the structure.py file. Saving the file ensures that your changes are preserved.

  2. Now, we need to modify our stock.py file to use this new decorator. Open the stock.py file using the following command:

code ~/project/stock.py
  1. Update the stock.py file to use the validate_attributes decorator. 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_attributes decorator right above the Stock class definition. This tells Python to apply the validate_attributes decorator to the Stock class.
  • We removed the explicit _fields declaration 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.

  1. 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.

โœจ Check Solution and Practice

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:

  1. Open the structure.py file:
code ~/project/structure.py

Here, we are using the code command to open the structure.py file in a code editor. This file contains the definition of the Structure class, and we are going to modify it to use the __init_subclass__ method.

  1. Add the __init_subclass__ method to the Structure class:
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.

  1. Save the file.

After making changes to the structure.py file, we need to save it so that the changes are applied.

  1. Now, let's update our stock.py file to take advantage of this new feature:
code ~/project/stock.py

We are opening the stock.py file to modify it. This file contains the definition of the Stock class, and we are going to make it inherit from the Structure class to use the automatic decorator application.

  1. Modify the stock.py file 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_attributes import because we no longer need to import it explicitly since the decorator is applied automatically through inheritance.
  • Removed the @validate_attributes decorator because the __init_subclass__ method in the Structure class will take care of applying it.
  • The code now relies solely on inheritance from Structure to get the validation behavior.
  1. 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.

โœจ Check Solution and Practice

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.

  1. First, you need to open the structure.py file. This is where we'll make our code changes. Use the following command in your terminal:
code ~/project/structure.py
  1. Next, we'll modify the validate_attributes function. This function is a class decorator that extracts Validator instances and builds the _fields and _types lists 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.

  1. Now, we'll add the from_row class method to the Structure class. 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 _types list.
  • It then creates and returns a new instance of the class using the converted values.
  1. After making these changes, save the structure.py file. This ensures that your code changes are preserved.

  2. Let's test our from_row method to make sure it works as expected. We'll create a simple test using the Stock class. 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.

  1. Finally, let's try reading data from a CSV file using our reader.py module. 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: 73444.0

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.

โœจ Check Solution and Practice

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:

  1. First, we need to understand how the validated decorator works. Open the validate.py file to review it:
code ~/project/validate.py

The validated decorator uses function annotations to validate arguments. Before allowing the function to run, it checks each argument against its annotation type. For example, if an argument is annotated as an integer, the decorator will make sure the passed value is indeed an integer.

  1. Now, we'll modify the validate_attributes function in structure.py to wrap annotated methods with the validated decorator. This means that any method with annotations in the class will have its arguments automatically validated. Open the structure.py file:
code ~/project/structure.py
  1. Update the validate_attributes function:
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:

  1. It processes validator descriptors as before. Validator descriptors are used to define validation rules for class attributes.

  2. It finds all methods with annotations in the class. Annotations are added to method parameters to specify the expected type of the argument.

  3. It applies the @validated decorator to those methods. This ensures that the arguments passed to these methods are validated according to their annotations.

  4. 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.

  5. Now, let's update the sell method in the Stock class to include an annotation. Annotations help in specifying the expected type of the argument, which will be used by the @validated decorator for validation. Open the stock.py file:

code ~/project/stock.py
  1. Modify the sell method 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.

  1. 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
  1. Let's test our new argument validation. We'll try to call the sell method 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: 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:

  1. Validating class attributes using descriptors. Descriptors are used to define validation rules for class attributes.
  2. Automatically collecting field information using class decorators. Class decorators can modify the behavior of a class, like collecting field information.
  3. Converting row data to instances. This is useful when working with data from external sources.
  4. 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.

โœจ Check Solution and Practice

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.