Debug before measuring

The first run of our word counter looks plausible, yet punctuation and capitalization split one word into several results. Before timing anything, we will turn that observation into a small failing test and follow the data through the program.

Objectives

  • Read a traceback from the final exception back to the relevant call.

  • Reduce a defect to a deterministic test and inspect state with breakpoint().

  • Choose among assertions, logging, and short-lived diagnostic printing.

Instructor note

  • 10 min teaching

  • 10 min type-along

  • 5 min exercise and discussion

Start from an observable failure

Run the deliberately defective implementation from the repository root:

$ cd content/episodes/code
$ cp wordcount_buggy.py wordcount_working.py
$ python -c "from wordcount_working import count_words_buggy; print(count_words_buggy('Python python, PYTHON!'))"

PowerShell users can create the working copy with Copy-Item wordcount_buggy.py wordcount_working.py. The copy is ignored by Git, so the reusable faulty example remains unchanged.

The result contains three keys instead of one. First capture the difference in a cell that remains safe to execute as part of the generated notebook:

from collections import Counter
from pathlib import Path
import sys

code_dir = next(
    path.resolve()
    for path in (Path("content/episodes/code"), Path("../content/episodes/code"))
    if path.is_dir()
)
if str(code_dir) not in sys.path:
    sys.path.insert(0, str(code_dir))

from wordcount_buggy import count_words_buggy

text = "Python python, PYTHON!"
observed = count_words_buggy(text)
expected = Counter({"python": 3})
print("observed:", observed)
print("expected: ", expected)
assert observed != expected  # The deliberate defect is still present.

The final assertion is a guard for the teaching fixture: it ensures the deliberately broken implementation has not been accidentally repaired. The actual correctness oracle learners apply to their working copy is:

from collections import Counter
from wordcount_working import count_words_buggy

assert count_words_buggy("Python python, PYTHON!") == Counter({"python": 3})

Run this snippet in Python or save it temporarily as test_working.py. The resulting AssertionError gives a reproducible boundary: same input, same expected output, same failure.

Before looking at the implementation, say what the result tells you and what it does not. It tells us normalization is not reflected in the keys. It does not yet tell us whether normalization failed, its return value was ignored, or the wrong value was used later. That distinction keeps the investigation focused without turning the first guess into a conclusion.

Read tracebacks from the bottom

The last line names the exception. The frames above it show how execution arrived there; begin at the lowest frame belonging to your code, inspect its values, then move outward only when needed. Produce a short multi-frame traceback now:

$ python -c "from wordcount import count_words; count_words(None)"

Read it from the bottom: identify the exception type, the failing expression in count_words(), and the caller that passed None. Preserve the full traceback in a job log—copying only its last line often removes the useful context.

Assertions document internal conditions that should never be false:

assert token == token.lower(), "normalization must lowercase tokens"

Do not use assert to validate user input or files: Python can disable assertions. Raise a normal exception for those cases, as make_workload() does for invalid sizes.

In a notebook, we can inspect the two candidate values without starting an interactive debugger. Predict the output before running the cell:

from wordcount import normalize_slow

raw_token = "Python,"
token = normalize_slow(raw_token)
raw_token, token

This observation motivates the debugger session below; it does not replace it. pdb lets us stop inside the real loop and verify which value is used on the following line.

Type-along: follow one token

Open wordcount_working.py and place breakpoint() after normalization:

for raw_token in text.split():
    token = normalize_slow(raw_token)
    breakpoint()
    if token:
        counts[raw_token] += 1

Then run the small example again. At the (Pdb) prompt:

p raw_token
p token
n
c

p evaluates an expression, n advances to the next line, and c continues. The evidence is now concrete: token is normalized, but the dictionary indexes raw_token. Replace counts[raw_token] with counts[token], remove the breakpoint, and rerun the oracle.

For a non-interactive batch failure, first preserve inputs and logs, reproduce with the smallest case, and then use python -m pdb script.py in an interactive allocation. An attached debugger cannot help after a batch job has disappeared unless the evidence was recorded.

Logging and IceCream

Use logging for durable, levelled diagnostics:

import logging

logger = logging.getLogger(__name__)

def log_token_metadata(raw_token: str) -> None:
    logger.debug("normalizing a token with %d characters", len(raw_token))

logging.basicConfig(level=logging.DEBUG)
log_token_metadata("Python,")

Avoid logging credentials, access tokens, personal data, proprietary inputs, or huge arrays. Log shape, type, range, or an anonymized identifier instead.

IceCream is an optional convenience for a short local investigation. ic(expression) prints the expression, its value, and context to standard error, and returns the value. Try the bounded example:

$ python debug_with_icecream.py

It is not a debugger or production logging system. Do not use global installation helpers in shared course code; remove or disable calls before benchmarking, and never pass secrets or large scientific data to ic().

After completing the exercise, remove test_working.py if you created it. You may keep the ignored wordcount_working.py for reference or delete it; later repository tests continue to validate the untouched course examples.

Exercise

Without running the code, predict which value should index counts: raw_token or token. Then use pdb to collect one observation that supports your answer.

Keypoints

  • Establish correctness with a small reproducible case before measuring performance.

  • A debugger follows state and control flow; logging preserves selected evidence across runs.

  • Diagnostic output can expose sensitive data and distort timings.

See also

Python documents pdb, logging, and exceptions.