B10. Errors and exception handling


[1]:
import warnings

And now we’ll proceed to discussing errors and exception handling.

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.

[2]:
my_str = 'AGCTATC'
my_str[3] = 'G'
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[2], line 2
      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, an error detected during execution is called an exception. We say that the interpreter “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 here.

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, you 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 interpreter 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 (Finnegans Wake by James Joyce). Clearly, many spelling and punctuation rules of English are violated here. To many of us, it is nonsensical, but there are plenty of people who have read the book and understand it. So, English is fairly tolerant of syntax errors. A simpler example would be

Boootcamp 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. Unlike users of the English language, the Python interpreter is not forgiving; it will raise a SyntaxError exception.

[3]:
my_list = [1, 2, 3
  Cell In[3], line 1
    my_list = [1, 2, 3
                      ^
SyntaxError: incomplete input

Syntax errors are often the easiest to deal with, since the program will not run at all if any are 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 just indicates a generic runtime (non-syntax) 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 for some inputs, but for other inputs, you get an error. Let’s consider the example of a simple function meant to add two numbers.

[4]:
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.

[5]:
add_two_things(6, 7)
[5]:
13

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

[6]:
add_two_things('Hello, ', 'world.')
[6]:
'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.

[7]:
add_two_things('a string', 5.7)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[7], line 1
----> 1 add_two_things('a string', 5.7)

Cell In[4], line 3, in add_two_things(a, b)
      1 def add_two_things(a, b):
      2     """Add two numbers."""
----> 3     return a + b

TypeError: can only concatenate str (not "float") to str

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.

[8]:
# 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
my_list.sort()

# 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 these exceptions. However, sometimes we want to use the fact that we know we might encounter a runtime error within our code. A common example of this is when importing modules that are convenient, but not essential, for your code to run. Errors are handled in your code using a try statement.

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

[9]:
import gc_content
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[9], line 1
----> 1 import gc_content

ModuleNotFoundError: No module named 'gc_content'

Now, if we had the gc_content 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 statement.

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

seq = 'ACGATCTACGATCAGCTGCGCGCATCG'

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

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

If, however, there is an ImportError, the code within 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 closing open file handles. While it is possible for a try statement to handle any generic exception by not specifying ImportError as e, it is good practice to explicitly specify the exception(s) that you anticipate in try statements as shown here. In this case, we only want to have control over ImportErrors. We want the interpreter to scream at us for any other, 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.

[11]:
# Try to get the gc_content module
try:
    import gc_content

    have_gc = True
except ImportError as e:
    have_gc = False
    warnings.warn(
        "Failed to load gc_content. Using custom function.", UserWarning
    )
finally:
    pass

seq = "ACGATCTACGATCAGCTGCGCGCATCG"

if have_gc:
    print(gc_content(seq))
else:
    print(seq.count("G") + seq.count("C"))
16
/var/folders/j_/c5r9ch0913v3h1w4bdwzm0lh0000gn/T/ipykernel_14997/3620265156.py:8: UserWarning: Failed to load gc_content. Using custom function.
  warnings.warn(

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

Computing environment

[12]:
%load_ext watermark
%watermark -v -p jupyterlab
Python implementation: CPython
Python version       : 3.10.9
IPython version      : 8.10.0

jupyterlab: 3.5.3