graph BT
classDef failing fill:#fde8e8,stroke:#c0392b,stroke-width:2px;
subgraph Stack ["Call stack at the error"]
direction BT
D["<module>"]
C["report()"]
B["line_summary()"]
A["unit_price()"]
D --> C
C --> B
B --> A
end
class A failing;
linkStyle default display:none;
4.2 Debugging and error handling
Debugging is the process of identifying and fixing defects in code. It is an essential skill for programmers to develop. In this course, you will encounter various errors while writing Python code. Understanding how to debug your code effectively will help you become a better programmer.
4.2.1 Common types of errors
Errors in Python code can be broadly classified into three categories:
- Syntax Errors: These errors occur when the code violates the rules of the Python language. Python reports them before it runs any of the file, so a file with a syntax error produces no output at all.
- Runtime Errors: Runtime errors occur while the program is running. The file is valid Python, but an operation fails as it executes, for example dividing by zero, indexing past the end of a list, or opening a file that does not exist. Python stops and reports the exception.
- Logic Errors: Logic errors are the most challenging to identify. They occur when the code runs without throwing any errors but produces incorrect results. Logic errors require careful analysis of the code to identify and fix.
4.2.2 Debugging techniques
Here are some common techniques you can use to debug your Python code:
- Print Statements: Use the
printfunction to report a value at the moment execution reaches a line. The section on tracing a program withprintbelow works through a defect this way, from the first print to the correction. - Breakpoints: Set breakpoints in your code to pause execution at specific lines and inspect the state of variables.
- Watch Expressions: Use watch expressions in your IDE to monitor the value of variables as the code executes.
- Exception Handling: Implement error handling routines to gracefully handle exceptions and provide meaningful error messages.
- Testing: Write test cases to verify the correctness of your code and catch errors early in the development process.
- Rubber Duck Debugging: Explain your code line by line to an inanimate object (like a rubber duck) to identify issues. Many times, just by articulating the problem, the solution becomes apparent. For example, you can use ChatGPT as a "virtual rubber duck" to help you debug your code.
- Logging: Write output to a log file to track the program's execution and identify errors.
- Code Review: Have a peer review your code to identify potential issues and suggest improvements.
- Assertions: Use
assertstatements to state what must be true at a point in your code, so that a broken assumption stops the program instead of travelling through it. The section on checking assumptions withassertbelow covers the statement, its failure message, and the cases it does not suit.
4.2.3 Debugging with VS Code
Visual Studio Code (VS Code) is a popular code editor that provides a powerful debugger for Python. Here are some common debugging features in VS Code:
- Breakpoints: Set a breakpoint by clicking in the narrow margin to the left of the line number, or by pressing
F9on the line. When execution reaches the breakpoint, it pauses before that line runs, so you can inspect the state of variables and step through the code. A breakpoint shows as a red dot; clicking the dot again removes it. To keep a breakpoint but stop it from pausing execution, disable it from the Breakpoints panel or from its right-click menu. - Step Over: Execute the current line and move to the next line in the code
- Step Into: If the current line calls a function, step into the function to debug it
- Step Out: Finish debugging the current function and return to the calling function
- Continue: Resume execution until the next breakpoint is encountered
- Watch: Monitor the value of variables as the code executes by adding them to the watch list in the debugger panel. You can see the value of variables change in real-time as the code runs.
- Call Stack: View the call stack to see the sequence of function calls that led to the current point in the code execution flow.
- Debug Console: Use the debug console to execute Python code and interact with the program during debugging.
- Variables: View the values of variables in the current scope and inspect their contents. The variables are further categorized into
LocalsandGlobalsso you can see what belongs to each scope. You can also modify variable values during debugging to test different scenarios.
Figure 1 shows the debugging interface in VS Code with various features highlighted. You can use these features to step through your code, inspect variables, and identify issues. For Python debugging, you could follow the steps outlined on the tutorial: Python debugging in VS Code.
4.2.4 Tracing a program with print
The quickest way to see what a program is doing is to make it say so. A print statement placed inside the code reports a value at the moment execution reaches that line. It needs no tools and no configuration, which is why it is usually the first thing to reach for.
The function in Listing 1 classifies a number, and it is wrong: it reports 0 as negative. Nothing in the output explains why.
0, with no indication of the cause.
Print the value you are unsure about
Start by showing the value the decision depends on. Listing 2 reports value on entry, which confirms that the function received what you expected and that the fault lies after this point.
!r conversion shows the value as Python writes it, so 0 and "0" cannot be confused.
Use an f-string with the variable name in it, as f"value={value!r}" does. A bare print(value) produces a lonely number that tells you nothing once several prints are in the file.
Print which branch runs
Once the inputs are confirmed, the remaining question is which path the program takes. Listing 3 labels every branch, and the output names the branch that produced the wrong answer.
0 is caught by value <= 0, so the Zero branch is never reached.
The report is now specific: the second condition should be value < 0, not value <= 0. Listing 4 applies that single change and checks the three cases.
Remove the prints when you are done
Tracing prints are scaffolding, not output. Left behind, they clutter the console of every later run and mix debugging chatter into results that someone else has to read. Delete them once the defect is found, as Listing 4 does.
This is also the limit of the technique. Printing shows you a value only while you are watching the console, and only for the run in front of you. To make the program check itself on every run, state the expectation with an assert, described next.
4.2.5 Checking assumptions with assert
Breakpoints and print statements show you what a program is doing while you watch it. An assert statement makes the program check itself, every time it runs, without you watching.
An assertion states something you believe is true at that point in the code. Python evaluates the condition: when it is True, execution continues and nothing is printed; when it is False, the program stops immediately with an AssertionError. A logic error that would otherwise travel silently through the rest of the program becomes a loud failure on the line where your assumption broke.
Listing 5 shows the quiet case. A water temperature cannot fall below absolute zero, and this one does not, so the assertion produces no output at all.
assert itself.
Writing the failure message
An assertion that fails is only useful if it tells you what went wrong. The statement takes an optional message after the condition, assert condition, message, and that message appears in the traceback.
The script in Listing 6, saved as shares.py, states that three percentages add up to a whole. They do not, and Listing 7 shows what Python reports when the file is run.
Write the message so that it names the value that broke the assumption, as the f-string above does with {total}. A bare AssertionError tells you only the line; a message tells you the state.
Checking a function against known cases
The most common use of assertions in this course is testing: you call a function with an input whose answer you already know, and assert that the function returns it. Listing 8 collects every case in one test function, so that a single call checks them all.
got, so the message can report what the function actually produced.
Include the boundaries among the cases. Here 0 separates the positive from the negative branch, and it is the input most likely to expose a wrong comparison operator. You have already met this pattern in the exercises of the Conditionals chapter, where each exercise ends with a block of assertions that your function has to satisfy.
Checking an assumption inside a function
Assertions are not only for tests. Placed inside a function, they record what the code requires to be correct. In Listing 9, the discount percentage has to lie between 0 and 100. If a caller ever passes 120, the function stops at the assertion instead of quietly returning a negative price.
What assertions are not for
An assertion is a statement about your own code, addressed to you. It is not a way to handle a situation you expect to happen.
Python can be run with the -O (optimize) option, and every assert statement is then skipped. Code whose correctness depends on an assertion running will therefore break silently under python -O. This makes assertions the wrong tool for anything outside your control:
- a value typed by a user,
- a line read from a file,
- a response from another program.
Those are expected conditions, not broken assumptions. Check them with an if statement, or handle the resulting error with the try-except statement described next.
4.2.6 Reading the call stack
The Functions chapter introduced the call stack: each call adds a frame, each return removes one, and every frame keeps its own local variables. When a program fails, that stack is the most useful thing on the screen, because the traceback is the call stack written out at the moment the error was raised.
The program in Listing 10, saved as orders.py, divides a total by a count. Nothing in unit_price looks wrong, and the fault is not there.
report supplies the count that unit_price eventually divides by.
Running orders.py produces Listing 11.
File entry is one frame of the call stack.
Traceback (most recent call last):
File "orders.py", line 13, in <module>
print(report())
~~~~~~^^
File "orders.py", line 10, in report
return line_summary(90.0, 0)
File "orders.py", line 6, in line_summary
return unit_price(total, count)
File "orders.py", line 2, in unit_price
return total / count
~~~~~~^~~~~~~
ZeroDivisionError: division by zeroRead a traceback from the bottom
The frames are printed oldest first, so the order on screen is the order the calls were made. Two lines carry most of the information:
- the last line names the error,
ZeroDivisionError: division by zero; - the frame just above it is where the program stopped,
unit_price, line 2.
Read upwards from there and the traceback answers the next question by itself: unit_price was called by line_summary, which was called by report, which passed the literal 0. The defect is in report, four lines away from the line that failed. The bottom frame tells you where it broke; the frames above tell you how execution got there.
Figure 2 shows the same four frames as a stack, with the initial frame of the program at the bottom and the failing call at the top.
ZeroDivisionError is raised, with the initial frame of the program at the bottom and the failing call at the top. The traceback in Listing 11 prints these frames from the bottom upwards, so its last frame is this diagram's top box.
The same stack in the debugger
The Call Stack panel described earlier in this chapter shows exactly these frames, with one difference that makes it more useful than the printed traceback: the program is still paused, so the frames are live. Selecting report in the panel switches the Variables view to that frame, where the literal 0 is visible before it is passed on.
This is the fastest route through a failure in a program of several functions: run to the error, read the bottom frame for the cause, then click up the stack until you reach the frame whose values are wrong. That frame holds the defect, and it is rarely the one that raised the error.
4.2.7 Conditional statements for error handling?
In Python, conditional statements can check a condition before an operation. For example, you can check whether a file exists before reading it or whether a value is None before using it. Exceptions report errors that arise while a program runs. A try-except block can handle a specified exception when the operation raises it.
In Listing 12, we compare two ways to handle a division by zero error. The first function uses an if statement to check if the denominator is zero before performing the division. The second function uses a try-except block to catch the ZeroDivisionError exception that occurs when dividing by zero.
if statement and a try-except block.
if statement to check if the denominator is zero before performing the division.
ZeroDivisionError exception using a try-except block. When an exception occurs, the code inside the except block is executed.
Choose the handling that matches the function's contract. A conditional check can handle an expected condition before an operation. A specific except block handles the corresponding exception raised during the operation and keeps that response separate from the normal calculation. In Listing 12, the try-except block can be further extended to handle other exceptions that may occur during the division operation, for example, TypeError if the input is not a number. For example, in Listing 13, we extend the divide_v2 function to handle both ZeroDivisionError and TypeError exceptions.
divide_v2 function to handle both ZeroDivisionError and TypeError exceptions using a try-except block.
4.2.8 Try-except statement
The try-except statement is used to enable error handling within Python code. There are several ways to use the try-except statement:
- try-except: This statement tells Python to execute the code in the
tryblock and handle any exceptions that occur in theexceptblock. - try-except-else: This statement adds an
elseblock that is executed if no exceptions occur in thetryblock. - try-except-finally: This statement adds a
finallyblock that is executed regardless of whether an exception occurs or not.
Reading a number that somebody typed is the standard case. int(text) raises a ValueError when the text is not a whole number, and no if statement can check that in advance without repeating the work that int already does. In Listing 14, the try block attempts the conversion and the except block supplies a fallback.
try block runs the conversion, and the except ValueError block returns 0 for text that is not a whole number, including "7.5" and the empty string.
This is the division of labor promised in the section on assertions. Text arriving from a person is an expected condition, not a broken assumption, so it is handled rather than asserted. Keep the try block to the statement that can fail, and name the exception you expect, as except ValueError does here. A bare except would also swallow a misspelled function name and leave you debugging a fallback value instead of a typing error.
4.2.9 Exercises
4.2.Q1 Trace argument mapping and scope
A variable's scope determines where its name is accessible in the code. In Listing 15, the Python function calculate takes 2 input parameters, a and b, and returns a floating-point value. The main function initializes variables a, b, and c, calls calculate, and prints the result.
The comments mark 3 breakpoints (BREAKPOINT 1, BREAKPOINT 2, and BREAKPOINT 3) where the variable values are inspected before the next statement executes.
calculate and main with positional arguments, local variables, and 3 breakpoint comments.
def calculate(a: float, b: float) -> float:
c: float = 0.0
result: float = 0.0
# BREAKPOINT 2
if c < b + a:
c = a * 2
result = -1.0
# BREAKPOINT 3
result = a + b + c
return result
def main() -> None:
a: float = 2.0
b: float = 3.0
c: float = 4.0
c = calculate(b, a)
# BREAKPOINT 1
print(c)
if __name__ == "__main__":
main()Complete the following tasks:
- Map the 2 arguments in
calculate(b, a)to the parameters ofcalculate. Give each caller value and the corresponding local parameter value. - Complete Table 1. Record the active function and the values visible in that function at each breakpoint. Write
not in scopewhen a listed name is not defined in the active function. - Calculate the value returned by
calculate. - Explain why
aandbinmainremain unchanged whilecinmainchanges.
| Breakpoint | Active function | a |
b |
c |
result |
|---|---|---|---|---|---|
BREAKPOINT 2 |
|||||
BREAKPOINT 3 |
|||||
BREAKPOINT 1 |
4.2.Q2 Tracing nested function calls
Turkish names can contain spaces, punctuation, and special characters that need adjustment before the names are used as folder names. The functions in Listing 16 each perform 1 adjustment.
def trim_spaces(text: str) -> str:
"""Remove leading and trailing whitespace."""
result = text.strip()
print(f'"{result}"')
return result
def strip_punct(text: str) -> str:
"""Remove punctuation symbols."""
result = text.replace("!", "")
result = result.replace("?", "")
result = result.replace(".", "")
result = result.replace(",", "")
print(f'"{result}"')
return result
def to_lower(text: str) -> str:
"""Convert the text to lowercase."""
result = text.lower()
print(f'"{result}"')
return result
def to_regular(text: str) -> str:
"""Replace special characters."""
result = text.replace("ç", "c")
result = result.replace("Ç", "C")
result = result.replace("ğ", "g")
print(f'"{result}"')
return result
def add_underscores(text: str) -> str:
"""Replace spaces with underscores."""
result = text.replace(" ", "_")
print(f'"{result}"')
return resultThe functions in Listing 17 use the helpers in one nested expression. The exclamation mark and trailing spaces are part of the input.
"Çiğdem Yildiz! ".
def format_folder_name(text: str) -> str:
"""Prepare a name for folder format."""
return add_underscores(
to_regular(
to_lower(
strip_punct(
trim_spaces(text)
)
)
)
)
def test_format_folder_name() -> None:
name_text = "Çiğdem Yildiz! "
print(f'"{name_text}"')
print(f'"{format_folder_name(name_text)}"')
if __name__ == "__main__":
test_format_folder_name()Complete the following tasks without executing the code.
- Number the 5 helper-function calls in their execution order.
- Write all 7 lines printed by
test_format_folder_name, including the quotation marks and repeated final value. - For each helper call, record its input and returned value.
- Rewrite
format_folder_namewithout nested calls. Store each intermediate value in a clearly named local variable and return the final value. - Explain why the final folder name is printed twice.