===== Slides =====
{{:advanced_python_2:advanced_python.handout.pdf|}}

[[http://www.physik.uzh.ch/data/aspp/4_1_AdvancedII.webm|Video of this Lecture]]

===== 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?

<code python>
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)
</code>

==== 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 ''<nowiki>**kwargs</nowiki>'' and print them
both):

<code pycon>
>>> @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
</code>

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) ==

<code python>
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
</code>

== Solution (function) ==

<code python>
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
</code>

==== 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...)

<code pycon>
>>> import os
>>> print(os.getcwd())
/home/zbyszek
>>> with Chdir('/tmp'):
...   print(os.getcwd())
/tmp
>>> print(os.getcwd())
/home/zbyszek
</code>

<code python>
@contextlib.contextmanager
def Chdir(dir):
  old = os.getcwd()
  try:
     os.chdir(dir)
     yield
  finally:
     os.chdir(old)
</code>

==== 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).

<code pycon>
>>> with time_limit(10):
...       short_computation()
...
42
>>> with time_limit(10):
...       loooong_computation()
...
⚡ function took 13s to execute — too long
</code>

<code python>
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
</code>

==== Excercise 4 [advanced] ====

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()))''.

<code pycon>
>>> @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(*[3])
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
</code>

<code python>
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(*[3])
    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)
</code>