Mathew K Analytics

Lesson 5 · Python Beginner Course

Python Debugging for Beginners: Read Errors, Fix Bugs, Write Tests (Beginner Python Course, Part 5)

In this lesson you'll be able to: Read a traceback line by line and jump straight to the bug Recognise the most common Python errors and fix each one Swap…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Debug Like a Pro: Errors, Testing and Clean Code#

In this lesson you'll be able to:

  • Read a traceback line by line and jump straight to the bug
  • Recognise the most common Python errors and fix each one
  • Swap scattered print() calls for the logging module
  • Check your code with assert, plain test functions and pytest
  • Write cleaner code with type hints, PEP 8 names, docstrings and refactoring
  • Capstone: debug and test a buggy gym membership fee calculator

Warm-Up#

In lesson 3 you caught errors with try and except. Today you learn to read them, prevent them and test for them.

def split_bill(total, people):
    return round(total / people, 2)

print(split_bill(48.60, 3))
print(split_bill(100, 4))
16.2
25.0
try:
    split_bill(48.60, 0)
except ZeroDivisionError as problem:
    print("Caught:", type(problem).__name__, "->", problem)
Caught: ZeroDivisionError -> float division by zero
from pathlib import Path

folder = Path("lesson_files")
folder.mkdir(exist_ok=True)
print("Working folder ready:", folder)
Working folder ready: lesson_files

Reading a Traceback#

A traceback is a trail of breadcrumbs. Start at the bottom: the last line says what went wrong, the lines above say where.

menu = {"latte": 3.20, "flat white": 3.40, "tea": 2.10}

def item_price(name):
    return menu[name]

def order_total(items):
    total = 0
    for item in items:
        total += item_price(item)
    return total

print(f"${order_total(['latte', 'tea']):.2f}")
$5.30

Read it from the bottom up#

  1. Last line: the error type and its message
  2. Just above: the exact line that crashed
  3. Further up: the chain of calls that led there
print(order_total(["latte", "mocha", "tea"]))
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[6], line 1
----> 1 print(order_total(["latte", "mocha", "tea"]))

Cell In[5], line 9, in order_total(items)
      6 def order_total(items):
      7     total = 0
      8     for item in items:
----> 9         total += item_price(item)
     10     return total

Cell In[5], line 4, in item_price(name)
      3 def item_price(name):
----> 4     return menu[name]

KeyError: 'mocha'
def order_total(items):
    total = 0
    for item in items:
        if item in menu:
            total += item_price(item)
        else:
            print(f"  Skipping '{item}': not on the menu")
    return round(total, 2)

print(order_total(["latte", "mocha", "tea"]))
  Skipping 'mocha': not on the menu
5.3

Quick check#

The order_total traceback showed three places in the code. Which one shows where the crash actually happened?

print("The LAST one, inside item_price. The ones above just show who called whom.")
The LAST one, inside item_price. The ones above just show who called whom.

Meet the Error Family#

Each error type is a different warning light on a car dashboard. Know what each light means and you know where to look. eval() runs a string as code. It's handy for this demo, but avoid it in real programs.

examples = ["playlist_lenght", "'Track ' + 7", "int('seven')",
            "['rock', 'jazz'][5]", "{'tea': 2.1}['coffee']",
            "'hello'.push('!')", "10 / 0"]
for code in examples:
    try:
        eval(code)
    except Exception as error:
        print(f"{code:<24} -> {type(error).__name__}: {error}")
playlist_lenght          -> NameError: name 'playlist_lenght' is not defined
'Track ' + 7             -> TypeError: can only concatenate str (not "int") to str
int('seven')             -> ValueError: invalid literal for int() with base 10: 'seven'
['rock', 'jazz'][5]      -> IndexError: list index out of range
{'tea': 2.1}['coffee']   -> KeyError: 'coffee'
'hello'.push('!')        -> AttributeError: 'str' object has no attribute 'push'
10 / 0                   -> ZeroDivisionError: division by zero

Common mistake: a typo in a variable name (NameError)#

playlist_length = 42
print(f"Playlist runs for {playlist_lenght} minutes")
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[10], line 2
      1 playlist_length = 42
----> 2 print(f"Playlist runs for {playlist_lenght} minutes")

NameError: name 'playlist_lenght' is not defined
playlist_length = 42
print(f"Playlist runs for {playlist_length} minutes")
Playlist runs for 42 minutes

Common mistake: gluing text to a number (TypeError)#

age = 29
print("You are " + age + " years old")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 2
      1 age = 29
----> 2 print("You are " + age + " years old")

TypeError: can only concatenate str (not "int") to str
age = 29
print("You are " + str(age) + " years old")
print(f"You are {age} years old")
You are 29 years old
You are 29 years old

ValueError: right type, wrong value#

int() happily takes a string, but only one that looks like a whole number.

for text in ["12", " 12 ", "12.5", "twelve"]:
    try:
        print(f"{text!r:<10} -> {int(text)}")
    except ValueError as error:
        print(f"{text!r:<10} -> ValueError: {error}")
'12'       -> 12
' 12 '     -> 12
'12.5'     -> ValueError: invalid literal for int() with base 10: '12.5'
'twelve'   -> ValueError: invalid literal for int() with base 10: 'twelve'
while True:
    answer = input("How many laps did you swim? ")
    try:
        laps = int(answer)
        break
    except ValueError:
        print(f"  '{answer}' isn't a whole number, try digits like 12.")
print(f"Great work: {laps} laps is {laps * 25} metres!")
How many laps did you swim? twelve
  'twelve' isn't a whole number, try digits like 12.
How many laps did you swim? 12 
Great work: 12 laps is 300 metres!

IndexError and KeyError: asking for something that isn't there#

genres = ["rock", "jazz", "folk"]
stock = {"tea": 12, "latte": 30}

print("Last genre:", genres[-1], "| valid positions 0 to", len(genres) - 1)
print("Coffee in stock:", stock.get("coffee", 0))
Last genre: folk | valid positions 0 to 2
Coffee in stock: 0

Common mistake: calling a method that doesn't exist (AttributeError)#

shopping = ["milk", "bread"]
shopping.push("eggs")
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[17], line 2
      1 shopping = ["milk", "bread"]
----> 2 shopping.push("eggs")

AttributeError: 'list' object has no attribute 'push'
shopping = ["milk", "bread"]
shopping.append("eggs")
print(shopping)
methods = [m for m in dir(shopping) if not m.startswith("_")]
print("List methods:", methods)
['milk', 'bread', 'eggs']
List methods: ['append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

Common mistake: averaging an empty list (ZeroDivisionError)#

def average(scores):
    return sum(scores) / len(scores)

print(average([7, 9, 8]))
print(average([]))
8.0
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[19], line 5
      1 def average(scores):
      2     return sum(scores) / len(scores)
      3 
      4 print(average([7, 9, 8]))
----> 5 print(average([]))

Cell In[19], line 2, in average(scores)
      1 def average(scores):
----> 2     return sum(scores) / len(scores)

ZeroDivisionError: division by zero
def average(scores):
    if not scores:
        return 0.0
    return sum(scores) / len(scores)

print(average([7, 9, 8]))
print(average([]))
8.0
0.0

Common mistake: wonky indentation (IndentationError)#

Python checks the layout before it runs anything. compile() lets us test a code snippet safely.

snippet = "def greet():\nprint('Hello!')"
compile(snippet, "greet.py", "exec")
Traceback (most recent call last):

  File /usr/local/lib/python3.13/dist-packages/IPython/core/interactiveshell.py:3823 in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)

  Cell In[21], line 2
    compile(snippet, "greet.py", "exec")

  File greet.py:2
    print('Hello!')
    ^
IndentationError: expected an indented block after function definition on line 1
snippet = "def greet():\n    print('Hello!')"
exec(compile(snippet, "greet.py", "exec"))
greet()
Hello!
quiz = [("int('ten')", "ValueError"),
        ("[1, 2][9]", "IndexError"),
        ("len(5)", "TypeError")]
score = 0
for code, answer in quiz:
    guess = input(f"Which error does {code} raise? ").strip()
    if guess.lower() == answer.lower():
        score += 1
        print("  Correct!")
    else:
        print(f"  Not quite, it's {answer}.")
print(f"Score: {score}/{len(quiz)}")
Which error does int('ten') raise? ValueError
  Correct!
Which error does [1, 2][9] raise? KeyError
  Not quite, it's IndexError.
Which error does len(5) raise? typeerror
  Correct!
Score: 2/3

Your turn#

  1. Predict the error for "abc"[3], None + 1 and {}.append(1), then check with the eval loop.
  2. Make average() raise a ValueError with a helpful message instead of returning 0.0.

When code gives the wrong answer but no error, switch the lights on: print the values and watch them change.

def weekly_summary(minutes):
    total = 0
    active_days = 0
    for m in minutes:
        total = m
        if m > 0:
            active_days += 1
    return total, round(total / active_days, 1)

workouts = [30, 0, 45, 20, 0, 60, 15]
print(weekly_summary(workouts))
(15, 3.0)
total = 0
for day, m in enumerate(workouts, start=1):
    total = m
    print(f"  day {day}: m={m:>2}  total={total}")
  day 1: m=30  total=30
  day 2: m= 0  total=0
  day 3: m=45  total=45
  day 4: m=20  total=20
  day 5: m= 0  total=0
  day 6: m=60  total=60
  day 7: m=15  total=15
def weekly_summary(minutes):
    total = 0
    active_days = 0
    for m in minutes:
        total += m
        if m > 0:
            active_days += 1
    return total, round(total / active_days, 1)

print(weekly_summary(workouts))
(170, 34.0)

Pro tip: print(f"{name=}") prints the variable's name and value together, perfect for quick debugging.

total, avg = weekly_summary(workouts)
print(f"{total=}")
print(f"{avg=}")
total=170
avg=34.0
typed = "42\n"
print("Really is: ", repr(typed))
print(typed == "42", typed.strip() == "42")
Really is:  '42\n'
False True

The logging Module#

print() is a torch you switch off by hand, line by line. logging is a dimmer switch for the whole program. Five levels, quietest to loudest: DEBUG, INFO, WARNING, ERROR, CRITICAL.

import logging
import sys

logging.basicConfig(level=logging.DEBUG, format="%(levelname)-8s %(name)s: %(message)s",
                    stream=sys.stdout, force=True)
log = logging.getLogger("cafe")

log.debug("Grinding beans")
log.info("Shop is open")
log.warning("Oat milk running low")
log.error("Card machine offline")
log.critical("Coffee machine on fire")
DEBUG    cafe: Grinding beans
INFO     cafe: Shop is open
WARNING  cafe: Oat milk running low
ERROR    cafe: Card machine offline
CRITICAL cafe: Coffee machine on fire
log.setLevel(logging.WARNING)
log.debug("You won't see this")
log.info("Or this")
log.warning("But you will see this")
WARNING  cafe: But you will see this
def process_orders(orders):
    total = 0.0
    for item in orders:
        log.debug(f"Looking up {item}")
        if item not in menu:
            log.warning(f"Unknown item '{item}' skipped")
            continue
        total += menu[item]
    log.info(f"Processed {len(orders)} orders, total {total:.2f}")
    return round(total, 2)

log.setLevel(logging.INFO)
print("Total:", process_orders(["latte", "tea", "cake"]))
WARNING  cafe: Unknown item 'cake' skipped
INFO     cafe: Processed 3 orders, total 5.30
Total: 5.3
log.setLevel(logging.DEBUG)
print("Total:", process_orders(["tea", "flat white"]))
DEBUG    cafe: Looking up tea
DEBUG    cafe: Looking up flat white
INFO     cafe: Processed 2 orders, total 5.50
Total: 5.5
file_handler = logging.FileHandler("lesson_files/cafe.log", mode="w")
file_handler.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
log.addHandler(file_handler)

log.setLevel(logging.INFO)
process_orders(["latte", "muffin"])
file_handler.close()
log.removeHandler(file_handler)
WARNING  cafe: Unknown item 'muffin' skipped
INFO     cafe: Processed 2 orders, total 3.20
print("Inside cafe.log:")
print(Path("lesson_files/cafe.log").read_text())
Inside cafe.log:
WARNING | Unknown item 'muffin' skipped
INFO | Processed 2 orders, total 3.20

Quick check#

You're done debugging. How do you hide all the DEBUG and INFO messages without deleting a single line?

log.setLevel(logging.WARNING)
print(process_orders(["latte", "latte"]), "(no log lines: only warnings and worse are shown now)")
6.4 (no log lines: only warnings and worse are shown now)

assert: Sanity Checks#

assert is a smoke alarm: silent while all is well, loud the moment something is wrong.

assert 2 + 2 == 4
assert "latte" in menu
print("No alarms. Everything is as expected.")
No alarms. Everything is as expected.
def apply_discount(price, percent):
    assert 0 <= percent <= 100, f"percent must be 0-100, got {percent}"
    return round(price * (1 - percent / 100), 2)

print(apply_discount(40, 25))
30.0
print(apply_discount(40, 150))
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[38], line 1
----> 1 print(apply_discount(40, 150))

Cell In[37], line 2, in apply_discount(price, percent)
      1 def apply_discount(price, percent):
----> 2     assert 0 <= percent <= 100, f"percent must be 0-100, got {percent}"
      3     return round(price * (1 - percent / 100), 2)

AssertionError: percent must be 0-100, got 150
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be 0-100, got {percent}")
    return round(price * (1 - percent / 100), 2)

try:
    apply_discount(40, 150)
except ValueError as error:
    print("Refused:", error)
Refused: percent must be 0-100, got 150

Use assert for your own assumptions while developing. Use raise ValueError for bad input from users, because assert can be switched off with python -O.

assert apply_discount(40, 25) == 30.0
assert apply_discount(19.99, 0) == 19.99
print("All discount checks passed")
All discount checks passed

Testing with Plain Functions#

A test is a tiny robot that runs your code and checks the answer, every single time, without getting bored. Pattern: arrange the inputs, act by calling the function, assert the result.

def dog_food_grams(weight_kg, active=False):
    grams = weight_kg * 20
    if active:
        grams *= 1.2
    return round(grams)

print(dog_food_grams(5), dog_food_grams(10, active=True))
100 240
def test_small_dog():
    result = dog_food_grams(5)
    assert result == 100, f"expected 100, got {result}"

def test_active_dog():
    result = dog_food_grams(10, active=True)
    assert result == 240, f"expected 240, got {result}"

def test_puppy_weight_zero():
    assert dog_food_grams(0) == 0

def run_tests(tests):
    passed = 0
    for test in tests:
        try:
            test()
            print(f"PASS  {test.__name__}")
            passed += 1
        except AssertionError as error:
            print(f"FAIL  {test.__name__}: {error}")
    print(f"{passed}/{len(tests)} tests passed")

run_tests([test_small_dog, test_active_dog, test_puppy_weight_zero])
PASS  test_small_dog
PASS  test_active_dog
PASS  test_puppy_weight_zero
3/3 tests passed
def test_negative_weight_refused():
    try:
        dog_food_grams(-3)
    except ValueError:
        return
    raise AssertionError("negative weight should raise ValueError")

run_tests([test_small_dog, test_negative_weight_refused])
PASS  test_small_dog
FAIL  test_negative_weight_refused: negative weight should raise ValueError
1/2 tests passed
def dog_food_grams(weight_kg, active=False):
    if weight_kg < 0:
        raise ValueError("weight can't be negative")
    grams = weight_kg * 20
    if active:
        grams *= 1.2
    return round(grams)

all_tests = [test_small_dog, test_active_dog, test_puppy_weight_zero, test_negative_weight_refused]
run_tests(all_tests)
PASS  test_small_dog
PASS  test_active_dog
PASS  test_puppy_weight_zero
PASS  test_negative_weight_refused
4/4 tests passed

Your turn#

  1. Write test_big_lazy_dog for a 30 kg dog that isn't active, and add it to all_tests.
  2. Write a test that checks dog_food_grams(2.5) returns a whole number (isinstance(..., int)).

Mini-Project: Bug Hunt in the Plant Shop#

The plant shop's basket total gives odd answers. Write the expected answers first, then hunt. Rules: members get 10% off, then the voucher comes off, and the total never goes below zero.

def basket_total(prices, voucher=0, member=False):
    total = sum(prices[1:])
    if member:
        total = total * 0.9
    total - voucher
    return round(total, 2)

print(basket_total([12.0, 8.5, 4.5]))
13.0
basket_cases = [
    (([12.0, 8.5, 4.5],), 25.0),
    (([12.0, 8.5, 4.5], 5), 20.0),
    (([20.0, 10.0], 0, True), 27.0),
    (([6.0], 10), 0.0),
]

def check_basket(function):
    for args, expected in basket_cases:
        got = function(*args)
        status = "PASS" if got == expected else "FAIL"
        print(f"{status}  {str(args):<28} expected {expected:>5}  got {got}")

check_basket(basket_total)
FAIL  ([12.0, 8.5, 4.5],)          expected  25.0  got 13.0
FAIL  ([12.0, 8.5, 4.5], 5)        expected  20.0  got 13.0
FAIL  ([20.0, 10.0], 0, True)      expected  27.0  got 9.0
PASS  ([6.0], 10)                  expected   0.0  got 0
prices = [12.0, 8.5, 4.5]
print(f"{prices[1:]=}")
total = 25.0
total - 5
print(f"{total=}  (the subtraction was thrown away)")
prices[1:]=[8.5, 4.5]
total=25.0  (the subtraction was thrown away)
def basket_total(prices, voucher=0, member=False):
    total = sum(prices)
    if member:
        total = total * 0.9
    total = total - voucher
    return round(max(total, 0), 2)

check_basket(basket_total)
PASS  ([12.0, 8.5, 4.5],)          expected  25.0  got 25.0
PASS  ([12.0, 8.5, 4.5], 5)        expected  20.0  got 20.0
PASS  ([20.0, 10.0], 0, True)      expected  27.0  got 27.0
PASS  ([6.0], 10)                  expected   0.0  got 0

pytest: The Professional Test Runner#

pytest finds every function whose name starts with test_ and runs them all. You just write plain asserts. Real projects keep code in one file and its tests in test_<name>.py.

Path("lesson_files/tips.py").write_text("""
def tip_amount(bill, percent=10):
    return round(bill * percent / 100, 2)

def split_with_tip(bill, people, percent=10):
    total = bill + tip_amount(bill, percent)
    return round(total / people, 2)
""")
print("tips.py saved")
tips.py saved
Path("lesson_files/test_tips.py").write_text("""
from tips import tip_amount, split_with_tip

def test_default_tip():
    assert tip_amount(50) == 5.0

def test_custom_tip():
    assert tip_amount(80, percent=15) == 12.0

def test_split_between_four():
    assert split_with_tip(100, 4) == 27.5
""")
print("Test file written")
Test file written
import subprocess

def run_pytest(target):
    result = subprocess.run(
        [sys.executable, "-m", "pytest", "-q", "--tb=short", "-p", "no:cacheprovider", "--color=no", target],
        capture_output=True, text=True)
    print(result.stdout.strip())

run_pytest("lesson_files/test_tips.py")
...                                                                      [100%]
3 passed in 0.01s
with open("lesson_files/test_tips.py", "a") as f:
    f.write("""
def test_split_between_three():
    assert split_with_tip(100, 3) == 36.6
""")
run_pytest("lesson_files/test_tips.py")
...F                                                                     [100%]
=================================== FAILURES ===================================
___________________________ test_split_between_three ___________________________
lesson_files/test_tips.py:14: in test_split_between_three
    assert split_with_tip(100, 3) == 36.6
E   assert 36.67 == 36.6
E    +  where 36.67 = split_with_tip(100, 3)
=========================== short test summary info ============================
FAILED lesson_files/test_tips.py::test_split_between_three - assert 36.67 == ...
1 failed, 3 passed in 0.01s
text = Path("lesson_files/test_tips.py").read_text()
text = text.replace("== 36.6", "== 36.67")
Path("lesson_files/test_tips.py").write_text(text)
run_pytest("lesson_files/test_tips.py")
....                                                                     [100%]
4 passed in 0.01s
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)

import pytest
print(0.1 + 0.2 == pytest.approx(0.3))
0.30000000000000004
False
True
Path("lesson_files/test_tips_extra.py").write_text("""
import pytest
from tips import tip_amount, split_with_tip

@pytest.mark.parametrize("bill, percent, expected", [
    (20, 10, 2.0),
    (45, 20, 9.0),
])
def test_tip_table(bill, percent, expected):
    assert tip_amount(bill, percent) == pytest.approx(expected)

def test_zero_people_crashes():
    with pytest.raises(ZeroDivisionError):
        split_with_tip(50, 0)
""")
run_pytest("lesson_files/test_tips_extra.py")
...                                                                      [100%]
3 passed in 0.01s

@pytest.mark.parametrize runs one test with many inputs. pytest.raises checks that an error happens. In a terminal you'd simply type: python -m pytest

Type Hints#

Type hints are labels on storage boxes. Python doesn't check them, but every reader (and your editor) knows what belongs inside.

def bmi(weight_kg: float, height_m: float) -> float:
    return round(weight_kg / height_m ** 2, 1)

print(bmi(70, 1.75))
print(bmi.__annotations__)
22.9
{'weight_kg': <class 'float'>, 'height_m': <class 'float'>, 'return': <class 'float'>}
def double(x: int) -> int:
    return x * 2

print(double(21))
print(double("ha"))
42
haha
def total_calories(meals: dict[str, int]) -> int:
    return sum(meals.values())

print(total_calories({"porridge": 350, "salad": 420, "curry": 680}))
1450
members = {"amara": "premium", "jonas": "basic"}

def find_plan(name: str) -> str | None:
    return members.get(name.lower())

print(find_plan("Amara"))
print(find_plan("Zed"))
premium
None

Quick check#

double("ha") ran without complaint. So what are type hints actually for?

print("1. Humans: you see what goes in and what comes out at a glance.")
print("2. Editors: autocomplete, and warnings before you even run the code.")
print("3. Checkers like mypy can scan a whole project for type mistakes.")
1. Humans: you see what goes in and what comes out at a glance.
2. Editors: autocomplete, and warnings before you even run the code.
3. Checkers like mypy can scan a whole project for type mistakes.

Clean Code: PEP 8, Names and Docstrings#

Code is read far more often than it's written. Write for the tired person reading it at midnight (often future you). PEP 8 is Python's style guide:

  • snake_case for variables and functions, PascalCase for classes, UPPER_CASE for constants
  • 4 spaces per indent, spaces around = and operators, lines under about 79 characters
def CalcP(p,q,D=0):return p*q-D
x=CalcP(3.5,4,2)
print(x)
12.0
def calculate_price(unit_price, quantity, discount=0):
    return unit_price * quantity - discount

order_price = calculate_price(3.5, 4, discount=2)
print(order_price)
12.0
INTENSITY_RATES = {"easy": 4, "moderate": 7, "hard": 11}

def calories_burned(minutes: int, intensity: str = "moderate") -> int:
    """Estimate the calories burned in a workout.

    minutes: length of the workout
    intensity: "easy", "moderate" or "hard"
    """
    return round(minutes * INTENSITY_RATES[intensity])

print(calories_burned(30), calories_burned(45, "hard"))
210 495
help(calories_burned)
Help on function calories_burned in module __main__:

calories_burned(minutes: int, intensity: str = 'moderate') -> int
    Estimate the calories burned in a workout.

    minutes: length of the workout
    intensity: "easy", "moderate" or "hard"

Pro tip: tools like black and ruff tidy your style automatically: pip install ruff, then ruff check . in a terminal.

Your turn#

  1. Rename def GetAvg(L): and its variables in PEP 8 style, then give it a one-line docstring.
  2. Add type hints to dog_food_grams and check them with dog_food_grams.__annotations__.

Refactoring a Messy Function#

Refactoring is tidying the kitchen while every dish tastes exactly the same. Tests prove the dishes didn't change.

def report(d):
    t = 0
    f = 0
    for x in d:
        t = t + x[1]
        if x[2] == "food":
            f = f + x[1]
    if t > 500:
        s = "OVER BUDGET"
    else:
        s = "ok"
    return "Total: " + str(round(t, 2)) + " | Food: " + str(round(f, 2)) + " | " + s

trip = [("hotel", 320.0, "stay"), ("pizza", 18.5, "food"),
        ("museum", 22.0, "fun"), ("tapas", 41.75, "food"),
        ("train", 64.0, "travel"), ("gelato", 7.2, "food")]
print(report(trip))
Total: 473.45 | Food: 67.45 | ok
test_trips = [trip, trip + [("flight", 120.0, "travel")], []]
snapshots = [report(t) for t in test_trips]
for line in snapshots:
    print(line)
Total: 473.45 | Food: 67.45 | ok
Total: 593.45 | Food: 67.45 | OVER BUDGET
Total: 0 | Food: 0 | ok
BUDGET_LIMIT = 500

def total_spent(expenses, category=None):
    """Add up expenses, optionally only one category."""
    return sum(amount for name, amount, kind in expenses
               if category is None or kind == category)

def budget_status(total):
    """Say whether a total is within the budget."""
    return "OVER BUDGET" if total > BUDGET_LIMIT else "ok"

def trip_report(expenses: list[tuple[str, float, str]]) -> str:
    """One-line summary of a trip's spending."""
    total = total_spent(expenses)
    food = total_spent(expenses, "food")
    return f"Total: {round(total, 2)} | Food: {round(food, 2)} | {budget_status(total)}"

print(trip_report(trip))
Total: 473.45 | Food: 67.45 | ok
for expenses, expected in zip(test_trips, snapshots):
    assert trip_report(expenses) == expected, trip_report(expenses)
print("Refactor is safe: all", len(snapshots), "outputs identical")
Refactor is safe: all 3 outputs identical

Capstone: The Gym Membership Fee Calculator#

Members say their bills are wrong. Our job: reproduce the bugs, find them, fix them, then lock the fix in with tests.

  • Monthly price: basic $25, standard $35, premium $50
  • Students get 20% off; members aged 65+ get 15% off (only the bigger discount applies)
  • $30 joining fee, waived on contracts of 12 months or more
PRICES = {"basic": 25, "standard": 35, "premium": 50}

def membership_fee(plan, months, age, student):
    monthly = PRICES[plan]
    if student:
        monthly = monthly - 20
    if age > 65:
        monthly = monthly * 0.15
    total = monthly * months
    if months > 12:
        total = total + 30
    return total

print("Basic, 1 month, age 30:", membership_fee("basic", 1, 30, False))
Basic, 1 month, age 30: 25
membership_fee("Premium", 6, 40, False)
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[70], line 1
----> 1 membership_fee("Premium", 6, 40, False)

Cell In[69], line 4, in membership_fee(plan, months, age, student)
      3 def membership_fee(plan, months, age, student):
----> 4     monthly = PRICES[plan]
      5     if student:
      6         monthly = monthly - 20
      7     if age > 65:

KeyError: 'Premium'
typed_plan = " Premium "
clean_plan = typed_plan.strip().lower()
print(repr(typed_plan), "->", repr(clean_plan))
print(membership_fee(clean_plan, 6, 40, False))
' Premium ' -> 'premium'
300

Step 1: work out the right answers by hand#

fee_cases = [
    (("basic", 1, 30, False), 55.0),      # 25 + 30 joining
    (("basic", 12, 30, False), 300.0),    # 25 x 12, fee waived
    (("standard", 6, 21, True), 198.0),   # 28 x 6 + 30
    (("premium", 12, 70, False), 510.0),  # 42.50 x 12
    (("premium", 3, 66, True), 150.0),    # student 20% wins: 40 x 3 + 30
    (("basic", 12, 65, False), 255.0),    # 65 counts as 65+
    (("Premium ", 12, 40, False), 600.0), # messy typing still works
]
print(len(fee_cases), "hand-checked cases")
7 hand-checked cases
def check_fees(fee_function):
    passed = 0
    for args, expected in fee_cases:
        try:
            got = round(fee_function(*args), 2)
        except Exception as error:
            got = type(error).__name__
        ok = got == expected
        passed += ok
        print(f"{'PASS' if ok else 'FAIL'}  {str(args):<32} want {expected:>6}  got {got}")
    print(f"{passed}/{len(fee_cases)} passed")

check_fees(membership_fee)
FAIL  ('basic', 1, 30, False)          want   55.0  got 25
PASS  ('basic', 12, 30, False)         want  300.0  got 300
FAIL  ('standard', 6, 21, True)        want  198.0  got 90
FAIL  ('premium', 12, 70, False)       want  510.0  got 90.0
FAIL  ('premium', 3, 66, True)         want  150.0  got 13.5
FAIL  ('basic', 12, 65, False)         want  255.0  got 300
FAIL  ('Premium ', 12, 40, False)      want  600.0  got KeyError
1/7 passed

Step 2: shine a light on the failures#

plan, months, age, student = "standard", 6, 21, True
monthly = PRICES[plan]
monthly = monthly - 20
print(f"after student discount {monthly=}  (want 28.0: 20% off, not $20 off)")
after student discount monthly=15  (want 28.0: 20% off, not $20 off)
monthly = PRICES["premium"]
print(f"{70 > 65=}  {65 > 65=}  (65 should count)")
print(f"{monthly * 0.15=}  (that's the discount, not the price)")
print(f"{monthly * 0.85=}  (the price after 15% off)")
70 > 65=True  65 > 65=False  (65 should count)
monthly * 0.15=7.5  (that's the discount, not the price)
monthly * 0.85=42.5  (the price after 15% off)

Step 3: fix it in a module, clean and commented#

Path("lesson_files/gym_fees.py").write_text("""
PRICES = {"basic": 25, "standard": 35, "premium": 50}
STUDENT_DISCOUNT = 0.20
SENIOR_DISCOUNT = 0.15
SENIOR_AGE = 65
JOINING_FEE = 30
FEE_FREE_MONTHS = 12


def membership_fee(plan: str, months: int, age: int, student: bool) -> float:
    plan = plan.strip().lower()
    if plan not in PRICES:
        raise ValueError(f"Unknown plan '{plan}'. Choose from: {', '.join(PRICES)}")
    discount = 0.0
    if student:
        discount = STUDENT_DISCOUNT
    if age >= SENIOR_AGE:
        discount = max(discount, SENIOR_DISCOUNT)
    monthly = PRICES[plan] * (1 - discount)
    total = monthly * months
    if months < FEE_FREE_MONTHS:
        total += JOINING_FEE
    return round(total, 2)
""")
print("gym_fees.py saved:", len(Path("lesson_files/gym_fees.py").read_text().splitlines()), "lines")
gym_fees.py saved: 23 lines
sys.path.insert(0, "lesson_files")
import gym_fees

check_fees(gym_fees.membership_fee)
PASS  ('basic', 1, 30, False)          want   55.0  got 55.0
PASS  ('basic', 12, 30, False)         want  300.0  got 300.0
PASS  ('standard', 6, 21, True)        want  198.0  got 198.0
PASS  ('premium', 12, 70, False)       want  510.0  got 510.0
PASS  ('premium', 3, 66, True)         want  150.0  got 150.0
PASS  ('basic', 12, 65, False)         want  255.0  got 255.0
PASS  ('Premium ', 12, 40, False)      want  600.0  got 600.0
7/7 passed
try:
    gym_fees.membership_fee("platinum", 6, 30, False)
except ValueError as error:
    print("Refused:", error)
Refused: Unknown plan 'platinum'. Choose from: basic, standard, premium

Step 4: lock it in with pytest#

Path("lesson_files/test_gym_fees.py").write_text("""
import pytest
from gym_fees import membership_fee

@pytest.mark.parametrize("plan, months, age, student, expected", [
    ("basic", 1, 30, False, 55.0),
    ("basic", 12, 30, False, 300.0),
    ("standard", 6, 21, True, 198.0),
    ("premium", 12, 70, False, 510.0),
    ("premium", 3, 66, True, 150.0),
    ("basic", 12, 65, False, 255.0),
    ("Premium ", 12, 40, False, 600.0),
])
def test_fees(plan, months, age, student, expected):
    assert membership_fee(plan, months, age, student) == pytest.approx(expected)

def test_unknown_plan_is_refused():
    with pytest.raises(ValueError):
        membership_fee("platinum", 6, 30, False)
""")
run_pytest("lesson_files/test_gym_fees.py")
........                                                                 [100%]
8 passed in 0.01s
print(f"{'Plan':<10}{'1 month':>11}{'6 months':>11}{'12 months':>11}")
for plan in gym_fees.PRICES:
    fees = [f"${gym_fees.membership_fee(plan, m, 30, False):.2f}" for m in (1, 6, 12)]
    print(f"{plan:<10}" + "".join(f"{fee:>11}" for fee in fees))
Plan          1 month   6 months  12 months
basic          $55.00    $180.00    $300.00
standard       $65.00    $240.00    $420.00
premium        $80.00    $330.00    $600.00
plan = input("Which plan (basic/standard/premium)? ")
age = int(input("Your age? "))
student = input("Student? (y/n) ").strip().lower() == "y"
months = int(input("How many months? "))

fee = gym_fees.membership_fee(plan, months, age, student)
print("-" * 32)
print(f"Plan:      {plan.strip().title()}")
print(f"Contract:  {months} months")
print(f"Total:     ${fee:.2f}")
Which plan (basic/standard/premium)?  Premium 
Your age? 67
Student? (y/n) n
How many months? 12
--------------------------------
Plan:      Premium
Contract:  12 months
Total:     $510.00

Recap#

  • Read tracebacks from the bottom up: error type, crashing line, then the chain of calls
  • NameError, TypeError, ValueError, IndexError, KeyError, AttributeError, ZeroDivisionError, IndentationError
  • Print debugging with f"{name=}" and repr() to see hidden whitespace
  • logging levels and handlers: turn detail up or down without deleting lines
  • assert for your own assumptions, raise ValueError for bad input
  • Tests: plain test_ functions, then pytest with parametrize, approx and raises
  • Type hints, PEP 8 names, constants and docstrings make code easy to read
  • Refactor in small steps, with saved outputs proving nothing changed

Try it yourself#

  1. Add a "family" plan at $80 a month for up to 4 people, plus tests for it.
  2. Give membership_fee a log that records each discount applied at DEBUG level.
  3. Add a 10% loyalty discount for contracts of 24 months or more, written test-first: write the failing test, then make it pass.

What's next#

Lesson 6: Text Wrangling: f-strings, Regular Expressions and Unicode. You'll format perfect reports, hunt for patterns like emails and phone numbers, and tame messy text from the real world.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.