3. Squashing Bugs with Python’s Debugging Tools#
Learning Objectives
Explain the difference between syntax errors and exceptions
Identify Python’s common built-in exceptions
Explain and use a try-catch statement
Raise exceptions in code
Explain the principles of defensive programming
Use Python’s built-in
logging
module to log outputExplain what assertions are and how to use them
Explain what unit tests are
Explain what debugging is and what the
pdb
module isUse the
pdb
debugger to navigate through running codeExplain what
ipdb
is and how to debug with IPython & JupyterExplain and use IPython magic commands
Explain what profiling is
This chapter describes how to prevent, detect, and diagnose bugs in your Python code. This includes an introduction to Python’s exceptions system, a discussion of defensive programming best practices, and an introduction to Python’s debugging tools. The chapter also describes some of the tools you can use to measure and improve the performance of your code.
3.1. Prerequisites#
This chapter assumes you already have basic familiarity with Python. DataLab’s Python Basics Reader and its accompanying workshop provide a suitable introduction.
To follow along, you’ll need the following software versions (or newer) installed on your computer:
One way to install these is to install the Anaconda Python distribution.
Chapter 2 provides more details about Anaconda and the conda
package manager.
3.2. Errors#
When you write and run Python code, there are two different kinds of errors you
might have to deal with: syntax errors and exceptions. A syntax error is a
grammatical mistake in how you wrote the code, and prevents Python from running
the code. For instance, this code to construct a list has a common syntax
error—a comma ,
is missing:
x = [1, 2, 3 4]
Cell In[2], line 1
x = [1, 2, 3 4]
^
SyntaxError: invalid syntax. Perhaps you forgot a comma?
Syntax errors are the simplest kind of error to resolve, because they’re usually caused by typos or a misunderstanding of the Python syntax (which you can clarify by reviewing the documentation). If you try to run code that contains a syntax error, Python will report the line and character where it first detects the error.
In contrast, an exception is an error that occurs while your code is running. Exceptions are usually caused by something that violates the assumptions of the code—such as an argument with an inappropriate type or value, a file that’s in the wrong place, or an unreliable network connection. As an example, adding an integer and a string raises an exception:
3 + "hello"
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[3], line 1
----> 1 3 + "hello"
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Many different types of exceptions are built into Python, and you can also
create your own. The code above raises a TypeError
, an exception that means
one or more arguments have an inappropriate type.
Another kind of exception, a NameError
, is raised if your code tries to
access a variable that doesn’t exist:
aggie
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[4], line 1
----> 1 aggie
NameError: name 'aggie' is not defined
Here are several common built-in exceptions:
Name |
Cause |
---|---|
|
Attribute is not found or read-only |
|
Index to sequence is out of range |
|
Key to dictionary is not found |
|
Variable is not found |
|
Iterator or generator is out of items |
|
Object has inappropriate type for an operation |
|
Object has inappropriate value for an operation |
You can find a complete list in the Built-in Exceptions chapter of the Python documentation.
3.2.1. Handling Exceptions#
You can make your code more robust by handling exceptions that are likely to occur or have a straightforward solution. You can use a try-except statement to indicate that some code might raise an exception and specify what should happen if it does.
As an example, consider this code to prompt the user for a number:
x = input("Specify a number: ")
x = float(x)
If the user provides an input that’s not a number, the call to float
raises a
ValueError
:
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[6], line 1
----> 1 x = float(x)
ValueError: could not convert string to float: 'hi'
Suppose instead you’d like to use 0.0 as a default if the input isn’t a number. One way you can do this is with a try-except statement:
x = input("Specify a number: ")
try:
x = float(x)
except:
print(f"'{x}' is not a valid number, using 0 instead.")
x = 0
Put code that might raise an exception in the try
block. Code in the except
block only runs if an exception is actually raised.
You can specify which types of exceptions an except
block should catch by
adding the type after the keyword:
x = input("Specify a number: ")
try:
x = float(x)
except ValueError:
print(f"'{x}' is not a valid number, using 0 instead.")
x = 0
You can optionally give the exception a name as well, so that you can refer to
it in the except
block:
x = input("Specify a number: ")
try:
x = float(x)
except ValueError as e:
print(f"'{x}' is not a valid number, using 0 instead.")
print(f"Original error: {e}")
x = 0
Specifying the type of exception serves two purposes:
It ensures that your code only catches exceptions it can handle.
It allows you to handle different types of exceptions in different ways.
The second is possible because you can have more than one except
block in a
try-except statement.
A try-except statement can also have an else
block. Code in the else
block
only runs if an exception is not raised. So continuing the example, you could
combine a try-except statement with a while-loop to prompt the user for a
number until they enter a valid one:
while True:
x = input("Specify a number: ")
try:
x = float(x)
except ValueError:
print(f"'{x}' is not a valid number.")
else:
# No exception, so break out of the loop.
break
Finally, you can use a finally
block in a try-except statement to specify
code that should always run, whether on not an exception is raised. This is
mainly useful for cleanup operations, such as closing connections to files or
network devices.
3.2.2. Raising Exceptions#
You can use the raise
keyword to make your code raise an exception. For
instance, suppose you define a function to trim a specified number of elements
from each end of a sequence. The function should raise an error if the number
of elements to trim is greater than the length of the sequence:
def trim_elements(x, n):
if len(x) < 2*n:
raise ValueError("Too many elements to trim.")
return x[n:-n]
In this case, the function raises a ValueError
because the value of n
is
the problem. The argument to the exception is the error message. You can use
raise
to raise any type of exception, including user-defined exceptions.
You can find further examples of how to handle, raise, and define new types of exceptions in the Errors and Exceptions chapter of the Python documentation.
3.3. Defensive Programming#
Defensive programming means taking steps to prevent or quickly detect and fix bugs when writing code. Defensive programming techniques can help you minimize time spent debugging and the number of unexpected bugs at run-time. Some examples of how you can program defensively include:
Format your code neatly. Use a consistent naming scheme and indentation style.
Organize your code into logical steps or “paragraphs” with blank lines in between. This makes it convenient to review what happens in each step, and to convert steps into functions when appropriate.
Add comments to your code:
To create a big picture plan for what to write.
To explain tricky code.
To summarize the purpose of a “paragraph” of code.
Convert steps that you plan to use more than once into short functions. Think about what the expected inputs and outputs are for each function, and make a note of these in the docstring.
Develop code to handle the simple cases first. Don’t try to handle more complicated cases until you’ve verified that the code works for the simple cases.
Test code frequently as you write it. Alternate between writing code and running the code to confirm it works as expected. You should have a Python session running to test things out almost any time you’re writing code.
Investigate unexpected behavior such as warning messages even if the code seems to produce correct results. Ideally, either fix the code so the unexpected behavior doesn’t occur, or document why the behavior occurs and why it doesn’t pose a problem.
Follow reproducibility best practices, including those laid out in Section 4.
3.3.1. Logging#
Logging means saving the printed output of your code to a file. Logging makes it easier to detect bugs because you can record information about the state of your code as it runs, and because you can compare logs across different runs.
Python’s built-in logging
module provides basic logging functionality. To set
up logging, import the module and call logging.basicConfig
:
import logging
logging.basicConfig(filename = "logs/my_script.log", level = logging.DEBUG)
# If you also want logged messages to print to the console:
logging.getLogger().addHandler(logging.StreamHandler())
The basicConfig
function has many keyword parameters that you can use to
customize how messages are logged. You can find a complete list in this
section of the Python documentation.
The filename
parameter makes the logger save log messages to a file. If you
don’t set this parameter, they’ll be printed to the Python console instead.
The level
parameter controls what kinds of messages are logged. Messages
below the specified level are not logged. The logging levels are, from highest
to lowest:
Name |
Description |
---|---|
|
Messages about errors that prevent the code from running |
|
Messages about non-critical errors |
|
Messages about unexpected events that don’t interfere with normal operation |
|
Informational messages |
|
All messages (this level is usually used only for debugging) |
Once you’ve set up logging, you can write something to the log at a specific
level using the debug
, info
, warning
, error
, exception
, or critical
functions in the logging
module.
For instance, to log a warning and an informational message:
logging.warning("This is a warning.")
logging.info("This is information.")
This is a warning.
This is information.
You can use these logging functions as replacements for the print
function.
Use debug or messages to record detailed information about your code as it
runs. When you’re sure the code works as intended, you can disable these
messages by changing the level in the call to basicConfig
.
For a much more detailed introduction to the logging
module, see the Logging
HOWTO in the Python documentation.
Also see the loguru
package, which provides a variety of
improvements and additional features compared to the logging
module.
3.3.2. Assertions#
An assertion is an expression in code that checks whether some condition is true and raises an exception if it isn’t. In other words, an assertion asserts that some assumption must be satisfied for the code to continue running.
You can use the assert
keyword and a condition to create an assertion in
Python:
x = 1.1
x = x ** 2
assert x > 1
When an assertion fails, Python raises an AssertionError
:
assert x < 0
---------------------------------------------------------------------------
AssertionError Traceback (most recent call last)
Cell In[11], line 1
----> 1 assert x < 0
AssertionError:
Assertions provide one way to confirm that objects in your code are as you expect. If you suspect some code might not work correctly, but you’re completely not sure, adding an assertion is a good way to check your suspicions.
The major advantage of using assertions rather than if-statements (and print
or raise
) to confirm assumptions is that once you’ve finished testing your
code, you can run Python with the -O
command line argument and it will ignore
all assertions.
As a consequence, you should think of assertions as a tool for testing code
while it’s in development. If you want your code to check an assumption even
after development is finished, use if-statements and raise
exceptions
instead. For example, if you create a package and want the functions in the
package to validate their inputs, they should use if-statements and exceptions.
3.3.3. Tests#
As already mentioned, testing code is an important step in the development process, and one you should do early and often. When developing single-use scripts and notebooks (such as data analyses), it’s usually sufficient to adopt an ad-hoc testing strategy, where you test code by manually inspecting the outputs (and possibly some intermediate values).
For projects where the code will be developed by many people, used by many people, or used many times, such as a package, it’s often a good idea to adopt a more formal and automated testing strategy.
Unit testing is a testing strategy where isolated components or “units” of code are tested. The units are typically functions or modules, and the tests are typically automated to report correct/incorrect results from each unit. Testing units in isolation makes it easier to assess where bugs originate. Using automated tests makes it convenient to run the tests every time a change is made to the code. Unit testing is a software development best practice, and some software developers even advocate using tests to motivate or “drive” development.
Python’s built-in unittest
provides functions for creating and running unit
tests. The pytest
package provides a simpler way to create unit
tests based on assert
statements. Many other unit testing packages are also
available; the Python Testing Tools Taxonomy has an
exhaustive list.
3.4. Debugging#
Debugging is the process of confirming, step-by-step, that what you believe the code does is what the code actually does, in order to identify the causes of bugs.
The key idea is to examine the output from each step in the code. There are two ways to go about this:
Work forward through the code from the beginning.
Work backward from the source of an error (if the bug causes an error).
A debugger is an interactive tool that enables running code one expression at a time. In most debuggers, you can also inspect and change the values of objects. Some debuggers even allow evaluation of arbitrary code.
Python’s built-in pdb
module provides a debugger. You can launch the debugger
from code by calling the pdb.set_trace
function. Python 3.7 and newer also
provide a breakpoint
function to launch the debugger. Alternatively, you can
run the debugger on a specific function by calling the pdb.runcall
function.
When you launch the debugger, you’ll be presented with a prompt similar to the Python prompt. Several special commands are available in the debugger. Here are some of the most useful ones:
Command |
Shortcut |
Description |
---|---|---|
|
|
print help for debugger commands |
|
|
halt execution and quit the debugger |
|
|
print a stack trace |
|
print an expression |
|
|
pretty-print an expression |
|
|
|
continue execution until the next possible stopping point |
|
|
continue execution until the next line |
|
|
continue execution until a given line |
|
|
continue execution until the next breakpoint |
|
|
print or set breakpoints |
|
|
move up the stack trace |
|
|
move down the stack trace |
Several of Python’s built-in functions are also useful when debugging:
Function |
Description |
---|---|
|
Get a list of all names in the current scope |
|
Get a dictionary of all local variables in the current scope |
|
Get a dictionary of all global variables in the current scope |
3.4.1. In IPython & Jupyter#
In IPython and Jupyter, the pdb
module doesn’t always work correctly, and the
breakpoint
function is currently ignored (although this will likely be fixed
in a future version).
Instead, you can use the ipdb
package as a drop-in replacement for the pdb
module. It provides the ipdb.set_trace
function, and the ipdb
debugger uses
the same set of commands as the pdb
debugger. The ipdb
debugger also has a
few additional features familiar from IPython, such as syntax highlighting and
tab completion.
In IPython, a magic command is a command you can enter at the console
that’s not part of the Python language. Magic commands usually begin with a
percent %
to distinguish them from ordinary code. Other Jupyter kernels (for
languages other than Python) can also support magic commands, but this is up to
the kernel developers. You can learn more about IPython magic commands in this
chapter of the IPython documentation.
IPython provides a %debug
magic command you can use to immediately run the
debugger at the last line that caused an error. It also provides a %pdb
magic
command to make running %debug
the default any time an exception is raised
(just run %pdb on
at the beginning of your session).
3.5. Profiling#
Profiling is the process of measuring how much CPU time or memory each expression in your code uses. Profiling is usually used to improve the performance of code rather than find bugs. It’s mentioned in this chapter because it’s another kind of diagnostic you can run on code.
The simplest form of profiling is benchmarking, where you measure the time
it takes for some code to run. You can use Python’s built-in timeit
module to
estimate how long an expression takes to run on average. For example, suppose
you want to construct a list of numbers as strings. Here are two different ways
to do it:
[str(i) for i in range(1000)]
list(map(str, range(1000)))
It’s not obvious which is faster. If you want to find out, you can use the
timeit.timeit
function:
from timeit import timeit
timeit(lambda: [str(i) for i in range(1000)], number = 100)
0.008362601000044378
timeit(lambda: list(map(str, range(1000))), number = 100)
0.010272853000060422
The function returns the average time in seconds over number
runs. In this
case, the map
function seems to be marginally faster (although to be thorough
we should examine multiple list lengths).
You might want more detailed profile information than just the benchmark for a single expression or function. For instance, you might want to know how long every expression in a script takes, to identify speed bottlenecks, or you might want information about memory usage instead of timing.
Python has two built-in profilers: cProfile
and profile
. Unless you want to
build new functionality into the profiler, generally cProfile
is the better
choice. You can learn more about how to use these profilers in the Python
Profilers chapter of the Python documentation.
Other CPU time profilers for Python include:
There’s no built-in memory profiler for Python, but the community has developed several: