from IPython.core.display import HTML

HTML(open("custom.html", "r").read())
Creative Commons License This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Copyright (C) 2014-2023 Scientific IT Services of ETH Zurich,
Contributing Authors: Uwe Schmitt, Mikolaj Rybniski

14. Decorators and context managers¶

Example decorator from standard library:¶

Python offers so called "decorators" which can "decorate" functions and methods to change their behaviour:

from functools import lru_cache


@lru_cache()  # this is a decorator
def slow_computation(t):
    print("got ", t)
    return t + 1


x = slow_computation(1)
x = slow_computation(2)
x = slow_computation(1)
got  1
got  2

Decorators transform functions¶

This is a simple and general example (although not very useful) for a function transformation without a decorator:

def make_friendly(function):
    # this is actually a weird "transformation", whatever function
    # you pass to transfrom, the result will always be the same:
    def transformed():
        print("what a lovely day")

    return transformed


def swear():
    print("wtf 💀☠")


swear = make_friendly(swear)
swear()
what a lovely day

A decorator actually is just "syntactic sugar" for such a transformation:

# exactly the same with a decorator, just better to read:


@make_friendly
def swear_even_more():
    print(3 * "wtf 💀☠")


swear_even_more()
what a lovely day

Example: decorator which reports execution time¶

We can use a decorator to measure function execution time.

The time function from the standard libraries time module returns franctional seconds of the current time passed since 1. Jan 1970 (https://en.wikipedia.org/wiki/Unix_time).

print(time.time())
print(time.time())
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 print(time.time())
      2 print(time.time())

NameError: name 'time' is not defined
started = time.time()
li = [i**2 for i in range(1_000_000)]
needed = time.time() - started
print(f"constructing li needed {needed:.4f} seconds")
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[6], line 1
----> 1 started = time.time()
      2 li = [i**2 for i in range(1_000_000)]
      3 needed = time.time() - started

NameError: name 'time' is not defined

Let's use this to implement a decorator @timeit:

import time

def timeit(function):
    
    def wrapped(*a, **kw):
        # we use *a, **kw to capture all kind of arguments somebody might
        # be able to pass to function
        started = time.time()
        
        # we call function exactly the same way as we will call wrapped,
        # we also keep the result:
        result = function(*a, **kw)
        
        # measure and report time:
        needed = time.time() - started
        print(f"executing {function.__name__} needed {needed:.2e} seconds")
        
        # return the origin result:
        return result
    
    return wrapped
@timeit
def many_lists():
    for _ in range(40):
        li = [a for a in range(1_000_000) if a % 7 == 0]
    return len(li)
        
print(many_lists())
executing many_lists needed 1.22e+00 seconds
142858

Decorators with arguments.¶

Decorators (like the lru_cache from the introduction example) can also take arguments, this increases the level of nesting further.

We want to implement a decorator, which tries to run a function max_attempts time until the function does not raise an Exception. This can be usefull if the function tries to access unreliable resources, like fetching data from a low-quality network:

def repeat_until_succeeds(max_attempts):
    # this function does not implement a decorator but constructs and 
    # returns a decorator where the value of max_attempts is fixed (bound) now:
    
    def decorator(function):
        # wrap is a decorator which wraps function
        def decorated(*a, **kw):
            # this is the transformed "version" of "function"
            counter = 0
            while counter < max_attempts:
                counter += 1
                try:
                    result = function(*a, **kw)
                    print(f"{function.__name__} succeeded after {counter} attempts")
                    return result
                except:
                    pass
            raise RuntimeError(f"{function.__name__} did not succeed after {counter} attempts")
        return decorated
    return decorator


import random

"""
explicit code for eductional purpose only:
"""

decorator = repeat_until_succeeds(max_attempts=3)

@decorator
def access_network():
    
    # our sh*tty network fails in 50% of all cases:
    if random.random() >= 0.5:
        raise IOError("network access failed.")
        

"""
this is how you do this usually:
"""

@repeat_until_succeeds(max_attempts=3)
def access_network():
    
    # our sh*tty network fails in 50% of all cases:
    if random.random() >= 0.5:
        raise IOError("network access failed.")
        

access_network()
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[9], line 51
     47     if random.random() >= 0.5:
     48         raise IOError("network access failed.")
---> 51 access_network()

Cell In[9], line 18, in repeat_until_succeeds.<locals>.decorator.<locals>.decorated(*a, **kw)
     16     except:
     17         pass
---> 18 raise RuntimeError(f"{function.__name__} did not succeed after {counter} attempts")

RuntimeError: access_network did not succeed after 3 attempts

To learn more about decorators, read here.

Context managers¶

Another advanced concepts are so called "context managers". We've already seen a context manager, namely open. A context manager can be used after the with keyword.

Acutally a context manager allows you to "wrap" code sections with specific code provided by the context manager.

To implement your own context manager, you can use contextlib:

import time
from contextlib import contextmanager


@contextmanager
def measure_time(info):
    started = time.time()

    # now we enter code block after "with":
    yield

    # now code block ended !
    needed = time.time() - started
    print("time needed for {}: {:.3f}[s]".format(info, needed))


with measure_time("sleep .1"):
    print("now sleep")
    time.sleep(0.1)
now sleep
time needed for sleep .1: 0.102[s]

This is how we could reimplement the open context decorator if we would have the open function only:

@contextmanager
def my_open(*a, **kw):
    fh = open(*a, **kw)
    try:
        yield fh
    finally:
        fh.close()


try:
    with my_open("blbla.txt", "w") as fh:
        1 / 0

except ZeroDivisionError:
    pass

print(fh.closed)
True

Exercise section¶

  1. Repeat and play with the examples.

  2. Implement a function decorator double which operatos on a function which takes one number and returns a number. The transformed function should return the original return value times 2. E.g.

    @double
    def inc(x):
        return x + 1
    
    print(inc(2))
    

    should print 6.