from IPython.core.display import HTML
HTML(open("custom.html", "r").read())
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
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
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 (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.
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
Repeat and play with the examples.
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))