Let’s look at Python Decorators

2021/05/29

Python decorators always surprise me whenever I come across them as I’ll instantly forget everything about them within minutes of using one.

So in an attempt to counteract this forgetfulness let’s take a look at Python decorators and cover some of the basics.

What are Decorators?

A decorator is a high-level construct that can be used to dynamically alter the functionality of a function, method or class.

To use a decorator we first define a decorator function, the example below outputs the start and end times of any function it’s applied to:

from datetime import datetime

def start_end_time_decorator(target):
    def internal_wrapper(*args, **kwargs):
        print(f"Started: {datetime.now()}")
        func_result = target(*args, **kwargs)
        print(f"Finished: {datetime.now()}")
		return func_result
    return internal_wrapper

When executed start_end_time_decorator will take in a target function and return a decorated reference (closure). Upon execution the decorated function will perform the actions defined in our internal_wrapper plus any action of the original target function.

To demonstrate, let’s define a new method and decorate it using the start_end_time_decorator so the start and end times are printed out.

def thing_printer(thing_name):
    print(f'This is a {thing_name}')

Pretty simple, it accepts a name and prints it out, now lets decorate it:

decorated_thing_printer = start_end_time_decorator(thing_printer)

We now have a decorated thing_printer which is really a reference to internal_wrapper itself containing a reference to thing_printer, we can execute the thing_printer as normal:

decorated_thing_printer('Bronze Sphere')
Started: 2021-05-29 15:44:08.752192
This is a Bronze Sphere
Finished: 2021-05-29 15:44:08.752306

Python’s @Syntax

@Syntax offers a neater way to apply decorators and is especially handy when applying multiple decorators.

So the above:

def thing_printer(thing_name):
    print(f'This is a {thing_name}')

decorated_thing_printer = start_end_time_decorator(thing_printer)
decorated_thing_printer('Bronze Sphere')

Becomes:

@start_end_time_decorator
def thing_printer(thing_name):
    print(f'This is a {thing_name}')

thing_printer('Bronze Sphere')

Object methods can also be decorated too using @Syntax:

class person():
    def __init__(self, name):
        self.name = name

    @start_end_time_decorator
    def do_something(self):
        print(f"{self.name} is doing something")

Which behave as expected:

joe = person("Joe")
joe.doSomething()
Started: 2021-05-29 15:46:28.096741
Joe is doing something
Finished: 2021-05-29 15:46:28.096763

Decorator Nesting

Decorators can be nested to add multiple new behavious to a function or method.

We’ll expand on the previous example by defining a new decorator which ’logs’ that a function has been called and applying it to thing_printer

def logging_decorator(target):
    def internal_wrapper(*args, **kwargs):
        print("Logging function call")
        target(*args, **kwargs)
    return internal_wrapper

@logging_decorator
@start_end_time_decorator
def thing_printer(thing_name):
    print(f'This is a {thing_name}')

And on execution:

thing_printer("Bronze Sphere")
Logging function call
Started: 2021-05-29 16:06:08.474310
This is a Bronze Sphere
Finished: 2021-05-29 16:06:08.474358

Note that annotation order does make a difference, if swapped around the execution order will change:

@start_end_time_decorator
@logging_decorator
def thing_printer(thing_name):
    print(f'This is a {thing_name}')
	
thing_printer("Bronze Sphere")
Started: 2021-05-29 16:07:03.709030
Logging function call
This is a Bronze Sphere
Finished: 2021-05-29 16:07:03.709121

Decorator Arguments

The last thing we’ll look at is decorator arguments, let’s define a decorator which repeats the decorated function or method.

def repeat_decorator(times_to_repeat):
    def internal_decorator(target):
        def internal_wrapper(*args, **kwargs):
            for _ in range(0, times_to_repeat):
                target(*args, **kwargs)
        return internal_wrapper
    return internal_decorator

The extra layer is used to capture the new parameter, which in this case is the number of times to repeat the target function or method.

We can then apply this decorator and supply a value using @Syntax:

@repeat_decorator(3)
def thing_printer(thing_name):
    print(f'This is a {thing_name}')

thing_printer("Bronze Sphere")
This is a Bronze Sphere
This is a Bronze Sphere
This is a Bronze Sphere

Extra - Decorating Functions

If a function (i.e. has a return value) is being decorated the our decorator function can be adjusted slightly to accomodate by storing the returned value from target_function and returning it:

def start_end_time_decorator(target_function):
    def internal_wrapper(*args, **kwargs):
        print(f"Started: {datetime.now()}")
        result = target_function(*args, **kwargs)
        print(f"Finished: {datetime.now()}")
        return result
    return internal_wrapper

Conclusion

The above should provide a good starting point for future understanding and use of Python Decorators. I’m planning to type up some follow-up notes to cover a couple of other applications not included above, i.e. class decorators and decorating classes.