Level 3 · Real Programs

Debugging and Logging 🔦

Lesson 10 taught you to read an error. This one is about the harder case: the program runs perfectly and produces the wrong answer.

The method, restated

A bug is always the same thing: a gap between what you believe and what is true. The whole job is finding which belief is wrong, and the fastest route is not to stare harder, it is to make beliefs visible and test them one at a time.

  1. Reproduce it reliably. A bug you cannot trigger on demand cannot be fixed, only guessed at. Get it down to the smallest input that shows the problem.
  2. State the belief. "At line 14, total should be 47."
  3. Check it. Print it, or stop there with a debugger.
  4. Halve the search space. If the belief was right, the bug is downstream. If it was wrong, it is upstream. Repeat.
  5. Fix, then prove. Write a test that fails before your fix and passes after (Lesson 29). Otherwise it will come back.

Level 1: better prints

def parse_row(row):
    parts = row.split(",")
    print(f"{row=}")
    print(f"{parts=}")
    print(f"{len(parts)=}")
    return {"name": parts[0], "pay": int(parts[1])}


print(parse_row("Guybrush, 100"))
row='Guybrush, 100'
parts=['Guybrush', ' 100']
len(parts)=2
{'name': 'Guybrush', 'pay': 100}
PERCEPTION[Formidable: Success]

Look at the second field: ' 100', with a space in front of it. int() happens to tolerate that, so this row parsed fine and you learned nothing.

Now imagine the field were a name and you compared it with ==. ' Otis' is not 'Otis', and the comparison fails for a reason that is completely invisible when you print the value on its own. This is why the debugging print shows repr, with the quotes, rather than the bare text.

That {variable=} form inside an f-string is the fastest debugging tool Python has. It prints the name, the value, and (because it uses repr) the quotes and escapes that reveal stray whitespace. Three keystrokes, and it has ended more mysteries than any debugger.

Two other print upgrades worth knowing:

import sys

print("this goes to stderr, so it does not pollute piped output", file=sys.stderr)
print("flushed immediately, useful when a program is about to crash", flush=True)
this goes to stderr, so it does not pollute piped output
flushed immediately, useful when a program is about to crash

Level 2: the built-in debugger

def calculate_total(prices, discount):
    total = sum(prices)
    # breakpoint()          # uncomment and run this in a terminal
    final = total * (1 - discount)
    return round(final, 2)


print(calculate_total([10.00, 24.99, 5.50], 0.1))
36.44

Put breakpoint() on a line and run the program in a terminal. Execution stops there and hands you a prompt where you can inspect anything:

(Pdb) total
40.49
(Pdb) discount
0.1
(Pdb) total * (1 - discount)
36.441
(Pdb) prices
[10.0, 24.99, 5.5]
(Pdb) n          # run the next line
(Pdb) c          # continue to the end
(Pdb) q          # quit
CommandDoes
n (next)Run this line, stay in this function
s (step)Run this line, step into any function it calls
c (continue)Run until the next breakpoint or the end
l (list)Show where you are in the source
p xPrint x
pp xPretty-print x
w (where)Show the call stack
q (quit)Stop

breakpoint() has been built in since Python 3.7 and needs no import. Your editor has a graphical version of the same thing, and in VS Code it is the F5 key. Both beat printing when the state is complicated, because you can ask questions you did not think of in advance.

Level 3: logging, for programs that outlive the terminal

import logging

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

log = logging.getLogger("crew")

log.debug("this will not appear, the level is INFO")
log.info("loaded 3 crew members")
log.warning("no ship assigned")
log.error("could not open manifest")
log.critical("hull breach")
INFO     loaded 3 crew members
WARNING  no ship assigned
ERROR    could not open manifest
CRITICAL hull breach

Why this beats print for anything real:

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s %(name)-8s %(levelname)-8s %(message)s",
    datefmt="%H:%M:%S",
)

log = logging.getLogger("ship")


def load_crew(names):
    log.debug("loading %d names", len(names))
    for name in names:
        if not name.strip():
            log.warning("skipping an empty name")
            continue
        log.info("added %s", name)
    return [n for n in names if n.strip()]


crew = load_crew(["Guybrush", "", "Elaine"])
log.info("finished with %d crew", len(crew))
🔤 Use %s, not an f-string, in log calls

log.info("added %s", name) rather than log.info(f"added {{name}}"). The formatting is then only done if the message is actually going to be emitted, which matters when a debug line sits inside a hot loop and the level is set to WARNING.

Logging exceptions properly

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("vault")


def open_vault(combination):
    try:
        return 100 / combination
    except ZeroDivisionError:
        log.exception("could not open the vault")
        return None


print(open_vault(0))

log.exception(...) inside an except block records your message and the full traceback. It is the single most useful logging call there is, and it is why "an error occurred" appears in so many useless log files: somebody used log.error and threw the traceback away.

Bugs that are not in your code

SymptomVery often
Works alone, fails in a loopShared mutable state, or a leftover variable from the previous iteration
Works on your machine onlyA file path, an environment variable, or a package version
Fails only sometimesOrdering, timing, or something depending on a set or dictionary you assumed was ordered
Broke after an updateA dependency changed. pip freeze and compare
Wrong by exactly oneAn off-by-one: a range end, or counting from 1 instead of 0
Changes when you add a printTiming or buffering. Usually threads (Lesson 39)
Exercise 1

Find the bug by halving

This should give each pirate an equal share of the treasure, rounded down, with the remainder going to the captain. It does not. Find out why, without just reading it: add prints and narrow it down.

def divide_treasure(total, crew):
    share = total // len(crew)
    remainder = total - share * len(crew)
    payouts = {}
    for member in crew:
        payouts[member] = share
    payouts[crew[0]] = remainder
    return payouts


print(divide_treasure(100, ["Guybrush", "Elaine", "Otis"]))
Reveal solution

The captain's line replaces their share instead of adding to it, so Guybrush gets 1 instead of 34.

def divide_treasure(total, crew):
    """Split total evenly, with any remainder going to crew[0]."""
    share = total // len(crew)
    remainder = total - share * len(crew)
    payouts = {member: share for member in crew}
    payouts[crew[0]] += remainder
    return payouts


result = divide_treasure(100, ["Guybrush", "Elaine", "Otis"])
print(result)
print("total paid out:", sum(result.values()))
{'Guybrush': 34, 'Elaine': 33, 'Otis': 33}
total paid out: 100

Note the second print. Checking that the parts add up to the whole is an invariant, and asserting invariants is how you catch this class of bug automatically instead of by eye.

Exercise 2

Replace prints with logging

Convert this debug-print-riddled function into one that uses logging at sensible levels.

def process(orders):
    print("starting")
    for order in orders:
        print("processing", order)
        if order["total"] < 0:
            print("BAD ORDER!", order)
            continue
        print("ok")
    print("done")
Reveal solution
import logging

log = logging.getLogger(__name__)


def process(orders):
    """Process each order, skipping any with a negative total."""
    log.info("processing %d orders", len(orders))
    processed = 0

    for order in orders:
        log.debug("order %s", order)
        if order["total"] < 0:
            log.warning("skipping order %s: negative total %s", order["id"], order["total"])
            continue
        processed += 1

    log.info("finished: %d of %d processed", processed, len(orders))
    return processed


logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
process([{"id": 1, "total": 10}, {"id": 2, "total": -5}])
INFO     processing 2 orders
WARNING  skipping order 2: negative total -5
INFO     finished: 1 of 2 processed

Notice the levels carry meaning now: the per-order detail is DEBUG and invisible by default, the skipped order is a WARNING you would want to see, and the summary is INFO. Same information, and you can now choose how much of it you want without editing the file.

Exercise 3

Practise with the debugger

On your own machine, save this and run it. Use breakpoint() to find out why the average is wrong.

def average(numbers):
    total = 0
    for n in numbers:
        total += n
    breakpoint()
    return total / len(numbers) - 1


print(average([10, 20, 30]))
Reveal solution

At the prompt, total shows 60 and total / len(numbers) shows 20.0, which is correct. So the bug is in the line you have not run yet: a stray - 1.

The lesson is the workflow, not the bug. You confirmed where the value was still right, which meant the fault had to be downstream of that point. Two questions, one bug found, no guessing.

+100 XP