10  Exception handling

This section deals with what to do in Python to make sure (un)expected errors during the runtime are handled in such a way that the program is not closing down in an unexpected way.

Warning

We will not deal with Syntax Errors here.

10.1 Exceptions

The BaseException class is the base class for the built-in exceptions (see docs and tutorials) and all the built-in exception are inherited from this class. For writing our own exceptions however, we should use Exception as the base.

In order to introduce the concept let us show some of the most common exceptions

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]

for number in numberlist:
    inverse = 1.0 / number
    print(f"Check: {inverse} * {number} = {inverse * number}")
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[1], line 4
      1 numberlist = [-2, -1, 0, 1, 2, "a", 1/4]
      3 for number in numberlist:
----> 4     inverse = 1.0 / number
      5     print(f"Check: {inverse} * {number} = {inverse * number}")

ZeroDivisionError: float division by zero

We can see that an ZeroDivisionError is raised for the third entry in the list of numberlist. What happened above is not a graceful exit as our little program just stopped at the point of exception and the last action of the interpreter was to print the traceback. Importantly, the other elements in the list are never looked at.

We can handle exceptions with the try-except construction to actually make sure that we react appropriately.

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]

1try:
    for number in numberlist:
        inverse = 1.0 / number
        print(f"Check: {inverse} * {number} = {inverse * number}")
2except ZeroDivisionError:
3    print("Warning: zero does not have an inverse!")
1
The try clause starts with try and ends with except and is executed normal
2
The exception clause starts at except, we can specify for which exception to look for.
3
The code in the context of except is only accessed if an exception occurs.
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
Warning: zero does not have an inverse!

From the output we can see that the exception occurred and was handled properly. Nevertheless, the rest of the elements in the list are still not handled. To make sure this is the case we need to move our try/except into the loop.

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]
                                                                  
1for number in numberlist:
2    try:
        inverse = 1.0 / number
        print(f"Check: {inverse} * {number} = {inverse * number}")
3    except ZeroDivisionError:
        print("Warning: zero does not have an inverse!")
1
Context of the for loop
2
Context of the try clause
3
Context of the except clause
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
Warning: zero does not have an inverse!
Check: 1.0 * 1 = 1.0
Check: 0.5 * 2 = 1.0
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[3], line 5
      3 for number in numberlist:                                            # <1>
      4     try:                                                          # <2>  
----> 5         inverse = 1.0 / number                                    # <2>
      6         print(f"Check: {inverse} * {number} = {inverse * number}")# <2>
      7     except ZeroDivisionError:                                     # <3>

TypeError: unsupported operand type(s) for /: 'float' and 'str'

Now everything works as expected and we see our second error TypeError happen for "a" in the list. We have multiple options as this point how to deal with more than one exception.

If we use multiple except we can interpret them similar to if, elif, and else statements, where we jump into one part as soon as we match.

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]
                                                                  
for number in numberlist:                                            
    try:                                                            
        inverse = 1.0 / number                                    
        print(f"Check: {inverse} * {number} = {inverse * number}")
1    except ZeroDivisionError:
        print("Warning: zero does not have an inverse!")
2    except TypeError:
        print("Error: The inverse is only defined for a number!")
1
Context of the ZeroDivisionError
2
Context of the TypeError
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
Warning: zero does not have an inverse!
Check: 1.0 * 1 = 1.0
Check: 0.5 * 2 = 1.0
Error: The inverse is only defined for a number!
Check: 4.0 * 0.25 = 1.0

Similar to when we inherited for multiple classes, we can combine exceptions by putting them into a set (with ( and )).

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]
                                                                  
for number in numberlist:                                            
    try:                                                            
        inverse = 1.0 / number                                    
        print(f"Check: {inverse} * {number} = {inverse * number}")
1    except (ZeroDivisionError, TypeError):
        print("Error: I can not compute the inverse!")            
1
Handling multiple errors in one except clause.
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
Error: I can not compute the inverse!
Check: 1.0 * 1 = 1.0
Check: 0.5 * 2 = 1.0
Error: I can not compute the inverse!
Check: 4.0 * 0.25 = 1.0

The construct has one further extension. After all of the except blocks we can place an else as well finally at the very end. The else block is called if no exception occurred and the finally is always called.

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]
                                                                  
for number in numberlist:                                            
    try:                                                            
        inverse = 1.0 / number                                    
1    except BaseException as be:
2        print(f"Error: I can not compute the inverse of {number}!")
        print(f"Message is: {be}")
3    else:
        print(f"Check: {inverse} * {number} = {inverse * number}")
4    finally:
        print("Let us work on the next element")
1
Inheritance allows you to match for all child error classes
2
If we give the exception a name we can access it during the error handling.
3
This block is only called if no exception occurred
4
This block is always called and normally used to clean up
Check: -0.5 * -2 = 1.0
Let us work on the next element
Check: -1.0 * -1 = 1.0
Let us work on the next element
Error: I can not compute the inverse of 0!
Message is: float division by zero
Let us work on the next element
Check: 1.0 * 1 = 1.0
Let us work on the next element
Check: 0.5 * 2 = 1.0
Let us work on the next element
Error: I can not compute the inverse of a!
Message is: unsupported operand type(s) for /: 'float' and 'str'
Let us work on the next element
Check: 4.0 * 0.25 = 1.0
Let us work on the next element

Of course you can also work with exception and raise them yourself, see docs for all the details.

To raise an exception we use

raise ValueError("No value given.")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[7], line 1
----> 1 raise ValueError("No value given.")

ValueError: No value given.

We can chain exceptions in case something happens during the handling of an exception

try:
    open("missing.txt")
except OSError as e:
    raise RuntimeError("Oh good, I can not handle this at the moment.") from e
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[8], line 2
      1 try:
----> 2     open("missing.txt")
      3 except OSError as e:

File ~/work/MECH-M-DUAL-1-SWD/MECH-M-DUAL-1-SWD/.venv/lib/python3.12/site-packages/IPython/core/interactiveshell.py:324, in _modified_open(file, *args, **kwargs)
    318     raise ValueError(
    319         f"IPython won't let you open fd={file} by default "
    320         "as it is likely to crash IPython. If you know what you are doing, "
    321         "you can use builtins' open."
    322     )
--> 324 return io_open(file, *args, **kwargs)

FileNotFoundError: [Errno 2] No such file or directory: 'missing.txt'

The above exception was the direct cause of the following exception:

RuntimeError                              Traceback (most recent call last)
Cell In[8], line 4
      2     open("missing.txt")
      3 except OSError as e:
----> 4     raise RuntimeError("Oh good, I can not handle this at the moment.") from e

RuntimeError: Oh good, I can not handle this at the moment.

To deactivate the chaining we use from None.

Alternatively, you can enrich the error with a note:

try:
    raise ValueError("No value given.")
except ValueError as e:
    e.add_note("Oh good, I can not handle this at the moment.")
    raise
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[9], line 2
      1 try:
----> 2     raise ValueError("No value given.")
      3 except ValueError as e:
      4     e.add_note("Oh good, I can not handle this at the moment.")

ValueError: No value given.
Oh good, I can not handle this at the moment.

10.2 with statements

For certain objects it is clear that some clean-up is required. The prime example is accessing a file:

path = "exception.qmd"                  
1file = open(path, "r")
try:
2    print(file.readline())
finally:
3    file.close()
1
Open the file
2
Read from the file
3
We always need to close the file again.
# Exception handling

The short and easier to read version is

path = "exception.qmd"
1with open(path, "r") as file:
    print(file.readline())
1
Automatically closes the file as soon as the context is closed.
# Exception handling
Caution

This is not a full replacement of a try-except block.

path = "exception1.qmd"
try:
    with open(path, "r") as file:
        print(file.readline())
except OSError as os:
    print(f"Something happened: {os}")
Something happened: [Errno 2] No such file or directory: 'exception1.qmd'

If you write your own class you can include the necessary functions for the with statement as follows, Example from GeeksForGeeks:

class MessageWriter(object):
    def __init__(self, file_name):
        self.file_name = file_name
    
    def __enter__(self):
        self.file = open(self.file_name, "w")
        return self.file

    def __exit__(self, *args):
        self.file.close()

# using with statement with MessageWriter

with MessageWriter("my_file.txt") as xfile:
    xfile.write("hello world")

It is also possible to collect exceptions and raise them in a group.

numberlist = [-2, -1, 0, 1, 2, "a", 1/4]
errors = []

for i, number in enumerate(numberlist):
    try:                                                            
        inverse = 1.0 / number
    except BaseException as be:
        be.add_note(f"Happened for entry {i}")
        errors.append(be)
if len(errors) > 0:
    raise ExceptionGroup("The following exceptions occurred:", errors)
  + Exception Group Traceback (most recent call last):
  |   File "/home/runner/work/MECH-M-DUAL-1-SWD/MECH-M-DUAL-1-SWD/.venv/lib/python3.12/site-packages/IPython/core/interactiveshell.py", line 3577, in run_code
  |     exec(code_obj, self.user_global_ns, self.user_ns)
  |   File "/tmp/ipykernel_2307/3846130209.py", line 11, in <module>
  |     raise ExceptionGroup("The following exceptions occurred:", errors)
  | ExceptionGroup: The following exceptions occurred: (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/tmp/ipykernel_2307/3846130209.py", line 6, in <module>
    |     inverse = 1.0 / number
    |               ~~~~^~~~~~~~
    | ZeroDivisionError: float division by zero
    | Happened for entry 2
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "/tmp/ipykernel_2307/3846130209.py", line 6, in <module>
    |     inverse = 1.0 / number
    |               ~~~~^~~~~~~~
    | TypeError: unsupported operand type(s) for /: 'float' and 'str'
    | Happened for entry 5
    +------------------------------------

10.3 Assertions

There is an additional feature that is a good way to make sure that conditions are met before working on something. For this assert can be used. It tests if a condition is true and if this is not the case an AssertionError is raised.

import numbers
numberlist = [-2, -1, 0, 1, 2, "a", 1/4]

for number in numberlist:
    try:
        assert isinstance(number, numbers.Number)
        assert number != 0
        inverse = 1.0 / number
    except AssertionError:
        print(f"Error: I can not compute the inverse of {number}!")
    else:
        print(f"Check: {inverse} * {number} = {inverse * number}")
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
Error: I can not compute the inverse of 0!
Check: 1.0 * 1 = 1.0
Check: 0.5 * 2 = 1.0
Error: I can not compute the inverse of a!
Check: 4.0 * 0.25 = 1.0

This feature is usually used during development. Therefore, there is an option -O to deactivate it during the run of a python program.

This is often done, due to the fact that each of these assert statements takes some time to be processed.

pdm run python -O test_assert.py
Check: -0.5 * -2 = 1.0
Check: -1.0 * -1 = 1.0
Traceback (most recent call last):
  File "/home/runner/work/MECH-M-DUAL-1-SWD/MECH-M-DUAL-1-SWD/errorhandling/../_assets/errorhandling/test_assert.py", line 8, in <module>
    inverse = 1.0 / number
              ~~~~^~~~~~~~
ZeroDivisionError: float division by zero