# Exceptions¶

When we learned about testing, we saw that Python complains when things go wrong by raising an “Exception” naming a type of error:

1 / 0

---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Input In [1], in <cell line: 1>()
----> 1 1 / 0

ZeroDivisionError: division by zero


Exceptions are objects, forming a class hierarchy. We just raised an instance of the ZeroDivisionError class, making the program crash. If we want more information about where this class fits in the hierarchy, we can use Python’s inspect module to get a chain of classes, from ZeroDivisionError up to object:

import inspect

inspect.getmro(ZeroDivisionError)

(ZeroDivisionError, ArithmeticError, Exception, BaseException, object)


So we can see that a zero division error is a particular kind of Arithmetic Error.

x = 1

for y in x:
print(y)

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [3], in <cell line: 3>()
1 x = 1
----> 3 for y in x:
4     print(y)

TypeError: 'int' object is not iterable

inspect.getmro(TypeError)

(TypeError, Exception, BaseException, object)


When we were looking at testing, we saw that it is important for code to crash with a meaningful exception type when something is wrong. We raise an Exception with raise. Often, we can look for an appropriate exception from the standard set to raise.

However, we may want to define our own exceptions. Doing this is as simple as inheriting from Exception (or one of its subclasses):

class MyCustomErrorType(ArithmeticError):
pass

raise (MyCustomErrorType("Problem"))

---------------------------------------------------------------------------
MyCustomErrorType                         Traceback (most recent call last)
Input In [5], in <cell line: 5>()
1 class MyCustomErrorType(ArithmeticError):
2     pass
----> 5 raise (MyCustomErrorType("Problem"))

MyCustomErrorType: Problem


class MyCustomErrorType(Exception):
def __init__(self, category=None):
self.category = category

def __str__(self):
return f"Error, category {self.category}"

raise (MyCustomErrorType(404))

---------------------------------------------------------------------------
MyCustomErrorType                         Traceback (most recent call last)
Input In [6], in <cell line: 9>()
5     def __str__(self):
6         return f"Error, category {self.category}"
----> 9 raise (MyCustomErrorType(404))

MyCustomErrorType: Error, category 404


The real power of exceptions comes, however, not in letting them crash the program, but in letting your program handle them. We say that an exception has been “thrown” and then “caught”.

import yaml

try:
user = config["userid"]

except FileNotFoundError:
print("No password file found, using anonymous user.")
user = "anonymous"

print(user)

No password file found, using anonymous user.
anonymous


Note that we specify only the error we expect to happen and want to handle. Sometimes you see code that catches everything:

try:
config = yaml.safe_lod(open("datasource.yaml"))
user = config["userid"]
except:
user = "anonymous"

print(user)

anonymous


This can be dangerous and can make it hard to find errors! There was a mistyped function name there (‘safe_lod’), but we did not notice the error, as the generic except caught it. Therefore, we should be specific and catch only the type of error we want.

## Managing multiple exceptions¶

Let’s create two credential files to read

with open("datasource2.yaml", "w") as outfile:
outfile.write("userid: eidle\n")

with open("datasource3.yaml", "w") as outfile:
outfile.write("user: eidle\n")


def read_credentials(source):
try:
datasource = open(source)
user = config["userid"]
datasource.close()
except FileNotFoundError:
user = "anonymous"
except KeyError:
user = "anonymous"

print(read_credentials("datasource2.yaml"))

('eidle', 'secret')

print(read_credentials("datasource.yaml"))

Password file missing
('anonymous', None)

print(read_credentials("datasource3.yaml"))

Expected keys not found in file
('anonymous', None)


This last code has a flaw: the file was successfully opened, the missing key was noticed, but not explicitly closed. It’s normally OK, as Python will close the file as soon as it notices there are no longer any references to datasource in memory, after the function exits. But this is not good practice, you should keep a file handle for as short a time as possible.

def read_credentials(source):
try:
datasource = open(source)

try:
print("File loaded, trying to extract credentials")
user = config["userid"]
except KeyError:
user = "anonymous"
finally:
# Runs irrespective of whether keys found
print("Closing file")
datasource.close()

except FileNotFoundError:
user = "anonymous"



The finally clause is executed whether or not an exception occurs.

The last optional clause of a try statement, an else clause is called only if an exception is NOT raised. It can be a better place than the try clause to put code other than that which you expect to raise the error, and which you do not want to be executed if the error is raised. It is executed in the same circumstances as code put in the end of the try block, the only difference is that errors raised during the else clause are not caught.

def read_credentials(source):
try:
datasource = open(source)

except FileNotFoundError:
user = "anonymous"

else:
# Runs only if opening the file was successful
try:
print("File loaded, trying to extract credentials")
user = config["userid"]
except KeyError:
user = "anonymous"
finally:
# Runs irrespective of whether keys found
print("Closing file")
datasource.close()



Don’t worry if else seems useless to you; most languages’ implementations of try/except don’t support such a clause. An alternative way of avoiding leaving the file open in the original implementation (and without using else or finally) is to use a context manager:

def read_credentials(source):
try:
with open(source) as datasource:  # closes the file when done
user = config["userid"]
except FileNotFoundError:
user = "anonymous"
except KeyError:
user = "anonymous"


## Catching Exceptions Elsewhere¶

Exceptions do not have to be caught close to the part of the program calling them. They can be caught anywhere “above” the calling point in the call stack: control can jump arbitrarily far in the program: up to the except clause of the “highest” containing try statement.

def f4(x):
if x == 0:
return
if x == 1:
raise ArithmeticError()
if x == 2:
raise SyntaxError()
if x == 3:
raise TypeError()

def f3(x):
try:
print("F3Before")
f4(x)
print("F3After")
except ArithmeticError:
print("F3Except (💣)")

def f2(x):
try:
print("F2Before")
f3(x)
print("F2After")
except SyntaxError:
print("F2Except (💣)")

def f1(x):
try:
print("F1Before")
f2(x)
print("F1After")
except TypeError:
print("F1Except (💣)")

f1(0)

F1Before
F2Before
F3Before
F3After
F2After
F1After

f1(1)

F1Before
F2Before
F3Before
F3Except (💣)
F2After
F1After

f1(2)

F1Before
F2Before
F3Before
F2Except (💣)
F1After

f1(3)

F1Before
F2Before
F3Before
F1Except (💣)


## Design with Exceptions¶

Now we know how exceptions work, we need to think about the design implications… How best to use them.

Traditional software design theory will tell you that they should only be used to describe and recover from exceptional conditions: things going wrong. Normal program flow shouldn’t use them.

Python’s designers take a different view: use of exceptions in normal flow is considered OK. For example, all iterators raise a StopIteration exception to indicate the iteration is complete.

A commonly recommended Python design pattern is to use exceptions to determine whether an object implements a protocol (concept/interface), rather than testing on type.

For example, we might want a function which can be supplied either a data series or a path to a location on disk where data can be found. We can examine the type of the supplied content:

import yaml

def analysis(source):
if type(source) == dict:
name = source["modelname"]
else:
content = open(source)
name = source["modelname"]
print(name)

analysis({"modelname": "Super"})

Super

with open("example.yaml", "w") as outfile:
outfile.write("modelname: brilliant\n")

analysis("example.yaml")

brilliant


However, we can also use the try-it-and-handle-exceptions approach to this.

def analysis(source):
try:
name = source["modelname"]
except TypeError:
content = open(source)
name = source["modelname"]
print(name)

analysis("example.yaml")

brilliant


This approach is more extensible, and behaves properly if we give it some other data-source which responds like a dictionary or string.

def analysis(source):
try:
name = source["modelname"]
except TypeError:
# Source was not a dictionary-like object
# Maybe it is a file path
try:
content = open(source)
name = source["modelname"]
except IOError:
# Maybe it was already raw YAML content
name = source["modelname"]
print(name)

analysis("modelname: Amazing")

Amazing


## Re-Raising Exceptions¶

Sometimes we want to catch an error, partially handle it, perhaps add some extra data to the exception, and then re-raise to be caught again further up the call stack.

The keyword “raise” with no argument in an except: clause will cause the caught error to be re-thrown. Doing this is the only circumstance where it is safe to do except: without catching a specific type of error.

try:
# Something
pass
except:
# Do this code here if anything goes wrong
raise


If you want to be more explicit about where the error came from, you can use the raise from syntax, which will create a chain of exceptions:

def lower_function():
raise ValueError("Error in lower function!")

def higher_function():
try:
lower_function()
except ValueError as e:
raise RuntimeError("Error in higher function!") from e

higher_function()

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Input In [32], in higher_function()
6 try:
----> 7     lower_function()
8 except ValueError as e:

Input In [32], in lower_function()
1 def lower_function():
----> 2     raise ValueError("Error in lower function!")

ValueError: Error in lower function!

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

RuntimeError                              Traceback (most recent call last)
Input In [32], in <cell line: 12>()
8     except ValueError as e:
9         raise RuntimeError("Error in higher function!") from e
---> 12 higher_function()

Input In [32], in higher_function()
7     lower_function()
8 except ValueError as e:
----> 9     raise RuntimeError("Error in higher function!") from e

RuntimeError: Error in higher function!


It can be useful to catch and re-throw an error as you go up the chain, doing any clean-up needed for each layer of a program.

The error will finally be caught and not re-thrown only at a higher program layer that knows how to recover. This is known as the “throw low catch high” principle.