A decorator is a function that takes in another function as argumen, it adds extra functionality to the function without actually modifying it.

Typically, decorator functions defines another inner function that is called a wrapper, the wrapper function is responsible of calling the decorated function,  performing any necessary manipulations and then returning the modified value to the caller.

Consider the following example:

def addition_decorator(func):
    def wrapper(a, b):
        return f"{a} + {b} = {func(a, b)}"
    return wrapper

@addition_decorator
def add(a, b):
    '''calculates the arithmetic sum of a and b'''
    return a + b

print(add(60, 50))

In the above example, we defined a decorator function called addition_decorator, it takes in a function to add two numbers as arguments. It then prettifies the outputs of the function by visually showing the evaluation of the addition operation.  Note that without decorating add(), it would simply output the sum of a and b

The purpose of functools.wraps()

When a function is decorated, the wrapper function does not automatically inherit some important properties such as the name, the docstring, annotations, etc from the decorated function. Consider the following example:

def addition_decorator(func):
    def wrapper(a, b):
        return f"{a} + {b} = {func(a, b)}"

    return wrapper

@addition_decorator
def add(a, b):
    '''calculates the arithmetic sum of a and b'''
    return a + b

print(add(60, 50))

#the wrapper does not inherit the properties of the wrapped function
print(add.__doc__) #docstring
print(add.__name__) #name

The wraps() decorator function in the functools module is a decorator that updates the wrapper function so that it becomes indistinguishable from the decorated function. This means that the wrapper function inherits the name of the original  function as well as other important attributes such as the docstring.

The syntax of the wraps() function is as shown below:

def decorator(func): 
    @functools.wraps(func) 
    def wrapper_decorator(*args, **kwargs): 
        # Do something

It takes the original function as the argument, it then applies its attributes to the wrapper. Consider the following example; 

from functools import wraps

def addition_decorator(func):

    #use the wraps function
    @wraps(func)
    def wrapper(a, b):
        return f"{a} + {b} = {func(a, b)}"
    
    return wrapper

@addition_decorator
def add(a, b):
    '''calculates the arithmetic sum of a and b'''

    return a + b

print(add(60, 50))

print(add.__doc__) #docstring
print(add.__name__) #name

As you can see above, the wrapper function now has attributes that are similar to that of the decorated function. Such attributes includes the ones shown above i.e __doc__ and __name__ as well as others.