Lesson 14: Exceptions and error handling

This tutorial was generated from a Jupyter notebook. You can download the notebook here.

In [26]:
import warnings

So far, we have encountered errors when we did something wrong. For example, when we tried to change a character in a string, we got a TypeError.

In [1]:
my_str = 'AGCTATC'
my_str[3] = 'G'
TypeError                                 Traceback (most recent call last)
<ipython-input-1-35bd914b99af> in <module>()
      1 my_str = 'AGCTATC'
----> 2 my_str[3] = 'G'

TypeError: 'str' object does not support item assignment

In this case, the TypeError indicates that we tried to do something that is legal in Python for some types, but we tried to do it to a type for which it is illegal (strings are immutable). In Python, this type of error is called an exception. We say that the interpreted "raised an exception." There are many kinds of built-in exceptions, and you can find a list of them, with descriptions here. You can write your own kinds of exceptions, but we will not cover that in bootcamp.

In this lesson, we will investigate how to handle errors in your code. Importantly, we will also touch on the different kinds of errors and how to avoid them. Or, more specifically, in this and the next lesson, we will learn how to use exceptions to help you write better, more bug free code.

Kinds of errors

In computer programs, we can break down errors into three types.

Syntax errors

A syntax error means you wrote something nonsensical, something the Python interpreted cannot understand. An example of a syntax error in English would be the following.

Sir Tristram, violer d'amores, fr'over the short sea, had passen-core rearrived from North Armorica on this side the scraggy isthmus of Europe Minor to wielderfight his penisolate war: nor had topsawyer's rocks by the stream Oconee exaggerated themselse to Laurens County's gorgios while they went doublin their mumper all the time: nor avoice from afire bellowsed mishe mishe to tauftauf thuartpeatrick: not yet, though venissoon after, had a kidscad buttended a bland old isaac: not yet, though all's fair in vanessy, were sosie sesthers wroth with twone nathandjoe.

This is recognizable as English. In fact, it is the second sentence of a very famous novel (Finnegan's Wake by James Joyce). Clearly, many spelling and punctuation rules of English are violated here. To many of us, it is nonsensical, but I do know of some people who have read the book and understand it. So, English is fairly tolerant of a syntax error. A simpler example would be

Boootamp is fun!

This has a syntax error ("Boootcamp" is not in the English language), but we understand what it means. A syntax error in Python would be this:

my_list = [1, 2, 3

We know what this means. We are trying to create a list with three items, 1, 2, and 3. However, we forgot the closing bracket. The Python interpreter is not forgiving; it will raise a SyntaxError exception.

In [2]:
my_list = [1, 2, 3
  File "<ipython-input-2-417643829e38>", line 1
    my_list = [1, 2, 3
SyntaxError: unexpected EOF while parsing

Syntax errors are often the easiest to deal with, since the program will not run at all if one is present.

Runtime errors

Runtime errors occur when a program is syntactically correct, so it can run, but the interpreter encountered something wrong. The example at the start of the tutorial, trying to change a character in a string, is an example of a runtime error. This particular one was a TypeError, which is a more specific type of runtime error. Python does have a RuntimeError, which is just a generic error.

Runtime errors are more difficult to spot than syntax errors because it is possible that a program could run all the way through without encountering the error, but only for certain inputs does it happen. Let's consider the example of a simple function meant to add two numbers.

In [3]:
def add_two_things(a, b):
    """Add two numbers."""
    return a + b

Syntactically, this function is just fine. We can use it and it works.

In [5]:
add_two_things(6, 7)

We can even add strings, even though it was meant to add two numbers.

In [7]:
add_two_things('Hello, ', 'world.')
'Hello, world.'

However, when we try to add a string and a number, we get a TypeError, the kind of runtime error we saw before.

In [9]:
add_two_things('a string', 5.7)
TypeError                                 Traceback (most recent call last)
<ipython-input-9-03a7849a7c3d> in <module>()
----> 1 add_two_things('a string', 5.7)

<ipython-input-3-fb20dd9a25a9> in add_two_things(a, b)
      1 def add_two_things(a, b):
      2     """Add two numbers."""
----> 3     return a + b

TypeError: Can't convert 'float' object to str implicitly

Semantic errors

Semantic errors are perhaps the most nefarious. They occur when your program is syntactically correct, executes without runtime errors, and then produces the wrong result. These errors are the hardest to find and can do the most damage. After all, when your program does not do what you designed it to do, you want it to scream out with an exception!

Following is a common example of a semantic error in which we change a mutable object within a function and then try to reuse it.

In [20]:
# A function to append a list onto itself, with the intention of 
# returning a new list, but leaving the input unaltered
def double_list(in_list):
    """Append a list to itself."""
    in_list += in_list
    return in_list

# Make a list
my_list = [3, 2, 1]

# Double it
my_list_double = double_list(my_list)

# Later on in our program, we want a sorted my_list

# Let's look at my_list:
print('We expect [1, 2, 3]')
print('We get   ', my_list)
We expect [1, 2, 3]
We get    [1, 1, 2, 2, 3, 3]

Yikes! We changed my_list within the function unintentionally. Question: How would you re-rewrite double_list() to avoid this issue?

Handling errors in your code

If you have a syntax error, your code will not even run. So, we will assume we are without syntax errors in this discussion on how to handle errors. So, how can we handle runtime errors? In most use cases, we just write our code and let the Python interpreter tell us about errors. However, sometimes we want to use the fact that we might encounter a runtime error within our code. A common example of this is when importing modules that are convenient, but not necessary in your code. Errors are handled in your code using a try block.

Let's try importing a module that computes GC content. This doesn't exit, so we will get an ImportError.

In [24]:
import gc_content
ImportError                               Traceback (most recent call last)
<ipython-input-24-60ed85d24bf1> in <module>()
----> 1 import gc_content

ImportError: No module named 'gc_content'

Now, if we had the magic module, we would like to use it. But if not, we will just hand-code a calculation of the GC content of a sequence. We use a try block.

In [25]:
# Try to get the gc_content module
    import gc_content
    have_gc = True
except ImportError as e:
    have_gc = False
    # Do whatever is necessary here, like close files

if have_gc:
    print(seq.count('G') + seq.count('C'))

The program now runs just fine! The try block consists of an initial try clause. Everything under the try clause is attempted to be executed. If it succeeds, the rest of the try block is skipped and the interpreter goes to the seq = ... line.

If, however, there is an ImportError, the code after the except ImportError as e clause is executed. The exception does not halt the program. If there is some other kind of error other than an ImportError, the interpreter will raise an exception after it does whatever code is in the finally clause. The finally clause is useful to tidy things up, like close open files (we'll get to file I/O in a forthcoming lesson). It is good practice to only use exceptions that you anticipate in try blocks. In this case, we only want to have control over ImportErrors. We want the interpreter to scream at us for any unanticipated errors.

Issuing warnings

We may want to issue a warning instead of silently continuing. For this, the warnings module from the standard library is useful. We use the warnings.warn method to issue the warning.

In [41]:
# Try to get the gc_content module
    import gc_content
    have_gc = True
except ImportError as e:
    have_gc = False
    warnings.warn('Failed to load gc_content.  Using custom function.', 
    # Do whatever is necessary here, like close files

if have_gc:
    print(seq.count('G') + seq.count('C'))
/Users/Justin/anaconda/lib/python3.4/site-packages/ipykernel/__main__.py:8: UserWarning: Failed to load gc_content.  Using custom function.

Normally, we would use an ImportWarning, but those are ignored by default, so we have used a UserWarning.

Checking input

It is often the case that you want to check the input of a function to ensure that it works properly. In other words, you want to anticipate errors that the user (or you) might make in running your function and you want to give descriptive error messages. For example, let's say you are writing a code that processes protein sequences that contain only the 20 naturally occuring amino acids and are represented by their one-letter abbreviation. You may wish to check that the amino acid sequence is legitimate. In particular, the letters B, J, O, U, X, and Z, are not abbreviations for amino acids. (We will not use the wild card abbreviations, B for aspartic acid or asparagine, Z for glutamine or glutamic acid, or X for any amino acid.)

To illustrate the point, we will write a simple function that converts the sequence of one-letter amino acids to the three-letter abbreviation. First, we'll make sure we have the amino acid dictionary we made in the lesson on dictionaries.

In [27]:
# Dictionary of amino acids from the dictionaries lesson
aa_dict = {'A': 'Ala',
           'R': 'Arg',
           'N': 'Asn',
           'D': 'Asp',
           'C': 'Cys',
           'Q': 'Gln',
           'E': 'Glu',
           'G': 'Gly',
           'H': 'His',
           'I': 'Ile',
           'L': 'Leu',
           'K': 'Lys',
           'M': 'Met',
           'F': 'Phe',
           'P': 'Pro',
           'S': 'Ser',
           'T': 'Thr',
           'W': 'Trp',
           'Y': 'Tyr',
           'V': 'Val'}

Now, we'll write our function, making sure the input sequence is ok.

In [35]:
def one_to_three(seq):
    Converts a protein sequence using one-letter abbreviations
    to one using three-letter abbreviations.
    # Convert seq to upper case
    seq = seq.upper()
    # Make sure there are no illegal amino acids
    for aa in seq:
        if aa not in aa_dict.keys():
            raise RuntimeError(aa + ' is not a valid amino acid.')
    # Otherwise, do the conversion
    aa_list = []
    for aa in seq:
        aa_list += [aa_dict[aa], '-']

    # Return the result
    return ''.join(aa_list[:-1])

So, if we put in a legitimate amino acid sequence, the function works as expected.

In [38]:

But, it we put in an improper amino acid, we will get a descriptive error.

In [39]:
RuntimeError                              Traceback (most recent call last)
<ipython-input-39-15b99f813ced> in <module>()
----> 1 one_to_three('waeifnsdfzklnsae')

<ipython-input-35-d009635cc41f> in one_to_three(seq)
     11     for aa in seq:
     12         if aa not in aa_dict.keys():
---> 13             raise RuntimeError(aa + ' is not a valid amino acid.')
     15     # Otherwise, do the conversion

RuntimeError: Z is not a valid amino acid.