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.
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
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
for number in numberlist:
= 1.0 / number
inverse 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.
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
1try:
for number in numberlist:
= 1.0 / number
inverse print(f"Check: {inverse} * {number} = {inverse * number}")
2except ZeroDivisionError:
3print("Warning: zero does not have an inverse!")
- 1
-
The try clause starts with
try
and ends withexcept
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.
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
1for number in numberlist:
2try:
= 1.0 / number
inverse print(f"Check: {inverse} * {number} = {inverse * number}")
3except 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.
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
for number in numberlist:
try:
= 1.0 / number
inverse print(f"Check: {inverse} * {number} = {inverse * number}")
1except ZeroDivisionError:
print("Warning: zero does not have an inverse!")
2except 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 )
).
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
for number in numberlist:
try:
= 1.0 / number
inverse print(f"Check: {inverse} * {number} = {inverse * number}")
1except (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.
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
for number in numberlist:
try:
= 1.0 / number
inverse 1except BaseException as be:
2print(f"Error: I can not compute the inverse of {number}!")
print(f"Message is: {be}")
3else:
print(f"Check: {inverse} * {number} = {inverse * number}")
4finally:
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:
"Oh good, I can not handle this at the moment.")
e.add_note(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:
- 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
= "exception.qmd"
path 1with open(path, "r") as file:
print(file.readline())
- 1
- Automatically closes the file as soon as the context is closed.
# Exception handling
This is not a full replacement of a try-except
block.
= "exception1.qmd"
path 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:
"hello world") xfile.write(
It is also possible to collect exceptions and raise them in a group.
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist = []
errors
for i, number in enumerate(numberlist):
try:
= 1.0 / number
inverse except BaseException as be:
f"Happened for entry {i}")
be.add_note(
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
= [-2, -1, 0, 1, 2, "a", 1/4]
numberlist
for number in numberlist:
try:
assert isinstance(number, numbers.Number)
assert number != 0
= 1.0 / number
inverse 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