Python Decorators

Decorators allow you to attach new functionality to a method without modifying it’s behavior. This can be easily implemented in python because functions are treated as first class citizens (i.e a can be passed as arguments or returned from functions).

To put in simple terms:

A Decorator is a function the takes a function and returns a function.

They are used extensively in some popular frameworks like Flask.

Dummy Decorator#

This is a simple decorator that prints a statement before and after a function is called.

Declare#

def dec(f: Callable) -> Callable:
    def g(*args, **kwargs):
        print('Function starts')
        r = f(*args, **kwargs)
        print(f) # print function name and location in memory
        print('Function ends')
        return r # return result
    return g # returns a new "decorated" function
  • Line 1: We define a new function (which will soon be our decorator) called dec which takes a function f as a parameter. I have added few type hints using import typing but it’s not necessary and not enforced by the interpreter.

  • Line 2: We define a new inner function g which takes all the ordinary arguments and keyword arguments that function f takes.

  • Line 3: Just an example of something that happens before executing function f.

  • Line 4: Call function f with all of it’s arguments and store result in variable r.

  • Line 5: Just and example of something that happens after executing function f.

  • Line 6: Returns the result of function f in order to keep it working as intended.

  • Line 7: Returns the newly decorated function g.

Usage#

Now let’s decorate a simple add function with the new decorator:

@dec
def add(x: float, y: float):
    return x + y

add(5, 6)
print(add)

Output#

Function starts
<function add at 0x7f6735d528b0>
Function ends
11
<function dec.<locals>.g at 0x7f6735d52940>

The difference in function name and memory address (line 2 & line 5) will be discussed below.

Timer Decorator#

Here is an applicable example of a decorator that enables you to easily determine how long does a function takes in order to finish execution.

Declare#

def timer(f: Callable) -> Callable:
    def g(*args, **kwargs):
        t_start = time.monotonic()
        r = f(*args, **kwargs)
        t_end = time.monotonic()
        print(f"It took {t_end - t_start} to execute \"{f.__name__}\" function")
        return r 
    return g

Usage#

You replace the @dec with @timer or even stack multiple decorators on top of each other:

@dec
@timer
def add(x: float, y: float):
    return x + y

add(5, 6)
print(add)

Output#

Function starts
It took 5.5779964895918965e-06 to execute "add" function
<function timer.<locals>.g at 0x7f1da16d2940>
Function ends
11
  • Line 1: Output of decorator @dec.
  • Line 2: Output of decorator @timer.
  • Line 3, 4: The rest of @dec’s output.
  • Line 5: Output of function add.

Tracker Decorator#

Let’s assume you want to keep track of the output of different functions in some sort of a global set. This can be useful for some sort of a simple unit testing solution.

Declare#

TEST = set()

def track(f: Callable) -> Callable:
    def g(*args, **kwargs):
        r = f(*args, **kwargs)
        t = (f.__name__, args, kwargs, r)
        TEST.add(t)
        return r
    return g

Not much different than the previous decorators, only is adding a tuple that contains the function name, arguments and result to a global set called TEST.

Usage#

print(add(5, 6))
print(add(3, 7))
print(add(11, 17))
print(add)
print(STATE)

assert STATE == {('add', (11, 17), 28), ('add', (5, 6), 11), ('add', (3, 7), 10)}
print('All tests passed successfully')

If anything went wrong with the assert statement; the interpreter will halt execution and raise and assert exception. AssertionError

Output#

11
10
28
<function track.<locals>.g at 0x7f01e1b399d0>
[('add', (5, 6), {}, 11), ('add', (3, 7), {}, 10), ('add', (11, 17), {}, 28)]
All tests passed successfully

functools.wraps#

In order to add additional functionality to a method, the decorator generate a new function with a new name in a new memory address which might confuse some tools and debuggers.

To avoid renaming your function and keep it’s original docstring, use wraps from functools:

from functools import wraps

def dec(f: Callable) -> Callable:
    @wraps(f)
    def g(*args, **kwargs):
        print('Function starts')
        r = f(*args, **kwargs)
        print(f) # print function name and location in memory
        print('Function ends')
        return r # return result
    return g