Errors and Documentation
Contents
9. Errors and Documentation#
9.1. Lesson overview#
When writing code in any language, the first draft will hardly ever be perfect. Even the second, third, or fifth
draft of our code can have errors, which are known as bugs in
programming. These bugs can range from simple syntax errors to complicated logic problems. Fortunately, Python has
built-in tools in diagnosing, logging, and reporting programming bugs. Therefore, to become proficient at writing
Python code, we need to become comfortable in understanding Python error logs. In this lesson, we will look at how to
read a Python traceback, how to work around errors with the try
-except
commands, and how to raise errors when
needed contextual information is needed to correct a bug. We will also look at a general troubleshooting approach by
searching through library documentation.
Definition: bug
An error, flaw, or unintended behavior in a program.
9.2. Reading error messages#
When Python runs into an error it will output a logging report called a traceback that shows the line where an exception has occurred, which has caused the Python program to quit. The traceback will show what part of the code started the exception process and then “traces back” step by step through the code blocks to ultimately where the error originated. Following this step by step logging, the traceback will show the exception type and provide an error message that will hint towards what is wrong with the code.
Definition: traceback
A logging report generated when a programming bug is detected.
Let us demonstrate how a traceback works using the following example:
def fail():
asdf
def start():
fail()
start()
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Input In [1], in <cell line: 7>()
4 def start():
5 fail()
----> 7 start()
Input In [1], in start()
4 def start():
----> 5 fail()
Input In [1], in fail()
1 def fail():
----> 2 asdf
NameError: name 'asdf' is not defined
In this example, we can see the traceback begins with an arrow pointed at the start()
function command. This is
the call that started the chain of events that led to the exception:
Input In [1], in <cell line: 7>()
4 def start():
5 fail()
----> 7 start()
Differences in how tracebacks are displayed
Based on your version of Python, Anaconda, and your computer’s operating system, your traceback might look slightly different than what is shown in this tutorial. While the format might be different, the content that is relayed will be the same.
There is nothing wrong with this call by itself. The start()
function is able to successfully execute its code block
and call the next function fail()
that is in our traceback. Note the first line of this section,
Input In [1], in start()
, shows the calling of fail()
happened in the start()
code block:
Input In [1], in start()
4 def start():
----> 5 fail()
The last call of the traceback shows the line of code that was the root cause of our exception:
Input In [1], in fail()
1 def fail():
----> 2 asdf
The traceback then prints out the exception type and the error message:
NameError: name 'asdf' is not defined
Here, the exception type (NameError
) is not as important as the error message: name 'asdf' is not defined
. The
traceback has reported that asdf
is defined before it is referenced in the code. Therefore, the error in our code
is that a value is not assigned to asdf
. From this error message we can deduce a
NameError
error type refers to when a variable
name is called without having a value assigned to it.
Following a traceback is important to debug your code. When running this code cell as a file, we can see that the traceback still shows the relevant line numbers in the Python file:
Traceback (most recent call last):
File "./fail.py", line 7, in <module>
start()
File "./fail.py", line 5, in start
fail()
File "./fail.py", line 2, in fail
asdf
NameError: name 'asdf' is not defined
9.2.1. Example: The wrong value#
Let’s register another type of exception called a ValueError
. This type of error often occurs when we pass
an argument that is of the right type (e.g., program expects a float
and receives a float
) but has an incorrect
value. A good example of this is when calculating the square root of a negative number. In this example, create a
simple program that takes the square root of the following list of numbers:
2, 4, 16, -5, 253, -2.5, and 100
Enter this set of number as a list
object, and pass this list through a for
loop that will take the square root
of each number. Have the for
loop print out a phrase that mentions both the number and its square root.
The built-in Python library does not have a square root function. However, the
math
standard library has this capability with the
sqrt()
function. Therefore, you will need to import this
library into your code. You can review how to import the math
library here.
Solution:
As seen in the problem statement, there are two numbers, -2.5 and -5, which should give us an error. The code below
demonstrates the ValueError
exception using the given code requirements:
from math import sqrt
list = [2, 4, 16, -5, 253, -2.5, 100]
for i in list:
print(f"The square root of {i} is {sqrt(i):.5f}.")
The square root of 2 is 1.41421.
The square root of 4 is 2.00000.
The square root of 16 is 4.00000.
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Input In [2], in <cell line: 5>()
3 list = [2, 4, 16, -5, 253, -2.5, 100]
5 for i in list:
----> 6 print(f"The square root of {i} is {sqrt(i):.5f}.")
ValueError: math domain error
As seen above, the for
loop successfully iterates over three loops before raising the ValueError
exception on
-5
. The example in the next section will show how we can have the program flag this error,
while also continuing through the remaining values.
9.3. Error catching with try
and except
#
When writing code, we may be dealing with input data that are not perfectly uniform. For instance, we may be parsing
a CSV file that supposedly only contains integers but a string is found in the file. This may cause our Python
program to crash when it reaches the string. In order to provide some “flexibility” in how our program operates, we
would prefer that our Python code could try an alternative block of code to resolve the issue rather than simply
crashing. Fortunately, the try
- except
coding pattern allows us to implement alternative code blocks to handle these exceptions.
To see how this all works, let us start with the following code block, which will initially fail:
def fail(my_list):
a = 0
for i in my_list:
a += i
return a
b = fail([1, 2, "3", 4])
print("b:", b)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Input In [3], in <cell line: 7>()
4 a += i
5 return a
----> 7 b = fail([1, 2, "3", 4])
9 print("b:", b)
Input In [3], in fail(my_list)
2 a = 0
3 for i in my_list:
----> 4 a += i
5 return a
TypeError: unsupported operand type(s) for +=: 'int' and 'str'
Here, we have defined a function fail(my_list)
that will step through an input list, which ideally should be all
int
objects, and returns the sum of the list. However, our input list contains a str
object, so our function
crashes when it loops over this object. This is described in the traceback that points at the line
a += 1
along with the error message TypeError: unsupported operand type(s) for +=: 'int' and 'str'
. Since the
code is trying to add an int
object to a str
object (i.e., when we get to "3"
in our list), a
TypeError
exception type is flagged.
Hey! Listen!
Remember that a += i
is shorthand for a = a + i
!. If you need a quick review of shorthand versions of
simple mathematical operators,
check out our previous lesson on conditional and loops statements.
The try
- except
pattern will help us bypass this failure mode in our code. The try
- except
pattern is
similar in structure to the if
- else
coding pattern that was described in an earlier lesson.
To use the try
- except
pattern, we first start with the try:
command, followed by the code block that we want to
try to execute. After that, we use the except:
command, followed with the code block that will be executed if there
is a problem in the try
code block. Below is a simplified version of the previously failed code that implements the
try
- except
pattern:
a = 0
try:
a += '3'
except:
print('oops, had an error')
try:
a += 4
except:
print('oops, had another error')
print("a:", a)
oops, had an error
a: 4
You can see we got to the end of the code, and we were able to print out the value of a
. Without the try
-
except
pattern our code would have crashed out after a += '3'
. Instead, we put a += '3'
in a try:
code block,
so when it failed it skipped to the except:
code block and printed the message oops, had an error
instead of
crashing. It then proceeded to the next try:
code block with a += 4
and executed this command successfully.
Therefore, the next except:
block is skipped. The end result is that a == 4
, which reflects that the a += 3
code block is skipped and only a += 4
is implemented.
We can make the except
statement more informative by including the exception message that was triggered from the
try
code block:
a = 0
try:
a += '3'
except Exception as e:
print(e)
try:
a += 4
except Exception as e:
print(e)
print("a:", a)
unsupported operand type(s) for +=: 'int' and 'str'
a: 4
By extending the except:
statement to except Exception as e:
we can print out the exception as the variable e
.
This will give us more information about how the preceding try:
code block failed, instead of simply printing out that
an error happened.
Let us now add the try
- except
pattern to our original example:
def fail(my_list):
a = 0
try:
for i in my_list:
a += i
except Exception as e:
print(e)
return a
b = fail([1, 2, "3", 4])
print("b:", b)
unsupported operand type(s) for +=: 'int' and 'str'
b: 3
Well, our code did not crash, but we also didn’t finish processing the list. We were able to sum up 1 and 2, but after
the a += '3'
was encountered when we broke out of the for
loop. This is because we have our entire for
loop in
the try
code block. When working with try
- except
, it is important to recognize what would happen if the code
block raises an exception. If we want to make sure all elements of the list is processed, we will need to put the
try
- except
pattern inside the for
loop. The code below implements this change an addition to modifying the
fail()
function to return both the sum of integers in the list and a list
object of failed elements:
def fail(my_list):
a = 0
failed = []
for i in my_list:
try:
a += i
except Exception as e:
print(e, i)
failed.append(i)
return a, failed
b, failures = fail([1, 2, "3", 4])
print("b:", b)
print("failed:", failures)
unsupported operand type(s) for +=: 'int' and 'str' 3
b: 7
failed: ['3']
Now we are able to get b == 7
by summing up 1, 2, and 4. Additionally, we added a failed
list and when the
except
block is executed we append the failed element to that list. We can now return a
and failed
as output of
the function.
9.3.1. Example: Trying to get out of the problem#
Modify the solution from the previous example using the try
- except
command structure
so the code iterates over all values in the starting list and also mentions if a number in the list is less than zero.
Solution:
Very little needs to be done in order to add the try
- except
command structure. The try
command will have our
main print()
code, while the except
command issues a new print()
call that states that the current value in
the list is less than zero.
from math import sqrt
list = [2, 4, 16, -5, 253, -2.5, 100]
for i in list:
try:
print(f"The square root of {i} is {sqrt(i):.5f}.")
except:
print(f"Cannot take the square root of {i} since it is less than zero!")
The square root of 2 is 1.41421.
The square root of 4 is 2.00000.
The square root of 16 is 4.00000.
Cannot take the square root of -5 since it is less than zero!
The square root of 253 is 15.90597.
Cannot take the square root of -2.5 since it is less than zero!
The square root of 100 is 10.00000.
All in all, the try
- except
command structure is an effective technique to ensure that erroneous values are
appropriately flagged while ensuring the program completes.
9.4. Returning errors with raise
#
While we can use try
- except
to bypass errors, there are times when we want a program to exit when an error is
encountered and return more information to the user besides a generic exception message. The
raise
keyword is designed to exit the code with a
raised exception that can include additional information for the user as to why the program exited. The code
below demonstrates how the raise
keyword is used:
def fail(my_list):
a = 0
for i in my_list:
if isinstance(i, int):
a += i
else:
raise Exception("Value in list is not an integer: " + str(i))
return a
b = fail([1, 2, "3", 4])
---------------------------------------------------------------------------
Exception Traceback (most recent call last)
Input In [9], in <cell line: 10>()
7 raise Exception("Value in list is not an integer: " + str(i))
8 return a
---> 10 b = fail([1, 2, "3", 4])
Input In [9], in fail(my_list)
5 a += i
6 else:
----> 7 raise Exception("Value in list is not an integer: " + str(i))
8 return a
Exception: Value in list is not an integer: 3
In this example, a new built-in function is being used in the if
statement, isinstance()
, which compares the
class of an object to a desired class. In this case, we are testing to see if i
belongs to the int
class. If
it is an int
object, the program moves the following code block with the a += i
command. If it
is not an int
object, then the program moves to the else:
code block and raises an exception.
As seen above, the raise
command allows us to insert our own unique exception (in this case
"Value in list is not an integer: " + str(i))
). If we did not include this but rather used our
except Exception as e
structure from before we would have received the generic traceback exception message:
TypeError: unsupported operand type(s) for +=: 'int' and 'str'
. Therefore, the raise
keyword has allowed us to
produce a more informative error message that clearly says that an element in the list is not an integer and the
erroneous value in question.
There are many built-in Exceptions,
and we can make custom ones too. It is a good practice to match the
exception type to what sort of error is happening. For example, we can use the built-in
ValueError
exception which is
better suited for an exception type than the generic
Exception
:
def fail(my_list):
a = 0
for i in my_list:
if isinstance(i, int):
a += i
else:
raise ValueError("Value in list is not an integer: " + str(i))
return a
b = fail([1, 2 , "3", 4])
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Input In [10], in <cell line: 10>()
7 raise ValueError("Value in list is not an integer: " + str(i))
8 return a
---> 10 b = fail([1, 2 , "3", 4])
Input In [10], in fail(my_list)
5 a += i
6 else:
----> 7 raise ValueError("Value in list is not an integer: " + str(i))
8 return a
ValueError: Value in list is not an integer: 3
By using the ValueError
exception type we can give a little more context to the error when someone encounters it.
9.4.1. Example: Raising an issue#
An example in a previous lesson created a function that converted an input force to one of three
units. Part of the exercise required the function to notify if incorrect force units were used.
This was done using simple print()
calls. In this example, replace the print()
calls with raise
commands so
ValueError
exceptions are issued when incorrect units are entered.
Solution:
Since the print()
calls that handle the incorrect units are placed in else
statements, it is straightforward to
modify the code. All we need to do is replace the print()
functions with a raise ValueError()
calls.
def convert_force(initial_force, initial_units="N", converted_units="lbf"):
"""
Convert force between Newtons, pound force, and dynes. Returns the
converted force and units.
:param initial_force: Force to be converted
:type: float
:param initial_units: Initial units of force. Allowable values are
"N", "lbf", and "dyn". Default value is "N"
:type: str
:param converted_units: Units of force to be converted to. Allowable
values are "N", "lbf", and "dyn". Default value is "N"
:type: str
:return:
- converted_force - converted force value (type: float)
- converted_units - final units (type: str)
"""
# Convert force to N to standardize
if initial_units == "N":
force_N = initial_force
elif initial_units == "lbf":
force_N = initial_force / 0.225
elif initial_units == "dyn":
force_N = initial_force / 100000
else:
raise ValueError("Incorrect initial force units")
# Convert to new units
if force_N == "\"incorrect initial force units\"":
converted_force = force_N
elif converted_units == "N":
converted_force = force_N
elif converted_units == "lbf":
converted_force = force_N * 0.225
elif converted_units == "dyn":
converted_force = force_N * 100000
else:
raise ValueError("Incorrect converted force units")
return converted_force, converted_units
The code below demonstrates the error handling in action:
converted_force, converted_units = convert_force(2536.2, initial_units="dyn", converted_units="bad")
print(f"The converted force is {converted_force} {converted_units}")
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Input In [12], in <cell line: 1>()
----> 1 converted_force, converted_units = convert_force(2536.2, initial_units="dyn", converted_units="bad")
2 print(f"The converted force is {converted_force} {converted_units}")
Input In [11], in convert_force(initial_force, initial_units, converted_units)
38 converted_force = force_N * 100000
39 else:
---> 40 raise ValueError("Incorrect converted force units")
42 return converted_force, converted_units
ValueError: Incorrect converted force units
This route is a more effective way for error handling since it produces both the exception output block and the traceback.
9.5. Documentation and troubleshooting#
It can be difficult to troubleshoot errors in code. Besides the inherent complexity of the code you are working with, Python libraries are constantly in development and changing. However, there are a few general strategies we can use to troubleshoot coding errors:
1. Use help()
As we have discussed in an earlier lesson, the built-in help()
function is a useful function to read
the docstrings of functions. Information about default values, normal behavior, and commonly seen errors are often
included in these docstrings.
2. Search online by last traceback line
A simple search online of the last traceback line can help, especially if the last line is from a Python library file. When searching the traceback, you may find other users that have encountered the problem. A popular site for asking questions and getting answers is StackOverflow. In evaluating an answer, it is useful to keep your operating environment in mind. Are the answers for Python 2.7 or for Python 3.10? Are they for your operating system or for a different one? How old is the answer, does it apply to our library version?
3. Check the library API documentation online
Most Python libraries have their documentation published online. We can search online for “LIBRARY NAME documentation” to find the documentation site and check to see if we are using a library or function correctly. You may also be able to select the specific version of the library you are using. For instance, on the popular documentation hosting site Read the Docs, the version of the library will be in the bottom left.
As an example of checking documentation for correct usage,
the numpy.loadtxt()
documentation shows the
many functional changes across the different versions. For instance, the quotechar
optional argument
was new in version 1.23.0. If we are running code that uses that argument, and our version of NumPy is older than
1.23.0, we will run into an error (the installed version of NumPy can be checked with numpy.version.full_version
).
4. Check library “Getting Started” or “Tutorials”
If you are getting an error with a library you are using for the first time, it can be useful to run known working code as a sanity check with your setup. For example, scikit-image is a bundled library with Anaconda and is useful for image processing. We can try out the robust gallery of examples first to make sure the functions we are using in scikit-image are working.
9.6. Conclusion#
Bugs and errors are part of life when coding, and it can be disheartening when we run into them. But with the useful
tracebacks that Python generates, we can see how an exception was raised through step by step analysis. We can also
use the try
-except
coding pattern to work around some errors, such as bypassing bad inputs from a file.
Sometimes it is important to raise
an exception in our code, which will stop execution, but can provide important
contextual information through raise
statements. Finally, looking for solutions through online resources is a great
way to get a head start on correcting an error. Keep in mind your operating environment when evaluating different
answers online, as some answers may not apply to your particular situation. Searching through the documentation online
and trying out different tutorials is a great way to sanity check your current code.
9.7. Want to learn more?#
Python Software Foundation - Errors and Exceptions
Python Software Foundation - Built-in Exceptions
Python Software Foundation - The Try Statement