# Advanced Scientific Programming in Python

a Summer School by the G-Node and the Physik-Institut, University of Zurich

## Exercises

### Exercise 0 [warmup]

We are writing a `git` replacement in Python, and we need to store long sequence of commits representing changes. The commits are identified by their numbers, and sometimes we need to remove “changes” from the list.

Execute the two following implementations of `rm_change`. Which one is faster? Why is there a difference?

```from time import time

def rm_change(change):
if change in COMMITS:
COMMITS.remove(change)

COMMITS = range(10**7)
t = time()
rm_change(10**7); rm_change(10**7-1); rm_change(10**7-2)
print(time()-t)

def rm_change(change):
try:
COMMITS.remove(change)
except ValueError:
pass

COMMITS = range(10**7)
t = time()
rm_change(10**7); rm_change(10**7-1); rm_change(10**7-2)
print(time()-t)```

### Exercise 1

Write a decorator which wraps functions to log function arguments and the return value on each call. Provide support for both positional and named arguments (your wrapper function should take both `*args` and `**kwargs` and print them both):

```>>> @logged
... def func(*args, **kwargs):
...     return len(args) + len(kwargs)
>>> func()
you called func()
it returned 0
0
>>> func(4, 4, 4)
you called func(4, 4, 4)
it returned 3
3
>>> func(x=1, y=2)
you called func(x=2, y=2)
it returned 2
2```

Note: getting the output details perfectly is fun, but not essential. If you have the basic wrapping working, consider jumping to the next exercise.

##### Solution (class)
```class logged(object):
def __init__(self, func):
self.func = func

def __call__(self, *args, **kwargs):
print('you called {.__name__}({}{}{})'.format(
func,
str(list(args))[1:-1], # cast to list is because tuple
# of length one has an extra comma
', ' if kwargs else '',
', '.join('{}={}'.format(*pair) for pair in kwargs.items()),
))
val = func(*args, **kwargs)

print('it returned', val)
return val```
##### Solution (function)
```def logged(func):
"""Print out the arguments before function call and
after the call print out the returned value
"""

def wrapper(*args, **kwargs):
print('you called {.__name__}({}{}{})'.format(
func,
str(list(args))[1:-1], # cast to list is because tuple
# of length one has an extra comma
', ' if kwargs else '',
', '.join('{}={}'.format(*pair) for pair in kwargs.items()),
))
val = func(*args, **kwargs)
print('it returned', val)
return val
return wrapper```

### Exercise 2

Write a context manager which temporarily changes to the current working directory of the program to the specified path, and returns to the original directory afterwards.

(In order words, write a context manager which does what was open-coded on slide 32 in the lecture…)

```>>> import os
>>> print(os.getcwd())
/home/zbyszek
>>> with Chdir('/tmp'):
...   print(os.getcwd())
/tmp
>>> print(os.getcwd())
/home/zbyszek```
```@contextlib.contextmanager
def Chdir(dir):
old = os.getcwd()
try:
os.chdir(dir)
yield
finally:
os.chdir(old)```

### Exercise 3

Write a context manager similar to `assertRaises`, which checks if the execution took at most the specified amount of time, and prints an error if too much time was taken. (This is not very useful for unit testing, we would expect and exception here, but should work nicely for doctests and casual testing).

```>>> with time_limit(10):
...       short_computation()
...
42
>>> with time_limit(10):
...       loooong_computation()
...
⚡ function took 13s to execute — too long```
```import time
import functools
def time_limit(limit):
def decorator(func):
def wraper(*args, **kwargs):
t = time.time()
ans = func(*args, **kwargs)
actual = t - time.time()
if actual > limit:
print('⚡ function took %fs to execute — too long'%actual)
return None
return ans
return functools.update_wrapper(wraper, func)
return decorator```

Memoization is the operation of caching computation results. When a specific combination of arguments is used for the first time, the original function is executed normally, but the result is stored. In subsequent invocations with the same arguments, the answer is retrieved from the cache and the function is not called. This makes sense for functions which take long to execute, but have arguments and results which are compact enough to store.

Write a decorator to memoize functions with an arbitrary set of arguments. Memoization is only possible if the arguments are hashable. If the wrapper is called with arguments which are not hashable, then the wrapped function should just be called without caching.

Note: To use `args` and `kwargs` as dictionary keys, they must be hashable, which basically means that they must be immutable. Variable `args` is already a `tuple`, which is fine, but `kwargs` have to be converted. One way is invoke `tuple(sorted(kwargs.items()))`.

```>>> @memoize
... def f(*args, **kwargs):
...     ans = len(args) + len(kwargs)
...     print(args, kwargs, '->', ans)
...     return ans
>>> f(3)
(3,) {} -> 1
1
>>> f(3)
1
>>> f(*)
1
>>> f(a=1, b=2)
() {'a': 1, 'b': 2} -> 2
2
>>> f(b=2, a=1)
2
>>> f([1,2,3])
([1, 2, 3],) {} -> 1
1
>>> f([1,2,3])
([1, 2, 3],) {} -> 1
1```
```import functools

def memoize(func):
"""
>>> @memoize
... def f(*args, **kwargs):
...     ans = len(args) + len(kwargs)
...     print(args, kwargs, '->', ans)
...     return ans
>>> f(3)
(3,) {} -> 1
1
>>> f(3)
1
>>> f(*)
1
>>> f(a=1, b=2)
() {'a': 1, 'b': 2} -> 2
2
>>> f(b=2, a=1)
2
>>> f([1,2,3])
([1, 2, 3],) {} -> 1
1
>>> f([1,2,3])
([1, 2, 3],) {} -> 1
1
"""
func.cache = {}
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
try:
ans = func.cache[key]
except TypeError:
# key is unhashable
return func(*args, **kwargs)
except KeyError:
# value is not present in cache
ans = func.cache[key] = func(*args, **kwargs)
return ans
return functools.update_wrapper(wrapper, func)``` 