# Python Decorators In Action

> An overview on Python higher-order functions, decorators and some practical examples for when decorators can be used.

- Canonical: https://dennisokeeffe.com/blog/2022-01-16-python-decorators-in-action
- Published: 2022-01-16
- Updated: 2022-01-16
- Tags: python, 100daysofpython

> This is Day 34 of the #100DaysOfPython challenge.

This post will provide an overview [Python decorators]() and then demonstrate their usage.

The final code can be found on my GitHub repo [`okeeffed/python-decorators-in-action`](https://github.com/okeeffed/python-decorators-in-action).

## Prerequisites

1. Familiarity with [Pipenv](https://pipenv.pypa.io/en/latest/). See [here](https://blog.dennisokeeffe.com/blog/2020-07-04-abcs-of-pipenv) for my post on Pipenv.
2. Familiarity with [JupyterLab](https://jupyterlab.readthedocs.io/en/stable/gettingstarted/install.html). See [here](https://blog.dennisokeeffe.com/blog/2021-07-19-hello-jupyter-notebook) for my post on JupyterLab.
3. ...

## What are Python decorators?

Python decorators are a way to modify classes and functions at runtime.

They make use of higher-order functions, which are functions that accepts and/or returns another function.

To demonstrate an example of a higher-order function that print "before" and "after" a provided function, see the following code:

```py
# py_decorators.py
def before_after_hof(func):
    def wrap_func():
        print('BEFORE')
        func()
        print('AFTER')
    return wrap_func


def hello():
    print('Hello')


# Create the higher order function by passing our function as an argument
higher_order_fn = before_after_hof(hello)

# Call our new higher order function.
higher_order_fn()
```

Running this code, we get:

```s
$ python3 py_decorators.py
BEFORE
Hello
AFTER
```

You might have an idea of what is happening, but to solidify and review:

1. We create a higher-order function that accepts a function as an argument `before_after_hof`.
2. We create a function `hello` that prints `Hello`.
3. We pass our function `hello` to the higher-order function `before_after_hof` to create a new function `higher_order_fn`.
4. We call our new higher-order function `higher_order_fn`.
5. Our higher-order function runs the `wrap_func` closure function that we have returned from `before_after_hof`.
6. This `wrap_func` calls `print('BEFORE')`.
7. It then calls `func` which was pass as an argument - then argument we passed in this case was the `hello` function and so running it will print `Hello`.
8. Finally, we call `print('AFTER')`.

Higher-order functions enable us to augment the behavior of a function. They can also be used directly as a decorator!

Let's update our code to reflect this change:

```diff
# py_decorators.py
def before_after_hof(func):
    def wrap_func():
        print('BEFORE')
        func()
        print('AFTER')
    return wrap_func

+ @before_after_hof
def hello():
    print('Hello')


- # Create the higher order function by passing our function as an argument
- higher_order_fn = before_after_hof(hello)

- # Call our new higher order function.
- higher_order_fn()

+ # Call our hello function
+ hello()
```

Running this code, we can confirm that we get the same output:

```s
$ python3 py_decorators.py
BEFORE
Hello
AFTER
```

Amazing! We have now augmented our function `hello` with the `before_after_hof` decorator.

But why would we want to do this? A few example are to "inject" certain common functionality into a function. Examples include (but are not limited to):

- Logging.
- Error handling.
- Authorization.
- Performance.

Let's run through an example of each to get a feel for this.

## Decorators with error handling

Our first basic example will be a contrived version of error handling.

For example, you might have a 3rd party API that you want to use in your application to capture errors. We can implement this as a decorator like so:

```py
from third_party_api import capture_error

def send_error_data(func):
    def wrap_func():
        try:
            func()
        except Exception as e:
            capture_error(e)
            # Again raise the exception
            raise e

    return wrap_func


@send_error_data
def fn_that_fails():
    raise Exception("Sorry, this failed")


fn_that_fails()
```

In our contrived example, the higher-order function will capture the error with our third-party library and then raise it again (to be handled elsewhere).

## Decorators with authorization

The following example demonstrates the running of a function based on a user property - namely their role type.

This particular example is quite contrived, although you can see how it can be used to implement a version of authorization for functions based on their role.

```py
class User:
    def __init__(self, role='user'):
        self.role = role


def admin_only(func):
    def check_is_admin(*args, **kwargs):
        if (args[0].role == 'admin'):
            func(*args, **kwargs)
        else:
            print('You are not an admin')
    return check_is_admin


@admin_only
def delete_important_record(user):
    print('Allowed')


user1 = User()
user2 = User('admin')

delete_important_record(user1) # You are not an admin
delete_important_record(user2) # Allowed
```

Running the following code will result in the following output:

```s
$ python3 py_decorators.py
You are not an admin
Allowed
```

You may also notice that in this example we needed to pass arguments. We can use the `*args, **kwargs` parameters to pass in any number of arguments and keyword arguments to our decorator.

## Performance

The last example comes directly from Fahim Sakri's article ["Python decorator to measure the execution time of methods"](https://medium.com/pythonhive/python-decorator-to-measure-the-execution-time-of-methods-fa04cb6bb36d) with some modifications for a basic example of timing a `hello` function:

```py
import time

def timeit(method):
    def timed(*args, **kw):
        ts = time.time()
        result = method(*args, **kw)
        te = time.time()
        if 'log_time' in kw:
            name = kw.get('log_name', method.__name__.upper())
            kw['log_time'][name] = int((te - ts) * 1000)
        else:
            print('%r  %2.2f ms' % (method.__name__, (te - ts) * 1000))
        return result
    return timed


@timeit
def hello():
    print("Hello World!")

hello()
```

Now we can use the `@timeit` decorator to profile the performance of a function call on the machine running the code:

```s
$ python3 temp/py_decorators.py
Hello World!
'hello'  0.04 ms
```

## Summary

Today's post covered an overview on decorators and higher-order functions. We have also covered the use of decorators with a number of examples including error handling, authorization and performance profiling.

Decorators are a useful feature that you'll see used across the frameworks and libraries in Python.

## Resources and further reading

1. ["Python decorator to measure the execution time of methods"](https://medium.com/pythonhive/python-decorator-to-measure-the-execution-time-of-methods-fa04cb6bb36d)
2. [`okeeffed/python-decorators-in-action`](https://github.com/okeeffed/python-decorators-in-action)
3. [Primer on Python decorators](https://realpython.com/primer-on-python-decorators/)

_Photo credit: [`orwhat`](https://unsplash.com/@orwhat)_
