2. Decorators#

Decorators allow you to modify the behaviour of a callable. Decorators are themselves a callable that takes a callable as an input and return a callable. Let us build some decorator intuition through some simple examples.

Suppose we have a function that prints a string.

def my_func():
    print("Function")
>>>my_func()
My function

Python treats functions as objects mean we can create another function that accepts my_func as an argument.

def wrapper(func):
    print("Decorator")
    return func

def my_func():
    print("Function")

Above, we created a new function called wrapper which accepts a function as an argument, prints Decorator then returns the function. Remember functions are objects so we can assign the function returned from wrapper to a new variable.

>>>new_func = wrapper(my_func)
>>>new_func()
Decorator
Function

Above, we assign the callable object to the variable new_func then we call new_func. This first prints Decorator which is from the wrapper function, then it prints Function which is from the my_func function. So we have used wrapper to modify the behaviour of my_func without modifying my_func itself.

Decorators essentially give us some syntax sugar to do this exact thing. In fact wrapper is a valid decorator.

def wrapper(func):
    print("Decorator")
    return func

@wrapper
def my_func():
    print("Function")
>>>my_func
Decorator
Function

We have now decorated my_func with the wrapper decorator.

2.1. Passing Arguments to Decorators#

The arguments of decorated functions can be passed to the decorator. One reason you may want to do this is to validate the arguments passed to a function before the function is executed. Let us create a simple addition function and use a decorator to check the input arguments are integers.

def check_arguments(func):
    def inner(a, b):
        if isinstance(int, a) and isinstance(int, b):
            return func(a, b)
        else:
            raise TypeError("Inputs must be integers")
    return inner

@check_arguments
def add_integers(a: int, b: int) -> int:
    return a + b

The decorator function check_arguments contains a nested function inner takes the arguments from function being decorated. The function check_arguments then returns the callable inner.

>>>add_integers(1, 2):
3
>>>add_integers("1", 2):
...
TypeError: Inputs must be integers

2.2. Example Decorators#

Here are some simple example decorators.

2.2.1. Check Dictionary Keys#

Suppose we have a function that takes a dictionary as an argument. We can create a decorator that makes sure the dictionary has the correct keys.

class CheckDictKeys:
    def __init__(self, dict_arg_name, allowed_keys):
        self.dict_arg_name = dict_arg_name
        self.allowed_keys = allowed_keys

    def __call__(func):
        def inner(*args, **kwargs):
        try:
            dictionary = kwargs[self.dict_arg_name]
        except KeyError:
            arg_names = inspect.getfullargspec(func).args
            arg_index = arg_names.index(self.dict_arg_name)
            dictionary = args[arg_index]

        missing_keys = [
            key for key in dictionary.keys() if key not in self.allowed_keys
        ]
        if missing_keys:
            msg = f"Dictionary is missing these keys: {missing_keys}"
            raise RuntimeError(msg)

        return func(*args, **kwargs)

 @CheckDictKeys("dictionary", ["key1", "key2", "key3"])
 def add_dict_values(dictionary: dict):
     total = 0
     for value in dictionary.values():
         total += value
     return total

We have created a decorator called CheckDictKeys that will check if a named dictionary contains the allowed keys. If any of the allowed keys are missing, a RuntimeError is raised.

>>>add_dict_values({"key1": 1, "key2": 2, "key3": 3})
6
>>>add_dict_values({"key1": 1, "key2": 2})
...
RuntimeError: Dictionary is missing these keys: ['key3']