Define a Proper Callable Object

PythonPythonBeginner
Practice Now

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

Introduction

In this lab, you will learn about callable objects in Python. A callable object can be invoked like a function using the object() syntax. While Python functions are inherently callable, you can create custom callable objects by implementing the __call__ method.

You will also learn to implement a callable object using the __call__ method and use function annotations with callable objects for parameter validation. The file validate.py will be modified during this lab.

Understanding Validator Classes

In this lab, we're going to build on a set of validator classes to create a callable object. Before we start building, it's important to understand the validator classes provided in the validate.py file. These classes will help us perform type checking, which is a crucial part of ensuring that our code works as expected.

Let's start by opening the validate.py file in the WebIDE. This file contains the code for the validator classes we'll be using. To open it, run the following command in the terminal:

code /home/labex/project/validate.py

Once you've opened the file, you'll see that it contains several classes. Here's a brief overview of what each class does:

  1. Validator: This is a base class. It has a check method, but currently, this method doesn't do anything. It serves as a starting point for the other validator classes.
  2. Typed: This is a subclass of Validator. Its main job is to check if a value is of a specific type.
  3. Integer, Float, and String: These are specific type validators that inherit from Typed. They're designed to check if a value is an integer, a float, or a string, respectively.

Now, let's see how these validator classes work in practice. We'll create a new file called test.py to test them. To create and open this file, run the following command:

code /home/labex/project/test.py

Once the test.py file is open, add the following code to it. This code will test the Integer and String validators:

from validate import Integer, String, Float

## Test Integer validator
print("Testing Integer validator:")
try:
    Integer.check(42)
    print("✓ Integer check passed for 42")
except TypeError as e:
    print(f"✗ Error: {e}")

try:
    Integer.check("Hello")
    print("✗ Integer check incorrectly passed for 'Hello'")
except TypeError as e:
    print(f"✓ Correctly raised error: {e}")

## Test String validator
print("\nTesting String validator:")
try:
    String.check("Hello")
    print("✓ String check passed for 'Hello'")
except TypeError as e:
    print(f"✗ Error: {e}")

In this code, we first import the Integer, String, and Float validators from the validate.py file. Then, we test the Integer validator by trying to check an integer value (42) and a string value ("Hello"). If the check passes for the integer, we print a success message. If it passes incorrectly for the string, we print an error message. If the check correctly raises a TypeError for the string, we print a success message. We do a similar test for the String validator.

After adding the code, run the test file using the following command:

python3 /home/labex/project/test.py

You should see output similar to this:

Testing Integer validator:
✓ Integer check passed for 42
✓ Correctly raised error: Expected <class 'int'>

Testing String validator:
✓ String check passed for 'Hello'

As you can see, these validator classes allow us to perform type checking easily. For example, when you call Integer.check(x), it will raise a TypeError if x is not an integer.

Now, let's think about a practical scenario. Suppose we have a function that requires its arguments to be of specific types. Here's an example of such a function:

def add(x, y):
    Integer.check(x)  ## Make sure x is an integer
    Integer.check(y)  ## Make sure y is an integer
    return x + y

This function works, but there's a problem. We have to manually add the validator checks every time we want to use type checking. This can be time-consuming and error-prone, especially for larger functions or projects.

In the next steps, we'll solve this problem by creating a callable object. This object will be able to automatically apply these type checks based on function annotations. This way, we won't have to add the checks manually every time.

Creating a Basic Callable Object

In Python, a callable object is an object that can be used just like a function. You can think of it as something that you can "call" by putting parentheses after it, similar to how you call a regular function. To make a class in Python act like a callable object, we need to implement a special method called __call__. This method gets automatically invoked when you use the object with parentheses, just like when you call a function.

Let's start by modifying the validate.py file. We're going to add a new class called ValidatedFunction to this file, and this class will be our callable object. To open the file in the code editor, run the following command in the terminal:

code /home/labex/project/validate.py

Once the file is open, scroll to the end of it and add the following code:

class ValidatedFunction:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print('Calling', self.func)
        result = self.func(*args, **kwargs)
        return result

Let's break down what this code does. The ValidatedFunction class has an __init__ method, which is the constructor. When you create an instance of this class, you pass a function to it. This function is then stored as an attribute of the instance, named self.func.

The __call__ method is the key part that makes this class callable. When you call an instance of the ValidatedFunction class, this __call__ method gets executed. Here's what it does step by step:

  1. It prints a message that tells you which function is being called. This is useful for debugging and understanding what's going on.
  2. It calls the function that was stored in self.func with the arguments that you passed when you called the instance. The *args and **kwargs allow you to pass any number of positional and keyword arguments.
  3. It returns the result of the function call.

Now, let's test this ValidatedFunction class. We'll create a new file called test_callable.py to write our test code. To open this new file in the code editor, run the following command:

code /home/labex/project/test_callable.py

Add the following code to the test_callable.py file:

from validate import ValidatedFunction

def add(x, y):
    return x + y

## Wrap the add function with ValidatedFunction
validated_add = ValidatedFunction(add)

## Call the wrapped function
result = validated_add(2, 3)
print(f"Result: {result}")

## Try another call
result = validated_add(10, 20)
print(f"Result: {result}")

In this code, we first import the ValidatedFunction class from the validate.py file. Then we define a simple function called add that takes two numbers and returns their sum.

We create an instance of the ValidatedFunction class, passing the add function to it. This "wraps" the add function inside the ValidatedFunction instance.

We then call the wrapped function twice, once with the arguments 2 and 3, and then with 10 and 20. Each time we call the wrapped function, the __call__ method of the ValidatedFunction class is invoked, which in turn calls the original add function.

To run the test code, execute the following command in the terminal:

python3 /home/labex/project/test_callable.py

You should see output similar to this:

Calling <function add at 0x7f2d1c3a9940>
Result: 5
Calling <function add at 0x7f2d1c3a9940>
Result: 30

This output shows that our callable object is working as expected. When we call validated_add(2, 3), it's actually calling the __call__ method of the ValidatedFunction class, which then calls the original add function.

Right now, our ValidatedFunction class just prints a message and passes the call to the original function. In the next step, we'll improve this class to perform type validation based on the function's annotations.

✨ Check Solution and Practice

Implementing Type Validation with Function Annotations

In Python, you have the ability to add type annotations to function parameters. These annotations serve as a way to indicate the expected data types of the parameters and the return value of a function. They don't enforce the types at runtime by default, but they can be used for validation purposes.

Let's take a look at an example:

def add(x: int, y: int) -> int:
    return x + y

In this code, x: int and y: int tell us that the parameters x and y should be integers. The -> int at the end indicates that the function add returns an integer. These type annotations are stored in the function's __annotations__ attribute, which is a dictionary that maps parameter names to their annotated types.

Now, we're going to enhance our ValidatedFunction class to use these type annotations for validation. To do this, we'll need to use Python's inspect module. This module provides useful functions to get information about live objects such as modules, classes, methods, functions, etc. In our case, we'll use it to match the function arguments with their corresponding parameter names.

First, we need to modify the ValidatedFunction class in the validate.py file. You can open this file using the following command:

code /home/labex/project/validate.py

Replace the existing ValidatedFunction class with the following improved version:

import inspect

class ValidatedFunction:
    def __init__(self, func):
        self.func = func
        self.signature = inspect.signature(func)

    def __call__(self, *args, **kwargs):
        ## Bind the arguments to the function parameters
        bound = self.signature.bind(*args, **kwargs)

        ## Validate each argument against its annotation
        for name, val in bound.arguments.items():
            if name in self.func.__annotations__:
                ## Get the validator class from the annotation
                validator = self.func.__annotations__[name]
                ## Apply the validation
                validator.check(val)

        ## Call the function with the validated arguments
        return self.func(*args, **kwargs)

Here's what this enhanced version does:

  1. It uses inspect.signature() to obtain information about the function's parameters, such as their names, default values, and annotated types.
  2. The signature's bind() method is used to match the provided arguments to their corresponding parameter names. This helps us associate each argument with its correct parameter in the function.
  3. It checks each argument against its type annotation (if one exists). If an annotation is found, it retrieves the validator class from the annotation and applies the validation using the check() method.
  4. Finally, it calls the original function with the validated arguments.

Now, let's test this enhanced ValidatedFunction class with some functions that use our validator classes in their type annotations. Open the test_validation.py file using the following command:

code /home/labex/project/test_validation.py

Add the following code to the file:

from validate import ValidatedFunction, Integer, String

def greet(name: String, times: Integer):
    return name * times

## Wrap the greet function with ValidatedFunction
validated_greet = ValidatedFunction(greet)

## Valid call
try:
    result = validated_greet("Hello ", 3)
    print(f"Valid call result: {result}")
except TypeError as e:
    print(f"Unexpected error: {e}")

## Invalid call - wrong type for 'name'
try:
    result = validated_greet(123, 3)
    print(f"Invalid call unexpectedly succeeded: {result}")
except TypeError as e:
    print(f"Expected error for name: {e}")

## Invalid call - wrong type for 'times'
try:
    result = validated_greet("Hello ", "3")
    print(f"Invalid call unexpectedly succeeded: {result}")
except TypeError as e:
    print(f"Expected error for times: {e}")

In this code, we define a greet function with type annotations name: String and times: Integer. This means that the name parameter should be validated using the String class, and the times parameter should be validated using the Integer class. We then wrap the greet function with our ValidatedFunction class to enable type validation.

We perform three test cases: a valid call, an invalid call with the wrong type for name, and an invalid call with the wrong type for times. Each call is wrapped in a try-except block to catch any TypeError exceptions that may be raised during validation.

To run the test file, use the following command:

python3 /home/labex/project/test_validation.py

You should see output similar to the following:

Valid call result: Hello Hello Hello
Expected error for name: Expected <class 'str'>
Expected error for times: Expected <class 'int'>

This output demonstrates that our ValidatedFunction callable object is now enforcing type validation based on the function annotations. When we pass arguments of the wrong type, the validator classes detect the error and raise a TypeError. This way, we can ensure that the functions are called with the correct data types, which helps prevent bugs and makes our code more robust.

✨ Check Solution and Practice

Challenge: Using a Callable Object as a Method

In Python, when you use a callable object as a method within a class, there's a unique challenge you need to tackle. A callable object is something you can "call" like a function, such as a function itself or an object with a __call__ method. When used as a class method, it doesn't always work as expected due to how Python passes the instance (self) as the first argument.

Let's explore this issue by creating a Stock class. This class will represent a stock with attributes like name, number of shares, and price. We'll also use a validator to ensure the data we're working with is correct.

First, open the stock.py file to start writing our Stock class. You can use the following command to open the file in an editor:

code /home/labex/project/stock.py

Now, add the following code to the stock.py file. This code defines the Stock class with an __init__ method to initialize the stock's attributes, a cost property to calculate the total cost, and a sell method to reduce the number of shares. We'll also try to use the ValidatedFunction to validate the input for the sell method.

from validate import ValidatedFunction, Integer

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: Integer):
        self.shares -= nshares

    ## Try to use ValidatedFunction
    sell = ValidatedFunction(sell)

After defining the Stock class, we need to test it to see if it works as expected. Create a test file named test_stock.py and open it using the following command:

code /home/labex/project/test_stock.py

Add the following code to the test_stock.py file. This code creates an instance of the Stock class, prints the initial number of shares and cost, tries to sell some shares, and then prints the updated number of shares and cost.

from stock import Stock

try:
    ## Create a stock
    s = Stock('GOOG', 100, 490.1)

    ## Get the initial cost
    print(f"Initial shares: {s.shares}")
    print(f"Initial cost: ${s.cost}")

    ## Try to sell some shares
    s.sell(10)

    ## Check the updated cost
    print(f"After selling, shares: {s.shares}")
    print(f"After selling, cost: ${s.cost}")

except Exception as e:
    print(f"Error: {e}")

Now, run the test file using the following command:

python3 /home/labex/project/test_stock.py

You'll likely encounter an error similar to:

Error: missing a required argument: 'nshares'

This error occurs because when Python calls a method like s.sell(10), it actually calls Stock.sell(s, 10) behind the scenes. The self parameter represents the instance of the class, and it's automatically passed as the first argument. However, our ValidatedFunction doesn't handle this self parameter correctly because it doesn't know it's being used as a method.

Understanding the Problem

When you define a method inside a class and then replace it with a ValidatedFunction, you're essentially wrapping the original method. The problem is that the wrapped method doesn't automatically handle the self parameter correctly. It expects the arguments in a way that doesn't account for the instance being passed as the first argument.

Fixing the Problem

To fix this issue, we need to modify the way we handle methods. We'll create a new class called ValidatedMethod that can handle method calls properly. Add the following code to the end of the validate.py file:

import inspect

class ValidatedMethod:
    def __init__(self, func):
        self.func = func
        self.signature = inspect.signature(func)

    def __get__(self, instance, owner):
        ## This method is called when the attribute is accessed as a method
        if instance is None:
            return self

        ## Return a callable that binds 'self' to the instance
        def method_wrapper(*args, **kwargs):
            return self(instance, *args, **kwargs)

        return method_wrapper

    def __call__(self, instance, *args, **kwargs):
        ## Bind the arguments to the function parameters
        bound = self.signature.bind(instance, *args, **kwargs)

        ## Validate each argument against its annotation
        for name, val in bound.arguments.items():
            if name in self.func.__annotations__:
                ## Get the validator class from the annotation
                validator = self.func.__annotations__[name]
                ## Apply the validation
                validator.check(val)

        ## Call the function with the validated arguments
        return self.func(instance, *args, **kwargs)

Now, we need to modify the Stock class to use ValidatedMethod instead of ValidatedFunction. Open the stock.py file again:

code /home/labex/project/stock.py

Update the Stock class as follows:

from validate import ValidatedMethod, Integer

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    @property
    def cost(self):
        return self.shares * self.price

    @ValidatedMethod
    def sell(self, nshares: Integer):
        self.shares -= nshares

The ValidatedMethod class is a descriptor, which is a special type of object in Python that can change how attributes are accessed. The __get__ method is called when the attribute is accessed as a method. It returns a callable that correctly passes the instance as the first argument.

Run the test file again using the following command:

python3 /home/labex/project/test_stock.py

Now you should see output similar to:

Initial shares: 100
Initial cost: $49010.0
After selling, shares: 90
After selling, cost: $44109.0

This challenge has shown you an important aspect of callable objects. When using them as methods in a class, they require special handling. By implementing the descriptor protocol with the __get__ method, we can create callable objects that work correctly both as standalone functions and as methods.

Summary

In this lab, you have learned how to create proper callable objects in Python. First, you explored basic validator classes for type - checking and created a callable object using the __call__ method. Then, you enhanced this object to perform validation based on function annotations and tackled the challenge of using callable objects as class methods.

Key concepts covered include callable objects and the __call__ method, function annotations for type hinting, using the inspect module to examine function signatures, and the descriptor protocol with the __get__ method for class methods. These techniques enable you to create powerful function wrappers for pre - and post - call processing, which is a fundamental pattern for decorators and other advanced Python features.