9. The exception mechanism#

9.1. What are exceptions#

By now you have probably seen quite some errors in you code passing by: Syntax Errors, ValueErrors, IndexErrors etc. The first is something you cannot deal with in your program but the others are called (runtime) exceptions. They indicate that something exceptional has occurred. The Python interpreter is designed in such a way that when this happens you get a Traceback of the method calls all the way down the call stack. We will now investigate these concepts in more detail, and als introduce a mechanism that you can use to hook into the flow of exceptions.

Consider the code cell below, which has two methods calling each other to form a small call stack.

def process_data(data):
    print("processing data")
    for e in data:
        process_person(e)

def process_person(tup):
    print("processing person")
    print(f"last name={tup[1]}")

my_data = [("Mike", "Mutter"), ("Ralph Racker", ), ("Louis", "Levee")]
process_data(my_data)
processing data
processing person
last name=Mutter
processing person
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[1], line 11
      8     print(f"last name={tup[1]}")
     10 my_data = [("Mike", "Mutter"), ("Ralph Racker", ), ("Louis", "Levee")]
---> 11 process_data(my_data)

Cell In[1], line 4, in process_data(data)
      2 print("processing data")
      3 for e in data:
----> 4     process_person(e)

Cell In[1], line 8, in process_person(tup)
      6 def process_person(tup):
      7     print("processing person")
----> 8     print(f"last name={tup[1]}")

IndexError: tuple index out of range

From the “global” context, which is the executing cell in this case (this is rather different when running scripts), method process_data is called and from this method process_person. everything is going hunky-dory until the process_person method tries to access a non-existing tuple element of the second person.
The Python interpreter has a special kind of error to inform you of such events: the IndexError.

Because we have no error / exception event handling in place, the error “falls” all the way through the call stack until it reaches “main”. The interpreter then exits execution with a representation of the route to the origin of the error.
Fortunately this traceback is really informative; it gives you in nice highlighted text

  • The type of error with extra info: IndexError: tuple index out of range

  • The state of the call stack at the moment the error occurred

  • The origin of the error: tup[1]

9.2. Catching exceptions#

So are we completely helpless in case of such events? No! We have have the tools to hook into the exception mechanism to prevent a crashing program. The basic tool is the try/except block:

try:
    # do something risky
except:
    # recover from error event

Given our apparently risky method:

def process_person(tup):
    print("processing person")
    print(f"last name={tup[1]}")

We need to ask ourselves “Is there a erasonable way to recover from the event where a person has no last name?

In this case there is one, and it is simply assigning a deafult name in case of absence:

def process_person(tup):
    print("processing person")
    try:
        last_name = tup[1]
    except:
        last_name = "UNKNOWN"
    print(f"last name={last_name}")
    

Now when the same data processing is performed we simply have an unknwon person in our collection

my_data = [("Mike", "Mutter"), ("Ralph Racker", ), ("Louis", "Levee")]
process_data(my_data)
processing data
processing person
last name=Mutter
processing person
last name=UNKNOWN
processing person
last name=Levee

But wait! There is more. What if a completely different error occurs from the one we were expecting? Here is a hypothetical example”

def process_person(tup):
    print("processing person")
    try:
        last_name = tup[1]
        if last_name == "Levee":
            x = 1/0
    except:
        last_name = "UNKNOWN"
    print(f"last name={last_name}")
my_data = [("Mike", "Mutter"), ("Ralph", "Racker"), ("Louis", "Levee")]
process_data(my_data)
processing data
processing person
last name=Mutter
processing person
last name=Racker
processing person
last name=UNKNOWN

Now we have an unknown person when there actually is a last name for this person. This can be solved by catching the right type of exception.

def process_person(tup):
    print("processing person")
    try:
        last_name = tup[1]
        if last_name == "Levee":
            x = 1/0
    except IndexError:
        last_name = "UNKNOWN"
    print(f"last name={last_name}")

The consequence is that the name handling is correct, but the ZeroDivisionError will still cause the system to crash.

my_data = [("Mike", "Mutter"), ("Ralph", "Racker"), ("Louis", "Levee")]
process_data(my_data)
processing data
processing person
last name=Mutter
processing person
last name=Racker
processing person
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[13], line 2
      1 my_data = [("Mike", "Mutter"), ("Ralph", "Racker"), ("Louis", "Levee")]
----> 2 process_data(my_data)

Cell In[3], line 4, in process_data(data)
      2 print("processing data")
      3 for e in data:
----> 4     process_person(e)

Cell In[12], line 6, in process_person(tup)
      4     last_name = tup[1]
      5     if last_name == "Levee":
----> 6         x = 1/0
      7 except IndexError:
      8     last_name = "UNKNOWN"

ZeroDivisionError: division by zero

We can catch an exception where it occurs or anywhere “below” the risky operation in the call stack. Here is another solution where the person causing the error is completely ignored:

def process_data(data):
    print("processing data")
    for e in data:
        try:
            process_person(e)
        except IndexError:
            print("--PERSON IGNORED--")

def process_person(tup):
    print("processing person")
    print(f"last name={tup[1]}")

my_data = [("Mike", "Mutter"), ("Ralph Racker", ), ("Louis", "Levee")]
process_data(my_data)
processing data
processing person
last name=Mutter
processing person
--PERSON IGNORED--
processing person
last name=Levee

Finally, you can get a hold of the actual error object and extract some information from it:

def process_data(data):
    print("processing data")
    for e in data:
        process_person(e)

def process_person(tup):
    print("processing person")
    try:
        print(f"last name={tup[1]}")
    except IndexError as e:
        print(f"ERROR: {e} on {tup}")

my_data = [("Mike", "Mutter"), ("Ralph Racker", ), ("Louis", "Levee")]
process_data(my_data)
processing data
processing person
last name=Mutter
processing person
ERROR: tuple index out of range on ('Ralph Racker',)
processing person
last name=Levee

9.3. Raising exceptions#

Sometimes you want to message your own error, or “convert” a previously cought exception into another type. In that case you will want to raise an exception in your code yourself.

def raise_it():
    print("going for it")
    raise ValueError("chickening out")
    
raise_it()
going for it
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[20], line 5
      2     print("going for it")
      3     raise ValueError("chickening out")
----> 5 raise_it()

Cell In[20], line 3, in raise_it()
      1 def raise_it():
      2     print("going for it")
----> 3     raise ValueError("chickening out")

ValueError: chickening out