Advanced Python Concepts

In this tutorial, a few advanced concepts (not class related) are introduced. This includes

  • packing and unpacking

  • context manager

  • decorator and factories

  • exceptions

[1]:
import time

Packing and unpacking of values

Using * or ** we can pack/unpack list-like objects and dict-like objects, respectively. They act as a “removal” of the parenthesis when situated on the right and as an “adder” of parenthesis when situated on the left of the assigmenemt operator (=).

Let’s play around…

[2]:
a, c, *b = [3, 4, 4.5, 5, 6]
[3]:
b
[3]:
[4.5, 5, 6]

As can be seen, b catches now all the remaining elements in a list. Interesting to see is also the special case if no element is left.

[4]:
d1, d2, *d3, d4 = [1, 2, 3]  # nothing left for d3
[5]:
d3
[5]:
[]

This is simply an empty list. However, this has the advantage that we know that it is always a list.

[6]:
a = [3, 4, 5]

Multiple unpackings can be added together (however, the other way around does not work: multiple packings are not possible as it is ill-defined which variable would get how many elements).

[7]:
d, e, f, g, h, i = *a, *b

Now we should be able to understand the *args and **kwargs for functions. Let’s look at it:

[8]:
def func(*args, **kwargs):
    print(f'args are {args}')
    print(f"kwargs are {kwargs}")
[9]:
mykwargs = {'a': 5, 'b': 3}
myargs = [1, 3, 4]
func(*myargs, *mykwargs)
args are (1, 3, 4, 'a', 'b')
kwargs are {}
[10]:
func(5, a=4)
args are (5,)
kwargs are {'a': 4}
[11]:
# play around with it!

Context manager

A context manager is an object that responds to a with statement. It may returns something. The basic idea is that some action is performed when entering a context and again when exiting it.

with context as var:
    # do something

translates to

# execute context entering code
var = return_from_context_entering_code
# do something
# execute context leaving code

The great advantage here is that the “leaving code” is automatically executed whenever we step out of the context!

This proved to be incredibly useful when operations have cleanup code that we need to execute yet that is tedious to write manually and can be forgotten.

Using yield

One way to create a context manager is to have a function that has a yield.

What is ``yield``?: It’s like a return, except that the executioin stops at the yield, lets other code execute and, at some point, continues again where the yield was. Examples are: - iterator: a function that yields elements. Everytime it is called, it is supposed to yield an element and then continue from there - asynchronous programing: it stops and waits until something else is finished - in the context manager, as we will see

[12]:
import contextlib


@contextlib.contextmanager
def printer(x):
    print(f'we just entered the context manager and will yield {x}')
    yield x
    print(f'Finishing the context manager, exiting')
[13]:
with printer(5) as number:
    print(f"we're inside, with number={number}")
print("left manager")
we just entered the context manager and will yield 5
we're inside, with number=5
Finishing the context manager, exiting
left manager

Where is this useful

Basically with stateful objects. This includes anything that can be set and changed (mutable objects).

[14]:
with open('tmp.txt', 'w') as textfile:
    textfile.write('asdf')

The implementation roughly looks like this:

[15]:
import contextlib


@contextlib.contextmanager
def myopen(f, mode):
    opened = open(f, mode)
    yield opened
    opened.close()

Exercise: create a context manager that temporarily sets a 'value' key to 42 of a dict and switches it back to the old value on exit

[16]:
testdict = {'value': 11, 'name': 'the answer'}

to be invoked like this

with manager(testdict) as obj:
    # here the value is 42
# here the value is 11
[17]:
# SOLUTION
@contextlib.contextmanager
def func(x):
    yield x


with func(5) as var1:
    print('inside')
print(var1)
inside
5
[18]:
@contextlib.contextmanager
def set_answer(obj):
    old_value = obj.get('value')
    obj['value'] = 42
    yield obj
    obj['value'] = old_value

Using a class

Instead of using the yield, we can have advanced control over the enter and exit methods by creating a class and implementing the two methods __enter__ and __exit__

[19]:
class MyContext:

    def __init__(self, x):
        self.x = x

    def __enter__(self):
        x = self.x
        print('entered')
        return x ** 2

    def __exit__(self, type_, value, traceback):  # but let's not go into things in detail here
        self.x = 42
        print('exited')
[20]:
with MyContext(5) as x:
    print(x)
entered
25
exited

While a class is way more powerful and offers ways to catch exceptions and more in the exit, ususally the functional way is enough and should then be preferred. If it doesn’t give you enough flexibility, remember the class, look it up and figure out all the things needed.

Decorators and factories

Sometimes we can’t write a function fully by hand but want to create it programatically. This pattern is called a “factory”. To achieve this, instead of having a function that returns an integer (an object), a list (an object), a dict (an object) or an array (an object), we return a function (an object). We see that the concept of Python, “everything is an object”, starts being very useful here.

[21]:
def make_power_func(power):
    def func(x):
        return x ** power
    return func
[22]:
pow3 = make_power_func(3)
[23]:
pow3(2)
[23]:
8
[24]:
def make_power_func(power):
    def func(x):
        return x ** power
    power = 42
    return func
[25]:
pow3 = make_power_func(3)
[26]:
pow3(2)
[26]:
4398046511104
[27]:
# Exercise: test it here

Another example is to create a timing wrapper. Exercise: create a timing function that can be used as follows

timed_pow3 = fime_func(pow3)
pow3(...)

HINT, scetch of solution

def time_func(func):
    def new_func(...):
        print('start')
        func(...)
        print('stop')
    return new_func
[28]:
# SOLUTION
def timed_func(func):
    def wrapped_func(*args, **kwargs):
        print(args)
        print(kwargs)
        start = time.time()
        func(*args, **kwargs)
        end = time.time()
        print(f'time needed: {end - start}')
    return wrapped_func
[29]:
def add_notime(x, y):
    return x + y
[30]:
add_timed = timed_func(add_notime)
[31]:
import time
[32]:
add_timed(y=4, x=5)
()
{'y': 4, 'x': 5}
time needed: 9.5367431640625e-07
[33]:
# test it here

Decorator

There is another way, just syntactical sugar, to make this automatic: a decorator. It is invoked as below

[34]:
@timed_func
def add(x, y):
    return x + y

Again, as for the contextmanager, we can also use a class here to give more flexibility and create a decorator that takes arguments.

Exceptions

Exceptions are used to stop the execution at a certain point and surface to higher stacks in the code, e.g. to go up in the call stack. A typical use-case is when an error is encountered, such as the wrong type of object is given. Exceptions can also be caught in a try ... except ... block in order to handle the exception.

There are a few built-in exceptions, the most common ones are: - TypeError: object has the wrong type, e.g. string instead of float - ValueError: the value of the object is illegal, e.g. negative but should be positive - RuntimeError: if a function is illegally executed or a status is wrong. E.g. if an object first has to be loaded before it gets parsed. It covers any error that does not fall into an other category. - KeyError, IndexError: if a key or index is not available, e.g. in a dict or list

An Exception can manually be raised by

[35]:
raise TypeError("Has to be int, not str")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[35], line 1
----> 1 raise TypeError("Has to be int, not str")

TypeError: Has to be int, not str

Note that it is often convenient to create an instance such as in the example above where the first argument is the message (as we see in the raised Exception above), but we can also raise an exception by only using the class itself

[36]:
raise TypeError
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[36], line 1
----> 1 raise TypeError

TypeError:

Custom Exception

In Python, exceptions are simply a class. And as such, we can inherit from it and create our own exception.

Attention: inherit from Exception or subclasses of it such as TypeError, ValueError, but NEVER from BaseException.

[37]:
class MyError(Exception):
    pass
[38]:
raise MyError("Hello world")
---------------------------------------------------------------------------
MyError                                   Traceback (most recent call last)
Cell In[38], line 1
----> 1 raise MyError("Hello world")

MyError: Hello world

An exception can also be created by inheriting from an already existing exception if it is more specific and provides hints on the nature of the exception.

[39]:
class NegativeValueError(ValueError):
    pass

Catching exceptions

An exception can be caught in a try..except block. This works as follows: - if an exception is raised in the try block, the next except is invoked - it is tested whether the raised exception is of type subclass of the exception type specified to be caught. For example, except TypeError checks if the raised error is of type TypeError or a subclass of it. - if that is not the case, it goes to the next except statement (yes, there can be multiple) - … more below

[40]:
try:
    raise NegativeValueError("Negative value encountered")
except ValueError as error:
    print(f"Caught {error}")
Caught Negative value encountered

By using the as keyword, the error that is raised is assigned to a variable. We can inspect the error now if we want or, as above, just print it.

If no error is specified, any error is caught (this should NOT be used, except for special cases

[41]:
try:
    raise TypeError
# Anti-pattern, do NOT use in general!
except:  # any exception if not specified
    pass
[42]:
try:
    raise TypeError("Type was wrong, unfortunately")
except TypeError as error:  # any exception
    print(f'caught TypeError: {error}')
    raise
except ValueError as error:
    print(f'caugth ValueError: {error}')
caught TypeError: Type was wrong, unfortunately
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[42], line 2
      1 try:
----> 2     raise TypeError("Type was wrong, unfortunately")
      3 except TypeError as error:  # any exception
      4     print(f'caught TypeError: {error}')

TypeError: Type was wrong, unfortunately

To continue from above: after the last except, an else statement is looked for. The else is executed if no exception was raised.

[43]:
try:
    print('no error raised')
#     raise TypeError("Type was wrong, unfortunately")
except TypeError as error:  # any exception
    print(f'caught Type {error}')
except ValueError as error:
    print(f'caugth Value: {error}')
else:
    print("No error")

print("Executed after block")
no error raised
No error
Executed after block

…and finally, after the else, a finally block is looked for. This is guaranteed to be executed! Whether an exception is raised, whether it is caught or not, whether there is an else or not, the finally is always executed.

Therefore it is suitable for any cleanup code such as closing files, removing temporary files and more.

[44]:
try:
#     pass
#     raise TypeError("Type was wrong, unfortunately")
    raise RuntimeError("Type was wrong, unfortunately")
except TypeError as error:  # any exception
    print(f'caught Type {error}')
except ValueError as error:
    print(f'caugth Value: {error}')
else:
    print("No error")
finally:  # POWERFUL! Guarantied to be executed
    print('Finally run')
print("Executed when passed")
Finally run
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[44], line 4
      1 try:
      2 #     pass
      3 #     raise TypeError("Type was wrong, unfortunately")
----> 4     raise RuntimeError("Type was wrong, unfortunately")
      5 except TypeError as error:  # any exception
      6     print(f'caught Type {error}')

RuntimeError: Type was wrong, unfortunately

Note that in the above example, the error was not caught! All the other statements could also be omitted and only a try...finally block can be created.

[45]:
try:
    raise ValueError
finally:
    print('raised')
raised
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[45], line 2
      1 try:
----> 2     raise ValueError
      3 finally:
      4     print('raised')

ValueError:

pitfall “guaranteed execution”

As the finally is guaranteed to be executed, this can have an odd effect: possible return statements can be ignored before the finally IF the finally also has a return statement. The logic says here that the finally return must be executed, as it is guaranteed to be executed.

[46]:
def func(x):
    try:
        if x == 5:
            raise RuntimeError('called inside func')
    except RuntimeError as error:
        return error
    else:
        print('else before 42')
        return 42
        print('after else 42')
    finally:
        print("cleaned up")
        return 11
[47]:
func(6)
else before 42
cleaned up
[47]:
11

Exceptions as control-flow

We are used to control-flow elements such as if...elif...else blocks. However, exceptions can also be used for this. They do not replace other ways of control-flow, however there are sometimes situations in which they provide a golden solution to steer the execution.

As an example, consider an add function that sometimes can add three elements - which is, for the sake of a good example, favorable as more performant (real world cases of this exist in larger scale, but too complicated to explain here) - and sometimes not. Also assume that the add function is called again maybe inside. A solution is to execute add with three elements. If an error is raised, we catch it (the specific one), and run the function again with two arguments and add the third argument by calling add again.

Note that this also solves the problem if add is called deeper nested again: we don’t care where it is called, we just try again with only two numbers. The advantage is that we don’t need to check the output of the function; this will always be a number (and not a None or something like this).

[48]:
def add(a, b, c=None):
    if c is not None:
        raise MyError
    return a + b
[49]:
add(1, 2, 3)
---------------------------------------------------------------------------
MyError                                   Traceback (most recent call last)
Cell In[49], line 1
----> 1 add(1, 2, 3)

Cell In[48], line 3, in add(a, b, c)
      1 def add(a, b, c=None):
      2     if c is not None:
----> 3         raise MyError
      4     return a + b

MyError:
[50]:
try:
    result = add(1, 2, 3)
except MyError as error:
    result = add(add(1, 2), 3)
result
[50]:
6