10. Decorators

Decorators are annotations that modify the behavior of functions. The mental model of a decorator is that it is an outer function wrapped around a target function. The outer function has a chance to intercept the arguments and modify or interact with them, before invoking the target function. Using decorators is sometimes referred to as meta-programming since we are programming the program.

10.1. Basic

Below, we have a log() function that takes a function f as an argument. Inside log() function is an inner function decorated(). Notice that decorated() is annotated itself with @wraps and also it takes in 2 arguments (a non-keyworded, variable-length argument *args and a key-worded, variable-length argument **kwargs). The @wraps annotation ensures the reflection against the target function is perserved (reflection is using Python and its inspection functions to get metadata about an object). Without the @wraps annotation/decoration, getting the name and docstrings of the target function will return the wrapper function’s information instead. The decorated function invokes the target function and returns its results. The log() function returns the decorated() function.

After we define the decorator, we can use it to annotate other fuctions. Below, we annotate add_one() and minus_one() with @log.

 1from functools import wraps
 2
 3def log(f):
 4    @wraps(f)
 5    def decorated(*args, **kwargs):
 6        print(f'init: {args}, {kwargs}')
 7        output = f(*args, **kwargs)
 8        print(f'output = {output}')
 9        print('finished')
10        return output
11    return decorated
12
13@log
14def add_one(a):
15    print('add one')
16    return a + 1
17
18@log
19def minus_one(a):
20    print('minus one')
21    return a - 1
22
23print(add_one(a=10))
24print(minus_one(a=10))

10.1.1. Exercise

Write a decorator to ensure that the value passed into the function below is always in the range [0, 100]. If the value passed in is not in this range, throw an exception (which exception is appropriate?).

1def add_one(a):
2    return a + 1

Solution.

 1from functools import wraps
 2
 3def ensure_legal_range(f):
 4    @wraps(f)
 5    def decorated(*args, **kwargs):
 6        for arg in args:
 7            if arg < 0 or arg > 100:
 8                raise ValueError(f'{arg} is not in [0, 100]')
 9
10        for k, v in kwargs.items():
11            if v < 0 or v > 100:
12                raise ValueError(f'{k}={v} is not in [0, 100]')
13
14        output = f(*args, **kwargs)
15        return output
16    return decorated
17
18@ensure_legal_range
19def add_one(a):
20    return a + 1
21
22print(add_one(10))
23print(add_one(a=10))
24
25print(add_one(-10))
26print(add_one(a=-10))

10.2. Multiple decorators

Here’s another example of defining two decorators and using them together. The log() decorators simply logs the inputs and outputs of a function. The positive_inputs() decorator modifies the inputs to always be positive. We decorate the add_one() and minus_one() functions with @log and @positive_inputs.

 1from functools import wraps
 2
 3def log(f):
 4    @wraps(f)
 5    def decorated(*args, **kwargs):
 6        output = f(*args, **kwargs)
 7        print(f'name = {f.__name__}, inputs={args}, {kwargs}, output = {output}')
 8        return output
 9    return decorated
10
11def positive_inputs(f):
12    @wraps(f)
13    def decorated(*args, **kwargs):
14        nargs = [abs(a) for a in args]
15        nkwargs = {k: abs(v) for k, v in kwargs.items()}
16
17        output = f(*nargs, **nkwargs)
18
19        return output
20    return decorated
21
22@log
23@positive_inputs
24def add_one(a):
25    return a + 1
26
27@log
28@positive_inputs
29def minus_one(a):
30    return a - 1
31
32print(add_one(a=-10))
33print(add_one(a=10))
34print(minus_one(a=10))
35print(minus_one(a=-10))

10.3. Parameterized decorators

Your decorator can also be parameterized to change its own behavior. To achieve a parameterized decorator, we wrap the real decorator yet inside another wrapper function.

 1from functools import wraps
 2
 3def range_check(*a, **k):
 4    lower = 10 if 'lower' not in k else k['lower']
 5    upper = 100 if 'upper' not in k else k['upper']
 6
 7    def real_decorator(f):
 8        @wraps(f)
 9        def wrapper(*args, **kwargs):
10            for arg in args:
11                if arg < lower or arg > upper:
12                    raise ValueError(f'{arg} is not in [{lower}, {upper}]')
13
14            for k, v in kwargs.items():
15                if v < lower or v > upper:
16                    raise ValueError(f'{k}={v} is not in [{lower}, {upper}]')
17
18            retval = f(*args, **kwargs)
19            return retval
20        return wrapper
21    return real_decorator
22
23@range_check(lower=-10, upper=100)
24def add_one(a):
25    return a + 1
26
27print(add_one(10))
28print(add_one(-10))
29print(add_one(-100))

10.3.1. Exercise

Write a decorator to time the following functions. Make sure to parameterize the time duration in seconds s or milliseconds ms. The use of the time module will help in capturing duration.

 1# capture the time duration in seconds
 2def get_numbers_s(n=50000000):
 3    return [i for i in range(n)]
 4
 5# capture the time duration in milliseconds
 6def get_numbers_ms(n=50000000):
 7    return [i for i in range(n)]
 8
 9# capture the time duration in seconds
10def get_numbers_slow(n=50000000):
11    numbers = []
12    for i in range(n):
13        numbers.append(i)
14    return numbers

Solution.

 1from functools import wraps
 2import time
 3
 4def benchmark(*a, **k):
 5    units = 's' if 'unit' not in k else k['unit']
 6    units = units if units in {'s', 'ms'} else 's'
 7
 8    def decorator(f):
 9        @wraps(f)
10        def wrapper(*args, **kwargs):
11            start = time.time()
12            retval = f(*args, **kwargs)
13            stop = time.time()
14            diff = stop - start
15            if units == 'ms':
16                diff = diff * 1000
17            print(f'diff = {diff}')
18            return retval
19        return wrapper
20    return decorator
21
22
23@benchmark(unit='s')
24def get_numbers_s(n=50000000):
25    return [i for i in range(n)]
26
27@benchmark(unit='ms')
28def get_numbers_ms(n=50000000):
29    return [i for i in range(n)]
30
31@benchmark(unit='s')
32def get_numbers_slow(n=50000000):
33    numbers = []
34    for i in range(n):
35        numbers.append(i)
36    return numbers
37
38get_numbers_s()
39get_numbers_ms()
40get_numbers_slow()

10.4. Built-in decorators

There are many built-in decorators. Use @lru_cache to cache outputs based on inputs to a function. The second and third calls to get_data() below will be super fast.

 1from functools import lru_cache
 2import time
 3
 4@lru_cache
 5def get_data(n=50000000):
 6    return [i for i in range(n)]
 7
 8# first call
 9start = time.time()
10get_data()
11diff = time.time() - start
12print(f'{diff:.10f} seconds')
13
14# second call
15start = time.time()
16get_data()
17diff = time.time() - start
18print(f'{diff:.10f} seconds')
19
20# third call
21start = time.time()
22get_data()
23diff = time.time() - start
24print(f'{diff:.10f} seconds')

The @property decorator will enable you to treat a function as if it was a property of an object. The @staticmethod decorator will turn a method into a static one (a static method is associated with a class not an instance of that class; you do not have to instantiate an instance of the class to use a static method).

 1import math
 2
 3class Circle(object):
 4    def __init__(self, radius):
 5        self.radius = radius
 6
 7    @property
 8    def area(self):
 9        return Circle.__get_area(self.radius)
10
11    @property
12    def circumference(self):
13        return Circle.__get_circumference(self.radius)
14
15    @staticmethod
16    def __get_area(r):
17        return math.pi * r ** 2
18
19    @staticmethod
20    def __get_circumference(r):
21        return 2.0 * math.pi * r
22
23circle = Circle(10)
24
25print(circle.radius)
26print(circle.area)
27print(circle.circumference)

A list of decorators has been curated.