Python Decorators#

Python decorators are a powerful feature of the Python programming language that allow computer scientists to modify or enhance the behavior of functions without changing their source code. Essentially, a decorator is a function that takes another function as input and returns a new function as output.

Nested functions & Inner Functions#

In Python, it is possible to define functions inside other functions. Such functions are called inner functions.

def parent():
    print("Printing from the parent() function")

    def first_child():
        print("Printing from the first_child() function")

    def second_child():
        print("Printing from the second_child() function")

    second_child()
    first_child()

What will the call to parent return?

parent()
Hide code cell output
Printing from the parent() function
Printing from the second_child() function
Printing from the first_child() function

Tip

inner functions are local variables accessible inside the function definition (scope dependent).

Functions are variables too#

We can pass a function as an argument to another function in Python. For Example

def say_hello(name):
    return f"Hello {name:s}"

def be_awesome(name):
    return f"Yo {name:s}, together we are the awesomest!"

def greet_bob(greeter_func):
    return greeter_func("Bob")

note

greet_bob takes a function as argument. In Python, functions can be passed around and used as arguments

print(greet_bob(say_hello))
print(greet_bob(be_awesome))
Hide code cell output
Hello Bob
Yo Bob, together we are the awesomest!

Since inner functions are variables, python functions can return functions like any other variable.

def parent():
    def inner():
        return "Hi, I am the inner function"
    return inner

parent()
<function __main__.parent.<locals>.inner()>

The returned value is a function defined in the parent scope as inner

parent()()
'Hi, I am the inner function'

A first useless decorator#

a decorator wraps a function into a function

def deco(func):
    def wrapper(*args, **kwargs):
        print(f"Something before calling the function.")
        result = func(*args, **kwargs)
        print("Something after calling the function.")
        return result
    return wrapper

def say_whee():
    print("Whee!")

say_whee = deco(say_whee)

Can you guess what happens when you call say_whee()?

say_whee()
Hide code cell output
Something before calling the function.
Whee!
Something after calling the function.

What happened?

>>> say_whee = deco(say_whee)   # decoration of `say_whee`
>>> say_whee
<function deco.<locals>.wrapper at 0x7f3c5dfd42f0>

Syntactic Sugar! @#

@deco
def say_whee():
    """ Useful Documentation here!"""
    print("Whee!")

say_whee()
Something before calling the function.
Whee!
Something after calling the function.

What is decorated?#

help(say_whee)
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

How do we fix this? e.g., propagating documentation –> @functools.wraps

from functools import wraps

def deco(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Something before
        result = func(*args, **kwargs)
        # Something after
        return result
    return wrapper


@deco
def say_whee():
    """ Useful Documentation here!"""
    print("Whee!")

say_whee()
help(say_whee)
Whee!
Help on function say_whee in module __main__:

say_whee()
    Useful Documentation here!

Some useful examples of decorators#

  • debug - provides verbose information when activated

  • timeit - gives runtime of a function

  • memoize - A quick caching

  • functools.lru_cache - a LRU caching mechanism (built-in)

  • astropy.units.quantity_input to validate the units of arguments/outputs

debug#

Imagine you have a long complex code. Instead of having to introduce various prints, or intentional errors, you can set a decorator to report or even break the code at a given postion and on demand.

VERBOSE = True

def debug(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        value = func(*args, **kwargs)
        if VERBOSE:
            print("{0}(\n\t{1}, \n\t{2}\n) -> {3}".format(
                func.__name__, args, kwargs, value ))
        return value
    return wrapper

@debug
def do_something(a, b, c=3):
    return a * b, 2 * c

do_something(1, 2, 3)
do_something(
	(1, 2, 3), 
	{}
) -> (2, 6)
(2, 6)

In the above example, you can set VERBOSE to show or not information. This could help also create a breakpoint for easier debugging.

timeit#

from functools import wraps
import time

def timeit(func):
    """Prints the runtime of decorated functions"""
    @wraps(func)
    def wrapper_timer(*args, **kwargs):
        start_time = time.perf_counter()
        value = func(*args, **kwargs)
        end_time = time.perf_counter()
        run_time = end_time - start_time
        print(f"Finished {func.__name__!r} in {run_time:.4f} secs")
        return value
    return wrapper_timer

@timeit
def waste_some_time(num_times):
    for _ in range(num_times):
        sum([i**2 for i in range(10000)])

waste_some_time(999)
Finished 'waste_some_time' in 3.5788 secs