# 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()
13    else:
14        raise KeyError(f'{letter} is invalid')
15
16
```

`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)]
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}')
```