Decorators

In Chapter 5, Saving Time and Memory, I measured the execution time of various expressions. If you recall, I had to initialize a variable to the start time, and subtract it from the current time after execution in order to calculate the elapsed time. I also printed it on the console after each measurement. That was very tedious.

Every time you find yourself repeating things, an alarm bell should go off. Can you put that code in a function and avoid repetition? The answer most of the time is yes, so let's look at an example:

# decorators/time.measure.start.py
from time import sleep, time

def f():
sleep(.3)

def g():
sleep(.5)

t = time()
f()
print('f took:', time() - t) # f took: 0.3001396656036377

t = time()
g()
print('g took:', time() - t) # g took: 0.5039339065551758

In the preceding code, I defined two functions, f and g, which do nothing but sleep (by 0.3 and 0.5 seconds, respectively). I used the sleep function to suspend the execution for the desired amount of time. Notice how the time measure is pretty accurate. Now, how do we avoid repeating that code and those calculations? One first potential approach could be the following:

# decorators/time.measure.dry.py
from time import sleep, time

def f():
sleep(.3)

def g():
sleep(.5)

def measure(func):
t = time()
func()
print(func.__name__, 'took:', time() - t)

measure(f) # f took: 0.30434322357177734
measure(g) # g took: 0.5048270225524902

Ah, much better now. The whole timing mechanism has been encapsulated into a function so we don't repeat code. We print the function name dynamically and it's easy enough to code. What if we need to pass arguments to the function we measure? This code would get just a bit more complicated, so let's see an example:

# decorators/time.measure.arguments.py
from time import sleep, time

def f(sleep_time=0.1):
sleep(sleep_time)

def measure(func, *args, **kwargs):
t = time()
func(*args, **kwargs)
print(func.__name__, 'took:', time() - t)

measure(f, sleep_time=0.3) # f took: 0.30056095123291016
measure(f, 0.2) # f took: 0.2033553123474121

Now, f is expecting to be fed sleep_time (with a default value of 0.1), so we don't need g any more. I also had to change the measure function so that it is now accepts a function, any variable positional arguments, and any variable keyword arguments. In this way, whatever we call measure with, we redirect those arguments to the call to func we do inside.

This is very good, but we can push it a little bit further. Let's say we want to somehow have that timing behavior built-in into the f function, so that we could just call it and have that measure taken. Here's how we could do it:

# decorators/time.measure.deco1.py
from time import sleep, time

def f(sleep_time=0.1):
sleep(sleep_time)

def measure(func):
def wrapper(*args, **kwargs):
t = time()
func(*args, **kwargs)
print(func.__name__, 'took:', time() - t)
return wrapper

f = measure(f) # decoration point

f(0.2)  # f took: 0.20372915267944336
f(sleep_time=0.3) # f took: 0.30455899238586426
print(f.__name__) # wrapper <- ouch!

The preceding code is probably not so straightforward. Let's see what happens here. The magic is in the decoration point. We basically reassign f with whatever is returned by measure when we call it with f as an argument. Within measure, we define another function, wrapper, and then we return it. So, the net effect is that after the decoration point, when we call f, we're actually calling wrapper. Since the wrapper inside is calling func, which is f, we are actually closing the loop like that. If you don't believe me, take a look at the last line.

wrapper is actually... a wrapper. It takes variable and positional arguments, and calls f with them. It also does the time measurement calculation around the call.

This technique is called decoration, and measure is, effectively, a decorator. This paradigm became so popular and widely used that at some point, Python added a special syntax for it (check out https://www.python.org/dev/peps/pep-0318/). Let's explore three cases: one decorator, two decorators, and one decorator that takes arguments:

# decorators/syntax.py
def func(arg1, arg2, ...):
pass
func = decorator(func)

# is equivalent to the following:

@decorator
def func(arg1, arg2, ...):
pass

Basically, instead of manually reassigning the function to what was returned by the decorator, we prepend the definition of the function with the special syntax, @decorator_name.

We can apply multiple decorators to the same function in the following way:

# decorators/syntax.py
def func(arg1, arg2, ...):
pass
func = deco1(deco2(func))

# is equivalent to the following:

@deco1
@deco2
def func(arg1, arg2, ...):
pass

When applying multiple decorators, pay attention to the order. In the preceding example, func is decorated with deco2 first, and the result is decorated with deco1. A good rule of thumb is: the closer the decorator is to the function, the sooner it is applied.

Some decorators can take arguments. This technique is generally used to produce other decorators. Let's look at the syntax, and then we'll see an example of it:

# decorators/syntax.py
def func(arg1, arg2, ...):
pass
func = decoarg(arg_a, arg_b)(func)

# is equivalent to the following:

@decoarg(arg_a, arg_b)
def func(arg1, arg2, ...):
pass

As you can see, this case is a bit different. First, decoarg is called with the given arguments, and then its return value (the actual decorator) is called with func. Before I give you another example, let's fix one thing that is bothering me. I don't want to lose the original function name and docstring (and other attributes as well, check the documentation for the details) when I decorate it. But because inside our decorator we return wrapper, the original attributes from func are lost and f ends up being assigned the attributes of wrapper. There is an easy fix for that from the beautiful functools module. I will fix the last example, and I will also rewrite its syntax to use the @ operator:

# decorators/time.measure.deco2.py
from time import sleep, time
from functools import wraps

def measure(func):
@wraps(func)
def wrapper(*args, **kwargs):
t = time()
func(*args, **kwargs)
print(func.__name__, 'took:', time() - t)
return wrapper

@measure
def f(sleep_time=0.1):
"""I'm a cat. I love to sleep! """
sleep(sleep_time)

f(sleep_time=0.3) # f took: 0.3010902404785156
print(f.__name__, ':', f.__doc__) # f : I'm a cat. I love to sleep!

Now we're talking! As you can see, all we need to do is to tell Python that wrapper actually wraps func (by means of the wraps function), and you can see that the original name and docstring are now maintained.

Let's see another example. I want a decorator that prints an error message when the result of a function is greater than a certain threshold. I will also take this opportunity to show you how to apply two decorators at once:

# decorators/two.decorators.py
from time import sleep, time
from functools import wraps

def measure(func):
@wraps(func)
def wrapper(*args, **kwargs):
t = time()
result = func(*args, **kwargs)
print(func.__name__, 'took:', time() - t)
return result
return wrapper

def max_result(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if result > 100:
print('Result is too big ({0}). Max allowed is 100.'
.format(result))
return result
return wrapper

@measure
@max_result
def cube(n):
return n ** 3

print(cube(2))
print(cube(5))
Take your time in studying the preceding example until you are sure you understand it well. If you do, I don't think there is any decorator you now won't be able to write.

I had to enhance the measure decorator, so that its wrapper now returns the result of the call to func. The max_result decorator does that as well, but before returning, it checks that result is not greater than 100, which is the maximum allowed. I decorated cube with both of them. First, max_result is applied, then measure. Running this code yields this result:

$ python two.decorators.py
cube took: 3.0994415283203125e-06
8

Result is too big (125). Max allowed is 100.
cube took: 1.0013580322265625e-05
125

For your convenience, I have separated the results of the two calls with a blank line. In the first call, the result is 8, which passes the threshold check. The running time is measured and printed. Finally, we print the result (8).

On the second call, the result is 125, so the error message is printed, the result returned, and then it's the turn of measure, which prints the running time again, and finally, we print the result (125).

Had I decorated the cube function with the same two decorators but in a different order, the error message would have followed the line that prints the running time, instead of have preceded it.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.137.192.3