Testing and debugging · about 50 min

Debugging: tracebacks, print and logging

Why this matters

Every programmer writes bugs; what separates beginners from experienced engineers is how quickly and calmly they find them. Debugging is not staring at code hoping the mistake jumps out. It is a method, much like a scientific experiment: observe, form a hypothesis, test it, repeat. This lesson gives you that method and three everyday tools: reading tracebacks properly, printing the right things, and the logging module, which is what real programs use instead of scattered prints.

The method

When a program misbehaves:

  1. Reproduce the problem reliably. Find the exact input that triggers it and a single command that shows it. A bug you cannot reproduce, you cannot confirm fixed.
  2. Read the evidence. If there is a traceback, read all of it (below). If there is wrong output, write down exactly how it differs from what you expected.
  3. Isolate. Shrink the input and the code until the bug is in a small region. Cut the 10,000-line input in half: does it still fail? Keep the failing half. Comment out steps. Call the suspicious function directly with one value.
  4. Hypothesize, then test the hypothesis. "I think scores holds strings, so max compares text." Check it with one targeted print or one experiment in the REPL. If wrong, discard the idea and form another; do not change code at random.
  5. Fix the cause, not the symptom, and add a test that would have caught it (a regression test, from the first lesson of this module).

The most common debugging failure is skipping steps 2-4: changing things until the output looks right, without understanding why. That hides bugs instead of fixing them.

Reading a traceback

A traceback is a snapshot of the call stack at the moment an exception escaped: which function called which, down to the line that failed.

Traceback (most recent call last):
  File "report.py", line 22, in <module>
    main()
  File "report.py", line 18, in main
    print(format_row(row))
  File "report.py", line 11, in format_row
    return f"{row['name']:<10} {unit_price(row):>8.2f}"
  File "report.py", line 6, in unit_price
    return row["total"] / row["qty"]
           ~~~~~~~~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero

Here the arithmetic is fine; the data is not. The useful question is "which row has qty 0, and should it have been filtered out earlier?", which leads you back up the stack to where rows are read.

If an exception happens while handling another one, you see two tracebacks joined by During handling of the above exception, another exception occurred:. The last one is what finally stopped the program; the first one is often the real cause.

Adding print calls is a perfectly respectable technique. Make them informative:

print("DEBUG scores:", repr(scores), type(scores[0]))
DEBUG scores: ['9', '10', '8'] <class 'str'>

In the exercise with three bugs, print(repr(scores)) immediately shows the scores are strings, and string comparison is alphabetical: "9" > "10" is True, because "9" sorts after "1".

logging: prints that grew up

For anything bigger than a throwaway script, use the standard logging module. Every log message has a level:

LevelUse for
DEBUGdetailed internals, only useful while diagnosing
INFOnormal milestones: "loaded 1,200 records"
WARNINGsomething unexpected but handled: "skipping malformed line 7"
ERRORan operation failed
CRITICALthe program cannot continue
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

logging.debug("raw input: %r", "x")      # below INFO: not shown
logging.info("loaded %d records", 1200)
logging.warning("skipping line %d: %r", 7, "n/a")
INFO: loaded 1200 records
WARNING: skipping line 7: 'n/a'

What is happening:

In larger programs, each module creates its own named logger with log = logging.getLogger(__name__) and calls log.info(...). The names form a hierarchy (shop, shop.cart), so you can turn on DEBUG for one part of the program only. Library code should never call basicConfig; configuring output is the application's job.

Inside an except block, logging.exception("could not parse %s", path) logs at ERROR level and includes the full traceback, which is ideal for long-running programs that must report a failure and carry on.

Common mistakes

Recap

Going further: read the "Logging HOWTO" in the Python documentation (the basic tutorial section), and try python3 -X faulthandler plus python3 -X importtime -c "import json" to see two more built-in diagnostic switches.

Exercises

Exercise 1 · quiz

Where did it break?

Traceback (most recent call last):
  File "report.py", line 22, in <module>
    main()
  File "report.py", line 18, in main
    print(format_row(row))
  File "report.py", line 11, in format_row
    return f"{row['name']:<10} {unit_price(row):>8.2f}"
  File "report.py", line 6, in unit_price
    return row["total"] / row["qty"]
           ~~~~~~~~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero

Which function was executing when the exception was raised, and what is the most useful first question to ask?

Answer
  • unit_price; why does this row have qty equal to 0, and where did that row come from?

A traceback lists frames from the outermost call (top) to the innermost (bottom); the last frame, just above the error line, is where the exception was raised: unit_price, line 6. The division itself is correct code; the data is surprising. So the useful question is where a row with qty == 0 came from (walk back up the frames to main, which supplied row) and what the program should do with such rows.

Exercise 2 · quiz

Which messages appear?

A program does not call logging.basicConfig and runs:

import logging
logging.debug("connecting")
logging.info("connected")
logging.warning("slow response")
logging.error("request failed")

Which messages are printed (to stderr)? Select all that apply.

Answer
  • slow response

  • request failed

The root logger's default threshold is WARNING: messages below it (DEBUG, INFO) are dropped, and WARNING, ERROR and CRITICAL are shown, in the default format WARNING:root:slow response. Call logging.basicConfig(level=logging.DEBUG) (or INFO) to see the quieter messages; that one line is how you turn on detail while debugging without editing every log call.

Exercise 3 · write code

Find and fix three bugs

This program reads lines of the form name score (score is a whole number) from stdin and should print:

students: <count>
average: <mean score, rounded to 1 decimal place>
top: <name of the student with the highest score>

For the input

ada 9
grace 10
alan 8

it should print students: 3, average: 9.0, top: grace. Instead it prints wrong answers. The starter contains three bugs. Run it, compare with the expected output, add temporary print(repr(...)) lines to see what the variables really hold, and fix each bug. Remove your debugging prints before checking. If several students share the highest score, the first one listed wins.

File: main.py

Starting point

import sys

names = []
scores = []
for line in sys.stdin:
    name, score = line.split()
    names.append(name)
    scores.append(score)

count = len(names)
total = 0
for i in range(1, count):
    total += int(scores[i])
average = total / count

best = 0
for i in range(count):
    if scores[i] > scores[best]:
        best = i

print("students:", count)
print("average:", round(average, 1))
print("top:", names[best])

Example

input

ada 9
grace 10
alan 8

expected output

students: 3
average: 9.0
top: grace

Example

input

linus 95
guido 87
barbara 90
ken 59

expected output

students: 4
average: 82.8
top: linus
Hint 1

Print repr(scores) after the reading loop. Are those numbers or strings? How does "9" > "10" compare?

Hint 2

Which index does range(1, count) start at? Is the first student's score included in the total?

Hint 3

The third bug shows up with a blank line in the input: line.split() on an empty line gives [], and unpacking it fails. Skip lines that are blank.

Reference solution
import sys

names = []
scores = []
for line in sys.stdin:
    if not line.strip():
        continue
    name, score = line.split()
    names.append(name)
    scores.append(int(score))

count = len(names)
total = 0
for i in range(count):
    total += scores[i]
average = total / count

best = 0
for i in range(count):
    if scores[i] > scores[best]:
        best = i

print("students:", count)
print("average:", round(average, 1))
print("top:", names[best])

Exercise 4 · write code

Logging to stderr, results to stdout

Write a program that reads lines from stdin, each supposed to contain one integer, and prints the sum of the valid integers to stdout as total: <sum>.

It must also log, using the logging module configured with

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

basicConfig sends log output to stderr, so stdout contains only the total. For the input

10
ten
-3

5

stdout is total: 12 and stderr is exactly:

WARNING: skipping line 2: 'ten'
WARNING: skipping line 4: ''
INFO: summed 3 numbers

File: main.py

Starting point

import logging
import sys

total = 0
for line in sys.stdin:
    total += int(line)
print("total:", total)

Example

input

10
ten
-3

5

expected output

total: 12

Example

input

1
2
3

expected output

total: 6
Hint 1

enumerate(sys.stdin, start=1) gives you line numbers.

Hint 2

Catch ValueError from int(text); int("") raises it too, which handles blank lines.

Hint 3

logging.warning("skipping line %d: %r", n, text) formats with %r, which is the repr: quotes included.

Reference solution
import logging
import sys

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

total = 0
count = 0
for n, line in enumerate(sys.stdin, start=1):
    text = line.strip()
    try:
        value = int(text)
    except ValueError:
        logging.warning("skipping line %d: %r", n, text)
        continue
    total += value
    count += 1

logging.info("summed %d numbers", count)
print("total:", total)