Exceptions

We have already seen exceptions. Whenever there is a problem in the code that Python can’t automatically handle, Python will raise an exception to report the error. The regular program flow is interrupted at this point.

Now we’re going to learn about what exceptions are, how to catch and deal with exceptions, and how to raise our own exceptions.

Catching Exceptions

Let’s make a program that allows users to solve quadratic equations.

First we’ll take a program quadratic.py that includes a quadratic function.

import math
import sys


def quadratic(a, b, c):
    x1 = -1 * b / (2 * a)
    x2 = math.sqrt(b ** 2 - 4 * a * c) / (2 * a)
    return (x1 + x2), (x1 - x2)


def main(args):
    a, b, c = (float(x) for x in args)
    solution1, solution2 = quadratic(a, b, c)
    print(f"x = {solution1} or {solution2}")


if __name__ == "__main__":
    main(sys.argv[1:])

Let’s try it out:

$ python quadratic.py 2 5 3
x = -1.0 or -1.5

Let’s try calling our program without any arguments:

$ python quadratic.py
Traceback (most recent call last):
File "quadratic.py", line 18, in <module>
    main(sys.argv[1:])
File "quadratic.py", line 12, in main
    a, b, c = (float(x) for x in args)
ValueError: need more than 0 values to unpack

That’s not a very helpful message for users. Let’s handle that exception and make a nicer error message for the user.

def main(args):
    try:
        a, b, c = (float(x) for x in args)
    except ValueError:
        print("Error: Three numeric arguments required")
        exit(1)
    solution1, solution2 = quadratic(a, b, c)
    print(f"x = {solution1} or {solution2}")

Here we introduce the “try-except” statements for handling exceptions. We have some code that might cause an error, so we put it inside of a try block. The try statement tells Python to be on the lookout for errors.

The except statement tells Python what exception to look for, or “catch”. The except block contains the code to handle the error if it occurs.

We don’t want to continue after we handle the exception, so after we print an informative message for the user, we call the system exit() function to exit Python. exit(0) means no error and any other value (we arbitrarily use 1) means there was an error. By default when a program ends normally, it is as if exit(0) was called.

If there is no exception caught, then the program flow continues after the except block.

Let’s try that out:

$ python quadratic.py
Error: Three numeric arguments required

So we can use try-except blocks to catch exceptions.

Let’s try running our program with 0 as the first argument:

$ python quadratic.py 0 1 5
Traceback (most recent call last):
File "quadratic.py", line 22, in <module>
    main(sys.argv[1:])
File "quadratic.py", line 17, in main
    solution1, solution2 = quadratic(a, b, c)
File "quadratic.py", line 6, in quadratic
    x1 = -1 * b / (2 * a)
ZeroDivisionError: float division by zero

Uh oh. Another unhandled exception! This time the exception is raised from inside our quadratic function.

Why don’t we refactor our code to handle all exceptions?

def main(args):
    try:
        a, b, c = (float(x) for x in args)
        solution1, solution2 = quadratic(a, b, c)
        print(f"x = {solution1} or {solution2}")
    except Exception:
        print("Error: bad inputs")
        exit(1)

The Exception class is the base class that all non-system-exiting exceptions inherit from.

>>> issubclass(ValueError, Exception)
True
>>> error = ValueError()
>>> isinstance(error, Exception)
True
>>> isinstance(error, ValueError)
True

Trying this new code out, we can see that this change doesn’t seem like an improvement from the user’s perspective:

$ python quadratic.py
Error: bad inputs
$ python quadratic.py 0 1 5
Error: bad inputs

The user knows even less than if we had just let the exceptions go uncaught.

Let’s refactor our code to handle each exception separately.

def main(args):
    try:
        a, b, c = (float(x) for x in args)
    except ValueError:
        print("Error: Three numeric arguments required")
        exit(1)
    try:
        solution1, solution2 = quadratic(a, b, c)
    except ZeroDivisionError:
        print("Error: the first argument cannot be zero")
        exit(1)
    print(f"x = {solution1} or {solution2}")

This is much more helpful:

$ python quadratic.py
Error: Three numeric arguments required
$ python quadratic.py 0 1 5
Error: the first argument cannot be zero

Program Flow

When a function does a return, the program flow goes back to the code immediately following the function call, and continues. However, when an exception is raised, the program flow is interrupted.

The system looks for the closest enclosing try with an appropriate except block. If the exception happened inside of a try block, then if the except block contains the error that was triggered, program control goes to the except block. If not, then the system goes up the calling stack, looking for an enclosing try block with an except block for the correct exception type.

If it does not find anything that applies, the exception will cause the program to exit.

Catching Multiple Exceptions

Can we think of another way to break our quadratic program? What if we took the square root of a negative number?

$ python quadratic.py 4 1 1
Traceback (most recent call last):
File "quadratic.py", line 26, in <module>
    main(sys.argv[1:])
File "quadratic.py", line 18, in main
    solution1, solution2 = quadratic(a, b, c)
File "quadratic.py", line 7, in quadratic
    x2 = math.sqrt(b ** 2 - 4 * a * c) / (2 * a)
ValueError: math domain error

Another error in our quadratic function call!

There are a couple of ways we can handle this. We could catch both errors at once:

def main(args):
    try:
        a, b, c = (float(x) for x in args)
    except ValueError:
        print("Error: Three numeric arguments required")
        exit(1)
    try:
        solution1, solution2 = quadratic(a, b, c)
    except (ValueError, ZeroDivisionError):
        print("Error: math error")
        exit(1)
    print(f"x = {solution1} or {solution2}")

But now we’re back to giving our user very little information about what happened. A better way is to have separate except blocks for each error:

def main(args):
    try:
        a, b, c = (float(x) for x in args)
    except ValueError:
        print("Error: Three numeric arguments required")
        exit(1)
    try:
        solution1, solution2 = quadratic(a, b, c)
    except ZeroDivisionError:
        print("Error: the first argument cannot be zero")
        exit(1)
    except ValueError:
        print("Error: invalid arguments")
        exit(1)
    print(f"x = {solution1} or {solution2}")

Now we’ll get two different error messages:

$ python quadratic.py 0 0 0
Error: the first argument cannot be zero
$ python quadratic.py 4 1 1
Error: invalid arguments

We can also grab the actual exception object using as:

def main(args):
    try:
        a, b, c = (float(x) for x in args)
    except ValueError as e:
        print(f"Error: {e}")
        exit(1)
    try:
        solution1, solution2 = quadratic(a, b, c)
    except ZeroDivisionError:
        print("Error: the first argument cannot be zero")
        exit(1)
    except ValueError:
        print("Error: invalid arguments")
        exit(1)
    print(f"x = {solution1} or {solution2}")

This won’t make our error message more useful though:

$ python quadratic.py
Error: need more than 0 values to unpack

So we should probably revert that change.

Raising

We’ve seen how to catch exceptions when they occur. But, we can also raise our own exceptions for other people to catch.

For example let’s modify our get_hypotenuse function to give an error whenever a non-positive number is provided:

def get_hypotenuse(x, y):
    if x <= 0 or y <= 0:
        raise ValueError("x and y must be positive numbers")
    return (x ** 2 + y ** 2) ** 0.5

Now when we call get_hypotenuse with zero or a negative number, an exception will be raised:

>>> get_hypotenuse(3, 4)
5.0
>>> get_hypotenuse(-3, 4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in get_hypotenuse
ValueError: x and y must be positive numbers

Exception Exercises

Hint

If you get stuck for a minute or more, try searching Google or using help.

If you’re stuck for more than a few minutes, some of these links might be helpful for some of the exercises below:

Length or None

This is the len_or_none exercise in exception.py.

Write a function len_or_none that returns the length of a given object or None if the object has no length.

>>> from exception import len_or_none
>>> len_or_none("hello")
5
>>> len(4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: object of type 'int' has no len()
>>> len_or_none(4)
>>> print(len_or_none(4))
None
>>> len_or_none([])
0
>>> len_or_none(zip([1, 2], [3, 4]))
>>> print(len_or_none(zip([1, 2], [3, 4])))
None
>>> len_or_none(range(10))
10

Average

This is the average.py exercise in the modules directory. Create the file average.py in the modules sub-directory of the exercises directory. To test it, run python test.py average.py from your exercises directory.

Make a program average.py that calculates the average of all provided command-line arguments and prints an error message if no arguments are provided and a second error message if invalid arguments are provided.

Note

To test these changes, you should modify the AverageTests class in modules_test.py to comment out the unittest.skip lines from the appropriate test methods.

$ python average.py
No numbers to average!
$ python average.py 2 3 4 5 6 7
Average is 4.5
$ python average.py 2 3 4
Average is 3.0
$ python average.py 2 s 3
Invalid values entered, only numbers allowed!

Flipped Dictionary Exception

Modify the flip_dict exercise in dictionaries.py to disallow duplicate values in the given dictionary. A ValueError should be raised if a dictionary with duplicate values is provided.

Note

To run tests for this updated program, open dictionaries_test.py, find the line that starts with class FlipDictTests. Look for the test named test_with_collisions and comment out the @unittest.skip line just above it. That line tells the Test Framework to skip the test because we don’t expect the test to pass, but now we are changing the program so the test should pass, therefore we comment the line out.

Negative Factors

Modify the get_factors exercise in ranges.py to raise a ValueError for negative numbers.

The exception message should state Only positive numbers are supported.

Note

To run tests for this updated program, open ranges_test.py and look for the line with “Comment this line for negative factors exercise” in it, and comment out that @skip line. Previously, the code we wrote wouldn’t pass the test, but now we are changing the program so the test should pass, therefore we comment the line out.

Deep Add

This is the deep_add exercise in exception.py.

Write a function deep_add that sums up all values given to it, including summing up the values of any contained collections.

>>> from exception import deep_add
>>> deep_add([1, 2, 3, 4])
10
>>> deep_add([(1, 2), [3, {4, 5}]])
15

CLI Only Error

Edit the hello.py file in the modules directory that you created in the “Modules->Modules Exercises” section. To test it, run python test.py hello.py in your exercises directory.

Modify the hello.py exercise to raise an ImportError when the module is imported.

The exception message should state This module can only be run from the command-line.

Note

To run tests for this updated program, open modules_test.py, find the line that starts with class HelloTests. Look for the test named test_exception_on_import and comment out the @unittest.skip line just above it. That line tells the Test Framework to skip the test because we don’t expect the test to pass, but now we are changing the program so the test should pass, therefore we comment the line out. In addition, un-comment the @unittest.skip line just above the test named test_import.

Deep Flatten

This is the deep_flatten exercise in exception.py.

Write a function deep_flatten that flattens all items given to it, including any contained collections, returning a list of all elements. As a bonus, make it work for strings, too.

>>> from exception import deep_flatten
>>> deep_flatten([1, 2, 3, 4])
[1, 2, 3, 4]
>>> deep_flatten([[1, 2], [3, [4, 5]]])
[1, 2, 3, 4, 5]

Bonus version should work like this:

Note

To test the bonus, you should modify the DeepFlattenTests class in exception_test.py to comment out the unittest.skip line from the appropriate test method.

>>> from exception import deep_flatten
>>> deep_flatten(["Hello ", "World"])
['Hello ', 'World']
>>> deep_flatten([(1, 2), [3, "bye", {4, 5}]])
[1, 2, 3, 'bye', 4, 5]

Exit Twice

This is the countdown.py exercise in the modules directory. Create the file countdown.py in the modules sub-directory of the exercises directory. To test it, run python test.py countdown.py from your exercises directory.

Write a program countdown.py that counts down from a given number, pausing for 1 seconds in between each number. The program should only exit if Ctrl-C is pressed twice.

You can pause for 1 second by using sleep from the time module.

Ctrl-C pressed just once:

$ python countdown.py 5
5
4
^C Press Ctrl-C again to exit
3
2
1

Ctrl-C pressed twice:

$ python countdown.py 10
10
9
^C Press Ctrl-C again to exit
8
7
^C Goodbye!