13. Exceptions

Coding is an error-prone activity. It is difficult to anticipate when something might go wrong, and even if we could anticipate problems, it is equally, if not more, difficult to decide what to do when something does go wrong. In Python, we can use try-except-finally or just try-except to handle problems that might occur. Generally speaking, there are two types of problems that will bring your program to a grinding halt: errors and exceptions. Errors cannot be handled, but exceptions can be handled (handle by our code). Let’s look at some examples of how to use try-except to handle problems.

13.1. Divide by zero

In math, any (positive) number divided by zero results in infinity. In Python, if you attempt to divide a number by zero, an exception will be thrown. An exception is just a very specific signal indicating that something has gone wrong. The particular exception thrown when attempting to divide a number by zero will be a ZeroDivisionError type of exception. Once an exception is thrown, your code will crash altogether at that very point.

110 / 0 # ZeroDivisionError thrown
2print('hello, world') # we will never reach this statement

You can use try-except to attempt to catch the exception and handle it as appropriate. Below, we simply print an informative message that the denominator is zero.

 1# throws ZeroDivisionError
 2a = 20
 3b = 0
 4c = a / b
 5
 6# try/catch ZeroDivisionError
 7try:
 8    a = 20
 9    b = 0
10    c = a / b
11except ZeroDivisionError:
12    print('your denominator is zero')

If we wanted to capture the details of the exception, we can use the as keyword to alias the exception as a variable.

1try:
2   a = 20
3   b = 0
4   c = a / b
5except ZeroDivisionError as zde:
6   print(f'error: {zde}')

Many times, we just want a quick and dirty way to handle exceptions or we do not really know what types of exceptions will be thrown. In these cases, we can omit the exception type.

1try:
2   a = 20
3   b = 0
4   c = a / b
5except:
6   print(f'something went wrong')

The finally block is always guaranteed to run in a try-catch statement. The catch block is only executed if the particular exception we are trying to catch is thrown. Only one catch block will ever be executed since as soon as an exception is thrown, your program will break (exceptions are thrown one at a time). In the example below, we attempt to divide a by b. We initialize c to None. If there is a division by zero exception, we simply log it (print out the fact that such thing has happened). Since c must always have a valid integer value, the finally block checks to see if c is set, and if not, sets it to -1.

 1a = 20
 2b = 0
 3c = None
 4
 5try:
 6   c = a / b
 7except:
 8   print(f'something went wrong')
 9finally:
10   c = -1 if c is None else c

13.2. Accessing invalid index

An IndexError will be thrown when we attempt to access an element by an index that is outside the bounds of the list indices.

 1# throws IndexError
 2names = ['John', 'Jack', 'Joe']
 3print(names[4])
 4
 5# try/catch IndexError
 6names = ['John', 'Jack', 'Joe']
 7
 8try:
 9    print(names[4])
10except IndexError:
11    print('no such index')

13.3. Accessing invalid key

A KeyError will be thrown when we attempt to access a value with a key that does not exists in a dictionary.

 1# throws KeyError
 2
 3students = {'John': 18, 'Jack': 19}
 4
 5print(students['Joe'])
 6
 7# try/catch KeyError
 8
 9students = {'John': 18, 'Jack': 19}
10
11try:
12    print(students['Joe'])
13except KeyError:
14    print('you tried to access an entry that does not exists')

13.4. Accessing invalid key

We can catch multiple types of exceptions. In the example below, we have a dictionary where the keys are car makes and the associated values are models. If we attempt to use a key that does not exists in the dictionary, a KeyError will be thrown, and if we attempt to use an index that is out of bounds for the list, an IndexError will be thrown.

 1def get_best_car(make, model):
 2    makes = {
 3        'honda': ['accord', 'civic', 'crv'],
 4        'toyota': ['camry', 'avalon', 'sienna']
 5    };
 6
 7    return makes[make.lower()][model]
 8
 9
10# valid
11print(get_best_car('Honda', 1))
12
13# throws IndexError
14print(get_best_car('Honda', 4))
15
16# throws KeyError
17print(get_best_car('Ford', 1))
18
19# catch multiple exceptions
20try:
21    print(get_best_car('Ford', 3))
22except IndexError:
23    print('invalid model')
24except KeyError:
25    print('invalid make')

13.5. Raising an exception

We can also throw or raise an exception.

 1def convert_grade(letter):
 2    grades = {
 3        'a': 4.0,
 4        'b': 3.0,
 5        'c': 2.0,
 6        'd': 1.0,
 7        'f': 0.0
 8    }
 9
10    grade = letter.lower()
11    if grade in grades:
12        return grades[grade]
13    else:
14        raise KeyError(f'{letter} is invalid')
15
16
17convert_grade('A')
18convert_grade('B')
19convert_grade('C')
20convert_grade('D')
21convert_grade('F')
22convert_grade('G')

ValueError is the appropriate exception type to throw when an argument has the right type but wrong value. In the example below, we have a method to compute the area of a square. Note that the side of a square must always be positive. We need to check to see if the side passed into the method is valid; if it’s not valid, then an exception should be thrown.

1def get_area(side):
2   if side < 1:
3      raise ValueError(f'{side} is not greater than zero.')
4   return side * side
5
6print(get_area(10))
7print(get_area(-10))

13.5.1. Exercise

John and Jack took some tests. Jack missed the first test and so there was no score reported for that test. A function to compute the average score of a list of scores was written as below. Modify this code to handle the exception as a result of summing over a list of numbers (scores) with possibly invalid data types.

 1def get_average(grades):
 2   total = sum(grades)
 3   n = len(grades)
 4   average = total / n
 5   return average
 6
 7john = [88, 90, 85, 100]
 8jack = [None, 88, 100, 100]
 9
10print(get_average(john))
11print(get_average(jack))

Solution.

 1def get_average(grades):
 2   try:
 3      total = sum(grades)
 4      n = len(grades)
 5      average = total / n
 6      return average
 7   except TypeError:
 8      valid_grades = [g for g in grades if isinstance(g, int)]
 9      return get_average(valid_grades)
10
11john = [88, 90, 85, 100]
12jack = [None, 88, 100, 100]
13
14print(get_average(john))
15print(get_average(jack))

13.6. User-defined exceptions

We can define our own exceptions by extending the Exception class. All built-in Python exceptions inherits from Exception, however, they all have the word Error as a part of their names (e.g. IndexError, KeyError, ValueError, etc.). This naming convention is a bit confusing as errors cannot be handled (but exceptions can) and if the root class is Exception why not have that word as a part of the name instead of Error (e.g. IndexException).

 1class NegativeValueError(Exception):
 2   def __init__(self, value):
 3      self.value = value
 4      super().__init__(f'{value} is negative.')
 5
 6def get_area(side):
 7   if side < 1:
 8      raise NegativeValueError(side)
 9   return side * side
10
11print(get_area(10))
12print(get_area(-10))

13.6.1. Exercise

Write a function to generate random dimensions for a triangle, square and rectangle. The dimensions (e.g. base, height, width, length) should be in the range [1, 10]. The function should return a dictionary that looks like the following.

  • {'shape': 'triangle', 'dimensions': [4, 10]}

  • {'shape': 'square', 'dimensions': [2]}

  • {'shape': 'rectangle', 'dimensions': [8, 7]}

If a user requests a random circle, throw an InvalidShapeError (you have to create a new exception type). Invoke this new function passing in randomly a request for triangle, square, rectangle and circle.

Solution.

 1from random import randint, choice
 2
 3class InvalidShapeError(Exception):
 4   def __init__(self, shape):
 5      self.shape = shape
 6      super().__init__(f'{shape} is invalid.')
 7
 8def get_shape(shape):
 9   shapes = {
10      'triangle': 2,
11      'square': 1,
12      'rectangle': 2
13   }
14
15   if shape not in shapes:
16      raise InvalidShapeError(shape)
17
18   return {
19      'shape': shape,
20      'dimensions': [randint(1, 10) for _ in range(shapes[shape])]
21   }
22
23all_shapes = ['triangle', 'square', 'rectangle', 'circle']
24
25for _ in range(10):
26   try:
27      shape = get_shape(choice(all_shapes))
28      print(shape)
29   except InvalidShapeError as ise:
30      print(f'Problem: {ise}')