Skip to content

Milestone Project 2: Persistent Expense Tracker

View project code on GitHub Download slides (PDF) Get the Video Course

Your second capstone, and it does not start from a blank folder. You take the Milestone Project 1 tracker you already built and upgrade it: store expenses as objects, save them to a file so they survive between runs, and handle errors without crashing. This project uses ideas from Sections 01-11 and 13, plus two pieces of Section 12: the __main__ guard in Step-09, and a virtual environment in the optional bonus step.

The upgrade from Milestone 1 to Milestone 2: a dictionary with three keys becomes an Expense object (Section 13); main() holds expenses = an empty list becomes an ExpenseBook; reading the "amount" key becomes expense.amount; compute_total(expenses) becomes book.compute_total(); compute_by_category(expenses) becomes book.compute_by_category(); the list comprehension moves into book.filter(); type hints are added (Section 08); book.save() and book.load() keep data in data/expenses.json (Section 10); try / except turns a broken file into a warning (Section 09); created_at on every expense is new (Section 11); and in the bonus step, where Milestone 1 needed nothing but Python, a virtual environment and pip install rich make the list print as a real table (Section 12)

Step-00 slide 1 of 2: what you will build, Steps 01 to 06. Eleven steps build the Expense Tracker 2.0, and Steps 01 to 06 turn your Milestone 1 code into objects and a book. Step-01 copy Milestone 1 in, with main() and show_menu(), so your tracker runs as 2.0. Step-02 type hints everywhere, with amount_text as str, an arrow to None and a list of Expense objects, across all six files with no logic change. Step-03 one expense is an object, with class Expense, __init__() and expense.amount, so dictionary keys become object attributes. Step-04 ExpenseBook holds the list, with class ExpenseBook and store_expense(), and the five actions add_expense(), list_expenses(), show_total(), show_by_category() and filter_by_category(), because every action takes the ExpenseBook. Step-05 helpers become methods, with compute_total(), compute_by_category() and filter() as methods in the ExpenseBook class, the better practice. Step-06 stamp every expense, with created_at, datetime.now() and __repr__(), to log each expense with a time stamp

Step-00 slide 2 of 2: what you will build, Steps 07 to 11. These steps give the tracker a file, a guard, a full run, and the optional rich bonus. Step-07 save to JSON, with save(), json.dumps() and write_text(), so choice 6 becomes Save to a JSON file and quit. Step-08 load it back, with load(), json.loads() and read_text(), so your data survives a restart, loaded on a fresh run. Step-09 do not crash, with try and except, json.JSONDecodeError and the __main__ guard, to handle a broken file and its errors with a warning and no crash. Step-10 run it all, with all six choices and data/expenses.json, the finished Expense Tracker run end to end. Step-11 the rich bonus, marked in amber because it is optional, with .venv, pip install rich, make_table() and Console().print(): a virtual environment, an installed package and a rich table

How your data survives a restart

Project 2: which folder is which. Five folders in milestone-projects/milestone-project-2/, and one of them is yours to type in. 01-orig-build-incremental is the answer: the finished tracker for Steps 01 to 10, your answer key. 02-orig-build-incremental-with-rich is the bonus answer: the same tracker after Step-11, with the rich table, your bonus answer key. 03-orig-warmup is optional: six short drills, one new skill each, an optional extra. 04-livecode-build-incremental is where you build: it starts as your Milestone 1 code, and you build Steps 01 to 10 in it. 05-livecode-build-incremental-with-rich is the bonus folder: the finished Step-11 code to read and run, and you make the virtual environment there. In short, everything you type goes in 04-livecode-build-incremental, and you compare it with 01 when you are done

Step-00: What You Will Build

This page grows your Milestone 1 tracker into Milestone 2, one stage at a time. Step-00 is the assignment. Step-01 to Step-09 are the nine stages: each one edits one or more files, and the app runs at the end of every step. Step-10 runs the finished program from end to end.

What you build

The same menu as Milestone 1, plus saved data:

  1. Add expense, stored as an Expense object.
  2. List expenses in an aligned table.
  3. Total spent, the sum of all amounts.
  4. Spending by category: the total per category.
  5. Show one category, only that category's expenses.
  6. Save and quit: write the expenses to data/expenses.json, in a data/ folder next to your code.

On start, it loads data/expenses.json if it exists, so your data from the last run is back. The file always sits next to the program file, not in the folder you happen to run from.

What you practice (Sections 01-11 and 13)

  • Classes (Section 13) for the data model: an Expense with __init__ and __repr__
  • Files, JSON and pathlib (Section 10) for saving and loading
  • try/except error handling (Section 09) and type hints (Section 08)
  • The __main__ guard (Section 12), and a virtual environment with pip install in the optional bonus step
  • datetime from the standard library (Section 11)

Where you start

Build in this project's 04-livecode-build-incremental/ folder. It already holds the six Milestone 1 files, with the comments and the long docstring at the top of each file taken out, so only the code and the short docstrings under the def lines are left, and it runs exactly like Milestone 1. Would you rather start from the Milestone 1 code itself? Copy the six .py files from ../milestone-project-1/build-incremental/ over them, then delete the long docstring at the top of each one: only the block above the first import or def goes, and the short docstring under each def line stays. Do not type into this project's 01-orig-build-incremental/: it holds the finished Milestone 2 files you compare against.

What carries over from Milestone 1

You are not rewriting the tracker. You are upgrading it, and most of your Milestone 1 code comes with you:

Your Milestone 1 file What happens to it here
f1_is_valid_amount.py Carried over. The function body does not change at all; the signature gains type hints, and the docstring at the top is reworded.
f2_format_row.py Carried over, changed inside. The signature gains type hints, and an expense is an object now, so the file imports Expense and three square brackets become dots. The f-string stays exactly as it was.
f3_compute_total.py Moves into the class as the method ExpenseBook.compute_total(), same name, then the file goes away.
f4_compute_by_category.py Moves into the class as the method ExpenseBook.compute_by_category(), same name, then the file goes away.
f5_actions.py Edited. Every action takes the book instead of a bare list.
main.py Edited. It holds a book, loads on start, and saves when you quit.
New here: expense.py and expense_book.py

That is why this project has an f1 and an f2 but no f3 or f4. The gap is where your two helper files used to be, before their code moved into the class.

Rules

  • Model the data with classes: an Expense class (with __init__ and __repr__) and an ExpenseBook.
  • Keep Expense and ExpenseBook free of input() and print(). They hold data and logic only. All input and printing lives in the menu actions and in main.py. (This separation is what lets a web app reuse the same logic later.)
  • Save and load with pathlib and json. Each expense carries a created_at time stamp (datetime).
  • Handle a MISSING file (first run, so start empty) and a BROKEN file (catch json.JSONDecodeError, TypeError and KeyError, print a warning, and start empty).
  • Validate the amount WITHOUT try/except (string methods), like Milestone 1.
  • Type-hint your functions and methods.
  • The table uses the same width and alignment f-string trick you already used in Milestone 1 (:<20 left-aligns in 20 columns, :<14 in 14, :>10 right-aligns in 10, with a single space between the columns). No section teaches those specs; you carry them over from Milestone 1 rather than learn them here.

Sample run

The menu prints again before every Choose 1-6: prompt. Here is a short session with one expense:

The menu (shown before each choice)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Sample run
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]
Choose 1-6: 4
food                 3.50
Choose 1-6: 6
Saved. Goodbye!

Run it again and choose 2: your Coffee is still there, loaded from the file.

How to run it

Open a terminal in the folder you are working in and run the program:

Run the app
python3 main.py

The menu appears. Type a number and press Enter. Once you finish Step-08, choose 6 to save and quit, then run it again: your expenses are still there.

How to build it

The finished program has five layers. Only the actions and the driver talk to a person:

Layer Files Its job
Data expense.py the Expense class: one expense
Logic and storage expense_book.py ExpenseBook: store_expense(), compute_total(), compute_by_category(), filter(), save(), load()
Pure helpers f1_is_valid_amount.py, f2_format_row.py check an amount, format a row
Actions f5_actions.py what each menu choice does, with input() and print()
Menu driver main.py load, show the menu, call the action, save

Nine stages, one step each. Each step changes one or more files, and the app runs at the end of every one:

Step Stage What you change Then the app
Step-01 0 start from the Milestone 1 files in 04-livecode-build-incremental/, retitle the menu runs exactly as before
Step-02 1 add type hints to all six files prints the same output
Step-03 2 add expense.py: an expense becomes an Expense object prints the same output
Step-04 3 add expense_book.py: ExpenseBook holds the list prints the same output
Step-05 4 move compute_total and compute_by_category into the class, add filter(), delete their files prints the same output
Step-06 5 a created_at time stamp and __repr__ on every expense prints the same output
Step-07 6 save() with json and pathlib; choice 6 saves writes data/expenses.json
Step-08 7 load() on start keeps your data after a restart
Step-09 8 try / except around the load, then the __main__ guard warns instead of crashing on a broken file
Step-10 - nothing new: run it all, every menu choice, a restart, and a broken file everything
Step-11 - optional, in its own folder: a venv, pip install rich, and tables on screen prints a real table

Steps 02 to 06 change nothing on screen, and that is the lesson: a refactor changes the shape of a program, not what it does. Step-07 is the first step that changes the screen: choice 6 becomes Save and quit.

Build it yourself first

Each step below shows what to change. Try the change on your own before you read the code. If you want some practice first, the optional drills in 03-orig-warmup/ practice each new skill on its own.

Step-01: main.py: Copy Milestone 1 In

Step-01 slide: main.py in the center, the concepts used on the left (Run a program: python3; Output: print()) and what we build on the right (04-livecode-build-incremental/; Menu title; Second run)

File: main.py

What you build in this step. Your starting point: the six Milestone 1 files in 04-livecode-build-incremental/, with one line changed so the menu says 2.0. Nothing else is new yet, and that is the point: the program you are about to improve runs before the real changes begin.

Concepts used:

  • Run a program: python3, run your Milestone 1 files as they are (Section 02)
  • Output: print(), the retitled menu line (Section 02)

Before you start. Build in 04-livecode-build-incremental/. It already holds the six Milestone 1 files without comments and without the long docstring at the top of each file, so you start from clean code. Would you rather start from the Milestone 1 code itself? Copy the six .py files from ../milestone-project-1/build-incremental/ over the ones in it, then delete the long docstring at the top of each file: only the block above the first import or def goes, and the short docstring under each def line stays. Those top blocks are long, you scroll past them every time you type, and they describe the Milestone 1 design, so parts of them are not true here any more. When a later step edits a comment or the docstring at the top of a file, skip that part: the code changes are what matter. The finished files in 01-orig-build-incremental/ still have their comments and longer docstrings, so they look longer than yours, and those docstrings are the up-to-date ones for Milestone 2. The code is the same.

Start the build
cd 04-livecode-build-incremental

Write it. In main.py, one line changes: the menu title in show_menu(). Here is the whole function, before and after:

main.py (before: Milestone 1 title)
def show_menu():
    print()
    print("==== Expense Tracker ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")
main.py (after: the 2.0 title)
def show_menu():
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")

What changed.

main.py (what changed)
     print()
-    print("==== Expense Tracker ====")
+    print("==== Expense Tracker 2.0 ====")
     print("1) Add expense")
  • This is the only change in this step. Every other line is your Milestone 1 code.

Run it. Add an expense, list it, and quit:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!

Then run it again and list the expenses:

Run it again (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 2
No expenses yet.

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • The expense is gone on the second run. Milestone 1 keeps expenses only in memory, so they disappear when the program ends. Saving them to a file is what this project adds, in Step-07 and Step-08.

Project files for this step: main.py (the finished version) | the Milestone 1 build files

Step-02: f1_is_valid_amount.py: Type Hints on Every File

Step-02 slide: f1_is_valid_amount.py in the center, the concepts used on the left (Type hints: parameters and ->; Type hints: list(...), dict(...); Type hints: not enforced; Type hints: -> None) and what we build on the right (is_valid_amount(); format_row(); compute_total(); compute_by_category(); Five actions; show_menu() and main(); Choice 3 output)

File: f1_is_valid_amount.py, plus type hints in the other five files

What are type hints? A type hint is a short label that says what kind of value something holds. On a function you hint each parameter and what it gives back, so def is_valid_amount(amount_text: str) -> bool: reads as "pass me text, get back True or False". On a variable you hint what it will hold, which matters most for an empty container, because [] on its own says nothing about what goes into it: expenses: list[Expense] = [] says this list holds Expense objects. Python never checks a hint, and the program runs exactly the same without it. Hints are for you and your editor: the code describes itself, and your editor can offer expense.amount while you type. You built both kinds in Section 08: Type Hints.

What you build in this step. Type hints on every function in all six files, and on the two empty containers, totals and expenses. No logic changes, and the output stays exactly the same.

Concepts used:

  • Type hints: parameters and ->, hint every parameter and return value (Section 08)
  • Type hints: list[...], dict[...], list[dict] and dict[str, float] (Section 08)
  • Type hints: not enforced, Python does not check them when the program runs (Section 08)
  • Type hints: -> None, marks a function that returns nothing (New in this project)

2.1 f1_is_valid_amount.py: Hint the Amount Check

Write it. Change only the def line. Every line below it stays your Milestone 1 code. Here is the whole file, before and after:

f1_is_valid_amount.py (before: no type hints)
def is_valid_amount(amount_text):
    """True if amount_text is a positive number like '12' or '12.50' (rejects 0 and negatives)."""
    amount_text = amount_text.strip()
    decimal_parts = amount_text.split(".")
    if len(decimal_parts) == 1:
        is_valid_format = decimal_parts[0].isdecimal()
    elif len(decimal_parts) == 2:
        is_valid_format = (
            decimal_parts[0].isdecimal() and
            decimal_parts[1].isdecimal()
        )
    else:
        is_valid_format = False
    if not is_valid_format:
        return False
    return float(amount_text) > 0
f1_is_valid_amount.py (after: with type hints)
def is_valid_amount(amount_text: str) -> bool:
    """True if amount_text is a positive number like '12' or '12.50' (rejects 0 and negatives)."""
    amount_text = amount_text.strip()
    decimal_parts = amount_text.split(".")
    if len(decimal_parts) == 1:
        is_valid_format = decimal_parts[0].isdecimal()
    elif len(decimal_parts) == 2:
        is_valid_format = (
            decimal_parts[0].isdecimal() and
            decimal_parts[1].isdecimal()
        )
    else:
        is_valid_format = False
    if not is_valid_format:
        return False
    return float(amount_text) > 0

What changed.

f1_is_valid_amount.py (what changed)
-def is_valid_amount(amount_text):
+def is_valid_amount(amount_text: str) -> bool:
     """True if amount_text is a positive number like '12' or '12.50' (rejects 0 and negatives)."""
  • amount_text: str says the parameter is text. -> bool says the function returns True or False.

2.2 f2_format_row.py: Hint the Row Formatter

Change the def line. Here is the whole file, before and after:

f2_format_row.py (before: no type hints)
def format_row(expense):
    """Return one aligned table row for an expense."""
    desc = expense["description"]
    cat = expense["category"]
    amt = expense["amount"]
    return f"{desc:<20} {cat:<14} {amt:>10,.2f}"
f2_format_row.py (after: with type hints)
def format_row(expense: dict) -> str:
    """Return one aligned table row for an expense."""
    desc = expense["description"]
    cat = expense["category"]
    amt = expense["amount"]
    return f"{desc:<20} {cat:<14} {amt:>10,.2f}"

What changed.

f2_format_row.py (what changed)
-def format_row(expense):
+def format_row(expense: dict) -> str:
     """Return one aligned table row for an expense."""
  • One expense is still a dictionary, so the hint is dict. It becomes Expense in Step-03.

2.3 f3_compute_total.py: Hint the Total

Change the def line. Here is the whole file, before and after:

f3_compute_total.py (before: no type hints)
def compute_total(expenses):
    """Return the sum of every expense amount."""
    total = 0.0
    for expense in expenses:
        total += expense["amount"]
    return total
f3_compute_total.py (after: with type hints)
def compute_total(expenses: list[dict]) -> float:
    """Return the sum of every expense amount."""
    total = 0.0
    for expense in expenses:
        total += expense["amount"]
    return total

What changed.

f3_compute_total.py (what changed)
-def compute_total(expenses):
+def compute_total(expenses: list[dict]) -> float:
     """Return the sum of every expense amount."""
  • list[dict] means a list of dictionaries.

2.4 f4_compute_by_category.py: Hint the Totals Dictionary

Two lines change: the def line, and the line that creates the empty dictionary. Here is the whole file, before and after:

f4_compute_by_category.py (before: no type hints)
def compute_by_category(expenses):
    """Return a dict of category -> total amount."""
    totals = {}
    for expense in expenses:
        category = expense["category"]
        totals[category] = totals.get(category, 0.0) + expense["amount"]
    return totals
f4_compute_by_category.py (after: with type hints)
def compute_by_category(expenses: list[dict]) -> dict[str, float]:
    """Return a dict of category -> total amount."""
    totals: dict[str, float] = {}
    for expense in expenses:
        category = expense["category"]
        totals[category] = totals.get(category, 0.0) + expense["amount"]
    return totals

What changed.

f4_compute_by_category.py (what changed)
-def compute_by_category(expenses):
+def compute_by_category(expenses: list[dict]) -> dict[str, float]:
     """Return a dict of category -> total amount."""
-    totals = {}
+    totals: dict[str, float] = {}
     for expense in expenses:
  • dict[str, float] means a dictionary with text keys and decimal values.

2.5 f5_actions.py: Hint the Five Actions

Change the five def lines. Here is the whole file, before and after:

f5_actions.py (before: no type hints)
from f1_is_valid_amount import is_valid_amount
from f2_format_row import format_row
from f3_compute_total import compute_total
from f4_compute_by_category import compute_by_category


def add_expense(expenses):
    """Prompt the user for a new expense and add it to the list."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = {
        "description": description,
        "amount": amount,
        "category": category,
    }

    expenses.append(expense)

    print(f"Added: {description} ({amount:,.2f}) [{category}]")


def list_expenses(expenses):
    """Display all recorded expenses."""
    if not expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in expenses:
        print(format_row(expense))


def show_total(expenses):
    """Display the total amount spent."""
    print(f"Total spent: {compute_total(expenses):,.2f}")


def show_by_category(expenses):
    """Display the total spent in each category."""
    totals = compute_by_category(expenses)

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")


def filter_by_category(expenses):
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in expenses
        if expense["category"] == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))
f5_actions.py (after: with type hints)
from f1_is_valid_amount import is_valid_amount
from f2_format_row import format_row
from f3_compute_total import compute_total
from f4_compute_by_category import compute_by_category


def add_expense(expenses: list[dict]) -> None:
    """Prompt the user for a new expense and add it to the list."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = {
        "description": description,
        "amount": amount,
        "category": category,
    }

    expenses.append(expense)

    print(f"Added: {description} ({amount:,.2f}) [{category}]")


def list_expenses(expenses: list[dict]) -> None:
    """Display all recorded expenses."""
    if not expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in expenses:
        print(format_row(expense))


def show_total(expenses: list[dict]) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {compute_total(expenses):,.2f}")


def show_by_category(expenses: list[dict]) -> None:
    """Display the total spent in each category."""
    totals = compute_by_category(expenses)

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")


def filter_by_category(expenses: list[dict]) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in expenses
        if expense["category"] == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))

What changed.

f5_actions.py (what changed)
-def add_expense(expenses):
+def add_expense(expenses: list[dict]) -> None:
     """Prompt the user for a new expense and add it to the list."""
 ...
-def list_expenses(expenses):
+def list_expenses(expenses: list[dict]) -> None:
     """Display all recorded expenses."""
 ...
-def show_total(expenses):
+def show_total(expenses: list[dict]) -> None:
     """Display the total amount spent."""
 ...
-def show_by_category(expenses):
+def show_by_category(expenses: list[dict]) -> None:
     """Display the total spent in each category."""
 ...
-def filter_by_category(expenses):
+def filter_by_category(expenses: list[dict]) -> None:
     """Display expenses that belong to one category."""
  • -> None says a function returns nothing: it only prints or changes data.

2.6 main.py: Hint the Menu and the Loop

Change the two def lines, and give the empty list in main() a hint. Here is the whole file, before and after:

main.py (before: no type hints)
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu():
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main():
    expenses = []
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_total(expenses)
        elif choice == "4":
            show_by_category(expenses)
        elif choice == "5":
            filter_by_category(expenses)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()
main.py (after: with type hints)
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main() -> None:
    expenses: list[dict] = []
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_total(expenses)
        elif choice == "4":
            show_by_category(expenses)
        elif choice == "5":
            filter_by_category(expenses)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()

What changed.

main.py (what changed)
-def show_menu():
+def show_menu() -> None:
     print()
 ...
-def main():
-    expenses = []
+def main() -> None:
+    expenses: list[dict] = []
     while True:
  • expenses: list[dict] = []: an empty [] says nothing about what goes inside, so the hint says it: a list of dictionaries. It is the same idea as totals in 2.4. In 3.2 the hint becomes list[Expense], and in Step-04 the book replaces the whole line.
  • Python does not check hints when the program runs. They are for the people who read the code.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • The output is the same as before.

Project files for this step: f1_is_valid_amount.py

Step-03: expense.py: One Expense Becomes an Object

Step-03 slide: one expense dictionary becomes an object. Before, in Milestone 1, one expense is a dictionary with three keys, description, amount and category, each value is read with square brackets and a quoted key, and the list is hinted as a list of dictionaries. After, in Milestone 2, the Expense class in the new file expense.py stores the same three values as attributes, each value is read with a dot, as in expense.amount, and the list is hinted as a list of Expense objects in main.py and in every function that takes it. Step-03 touches six files: expense.py, which is new, main.py, f5_actions.py, f2_format_row.py, f3_compute_total.py and f4_compute_by_category.py

File: expense.py, plus dot edits and Expense hints in f2_format_row.py, f3_compute_total.py, f4_compute_by_category.py and f5_actions.py, and the list hint in main.py

What you build in this step. An Expense class, so one expense becomes an object with three attributes instead of a dictionary with three keys. Every place that read a key with square brackets now reads an attribute with a dot.

Concepts used:

  • Modules: from ... import, from expense import Expense (Section 07)
  • Type hints: parameters and ->, description: str and -> None on __init__ (Section 08)
  • Type hints: list[...], dict[...], list[Expense] in f3, f4, f5_actions.py and main.py: the hints follow the objects (Section 08)
  • Debugging: read the traceback, the TypeError that shows where to fix next (Section 09)
  • Classes: a blueprint for objects, the Expense class (Section 13)
  • Classes: __init__ and self, store the description, amount and category (Section 13)
  • Classes: attributes with a dot, expense.description in place of expense["description"] (Section 13)

3.1 expense.py: One Expense Becomes an Object

Step-03 slide: expense.py in the center, the concepts used on the left (Classes: a blueprint for objects; Classes: __init__ and self; Type hints: parameters and ->) and what we build on the right (expense.py; class Expense; __init__(); Read with a dot)

Write it. Create expense.py:

expense.py
class Expense:
    """One expense: what it was, how much, and which category."""

    def __init__(self, description: str, amount: float, category: str) -> None:
        self.description = description
        self.amount = amount
        self.category = category
  • class Expense: describes one expense.
  • __init__ runs when you create an object, and self.description = description stores each value on that object.
  • You add a fourth attribute in Step-06.

3.2 main.py: The List Holds Expense Objects

Write it. From this step on, the list in main() holds Expense objects, so its hint says so. Import the class at the top, above from f5_actions import (, and change the hint. Here is the whole file, before and after:

main.py (before: the list hint says dict)
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main() -> None:
    expenses: list[dict] = []
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_total(expenses)
        elif choice == "4":
            show_by_category(expenses)
        elif choice == "5":
            filter_by_category(expenses)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()
main.py (after: the list hint says Expense)
from expense import Expense
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main() -> None:
    expenses: list[Expense] = []
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_total(expenses)
        elif choice == "4":
            show_by_category(expenses)
        elif choice == "5":
            filter_by_category(expenses)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()

What changed.

main.py (what changed)
+from expense import Expense
 from f5_actions import (
 ...
 def main() -> None:
-    expenses: list[dict] = []
+    expenses: list[Expense] = []
     while True:
  • Python runs either way. The hint tells your editor, and anyone reading the code, what the list holds. The next sub-step starts putting Expense objects into it.

3.3 f5_actions.py: Build an Expense in add_expense()

Step-03 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Classes: attributes with a dot; Debugging: read the traceback) and what we build on the right (f5_actions.py; TypeError; f2_format_row.py; f3_compute_total.py; f4_compute_by_category.py; f5_actions.py)

Wire it in. At the top of f5_actions.py, import the class:

f5_actions.py (the new import)
from expense import Expense

Every action now works with a list of Expense objects, so the hint in all five def lines says so:

f5_actions.py (the five def lines)
-def add_expense(expenses: list[dict]) -> None:
+def add_expense(expenses: list[Expense]) -> None:
-def list_expenses(expenses: list[dict]) -> None:
+def list_expenses(expenses: list[Expense]) -> None:
-def show_total(expenses: list[dict]) -> None:
+def show_total(expenses: list[Expense]) -> None:
-def show_by_category(expenses: list[dict]) -> None:
+def show_by_category(expenses: list[Expense]) -> None:
-def filter_by_category(expenses: list[dict]) -> None:
+def filter_by_category(expenses: list[Expense]) -> None:
  • A hint that says dict while the list holds Expense objects would be wrong, even though Python runs it. With list[Expense], your editor also suggests .description, .amount and .category as you type.

Inside add_expense(), the dictionary becomes an Expense object. Here is the whole function, before and after:

f5_actions.py (before: add_expense builds a dictionary)
def add_expense(expenses: list[dict]) -> None:
    """Prompt the user for a new expense and add it to the list."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = {
        "description": description,
        "amount": amount,
        "category": category,
    }

    expenses.append(expense)

    print(f"Added: {description} ({amount:,.2f}) [{category}]")
f5_actions.py (after: add_expense builds an Expense)
def add_expense(expenses: list[Expense]) -> None:
    """Prompt the user for a new expense and add it to the list."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = Expense(description, amount, category)

    expenses.append(expense)

    print(f"Added: {description} ({amount:,.2f}) [{category}]")

What changed.

f5_actions.py (what changed inside add_expense)
-def add_expense(expenses: list[dict]) -> None:
+def add_expense(expenses: list[Expense]) -> None:
     """Prompt the user for a new expense and add it to the list."""
 ...
-    expense = {
-        "description": description,
-        "amount": amount,
-        "category": category,
-    }
+    expense = Expense(description, amount, category)
  • The next line, expenses.append(expense), stays. The list now holds objects instead of dictionaries.

Run it. Choose 1, add an expense, then choose 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 1 works exactly as before. Its Added: message prints the three values it just read, not the dictionary.

What happens next. Four files still read an expense with square brackets, like expense["amount"], and an object has no keys. Run the app and Python stops at each of those reads, one file at a time. The next four sub-steps fix them in that order:

Sub-step You choose Python stops in You change
3.4 2 List expenses f2_format_row.py three reads, the hint and the import
3.5 3 Total spent f3_compute_total.py the import, the hint and one read
3.6 4 Spending by category f4_compute_by_category.py the import, the hint and two reads
3.7 5 Show one category f5_actions.py one read

Each run starts with an empty list, so add one expense (choice 1) at the start of every run.

3.4 f2_format_row.py: Choice 2 Stops Here, Three Reads Become Dots

Before the fix. Run the app, add an expense, and choose 2. Python stops:

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 30, in main
    list_expenses(expenses)
    ~~~~~~~~~~~~~^^^^^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 44, in list_expenses
    print(format_row(expense))
          ~~~~~~~~~~^^^^^^^^^
  File ".../04-livecode-build-incremental/f2_format_row.py", line 3, in format_row
    desc = expense["description"]
           ~~~~~~~^^^^^^^^^^^^^^^
TypeError: 'Expense' object is not subscriptable
  • Read the traceback from the bottom up. The last line is the error: an Expense object has no keys. The lines above it show where it happened: format_row() in f2_format_row.py, still reading a key with square brackets.
  • Your line numbers can be different, if your file has a few more or fewer lines.

The fix. In f2_format_row.py, the three reads in format_row() become dots, the hint says Expense, and the file imports the class for that hint. Here is the whole file, before and after:

f2_format_row.py (before: reads keys)
def format_row(expense: dict) -> str:
    """Return one aligned table row for an expense."""
    desc = expense["description"]
    cat = expense["category"]
    amt = expense["amount"]
    return f"{desc:<20} {cat:<14} {amt:>10,.2f}"
f2_format_row.py (after: reads attributes)
from expense import Expense


def format_row(expense: Expense) -> str:
    """Return one aligned table row for an expense."""
    desc = expense.description
    cat = expense.category
    amt = expense.amount
    return f"{desc:<20} {cat:<14} {amt:>10,.2f}"

What changed.

f2_format_row.py (what changed)
+from expense import Expense

-def format_row(expense: dict) -> str:
+def format_row(expense: Expense) -> str:

-    desc = expense["description"]
-    cat = expense["category"]
-    amt = expense["amount"]
+    desc = expense.description
+    cat = expense.category
+    amt = expense.amount
  • The f-string on the last line does not change. f2_format_row.py is finished now.

Run it. Run again, add an expense, choose 2, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 2 works now.

3.5 f3_compute_total.py: Choice 3 Stops Here, One Read

Before the fix. Run the app, add an expense, and choose 3. Python stops:

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 32, in main
    show_total(expenses)
    ~~~~~~~~~~^^^^^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 49, in show_total
    print(f"Total spent: {compute_total(expenses):,.2f}")
                          ~~~~~~~~~~~~~^^^^^^^^^^
  File ".../04-livecode-build-incremental/f3_compute_total.py", line 5, in compute_total
    total += expense["amount"]
             ~~~~~~~^^^^^^^^^^
TypeError: 'Expense' object is not subscriptable
  • This time the read that fails is in compute_total() in f3_compute_total.py.

The fix. In f3_compute_total.py, the file imports Expense, the hint says list[Expense], and one read in compute_total() becomes a dot. Here is the whole file, before and after:

f3_compute_total.py (before: reads a key)
def compute_total(expenses: list[dict]) -> float:
    """Return the sum of every expense amount."""
    total = 0.0
    for expense in expenses:
        total += expense["amount"]
    return total
f3_compute_total.py (after: reads an attribute)
from expense import Expense


def compute_total(expenses: list[Expense]) -> float:
    """Return the sum of every expense amount."""
    total = 0.0
    for expense in expenses:
        total += expense.amount
    return total

What changed.

f3_compute_total.py (what changed)
+from expense import Expense

-def compute_total(expenses: list[dict]) -> float:
+def compute_total(expenses: list[Expense]) -> float:

-        total += expense["amount"]
+        total += expense.amount

Run it. Run again, add an expense, choose 3, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 3 works now.

3.6 f4_compute_by_category.py: Choice 4 Stops Here, Two Reads

Before the fix. Run the app, add an expense, and choose 4. Python stops:

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 34, in main
    show_by_category(expenses)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 54, in show_by_category
    totals = compute_by_category(expenses)
  File ".../04-livecode-build-incremental/f4_compute_by_category.py", line 5, in compute_by_category
    category = expense["category"]
               ~~~~~~~^^^^^^^^^^^^
TypeError: 'Expense' object is not subscriptable
  • This time the read that fails is in compute_by_category() in f4_compute_by_category.py.

The fix. In f4_compute_by_category.py, the file imports Expense, the hint says list[Expense], and both reads in compute_by_category() become dots. Here is the whole file, before and after:

f4_compute_by_category.py (before: reads two keys)
def compute_by_category(expenses: list[dict]) -> dict[str, float]:
    """Return a dict of category -> total amount."""
    totals: dict[str, float] = {}
    for expense in expenses:
        category = expense["category"]
        totals[category] = totals.get(category, 0.0) + expense["amount"]
    return totals
f4_compute_by_category.py (after: reads two attributes)
from expense import Expense


def compute_by_category(expenses: list[Expense]) -> dict[str, float]:
    """Return a dict of category -> total amount."""
    totals: dict[str, float] = {}
    for expense in expenses:
        category = expense.category
        totals[category] = totals.get(category, 0.0) + expense.amount
    return totals

What changed.

f4_compute_by_category.py (what changed)
+from expense import Expense

-def compute_by_category(expenses: list[dict]) -> dict[str, float]:
+def compute_by_category(expenses: list[Expense]) -> dict[str, float]:

-        category = expense["category"]
-        totals[category] = totals.get(category, 0.0) + expense["amount"]
+        category = expense.category
+        totals[category] = totals.get(category, 0.0) + expense.amount

Run it. Run again, add an expense, choose 4, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 4 works now.

3.7 f5_actions.py: Choice 5 Stops Here, the Filter Read

Before the fix. Run the app, add an expense, choose 5, and type food. Python stops:

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: food
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 36, in main
    filter_by_category(expenses)
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 71, in filter_by_category
    if expense["category"] == wanted_category
       ~~~~~~~^^^^^^^^^^^^
TypeError: 'Expense' object is not subscriptable
  • This time the read that fails is in filter_by_category() in f5_actions.py.

The fix. In f5_actions.py, the read inside the list comprehension in filter_by_category() becomes a dot. Here is the whole function, before and after:

f5_actions.py (before: filter_by_category reads a key)
def filter_by_category(expenses: list[Expense]) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in expenses
        if expense["category"] == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))
f5_actions.py (after: filter_by_category reads an attribute)
def filter_by_category(expenses: list[Expense]) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in expenses
        if expense.category == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))

What changed.

f5_actions.py (what changed)
-        if expense["category"] == wanted_category
+        if expense.category == wanted_category

Run every choice. No square brackets are left, so every choice works again:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: food
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • The output is the same as before, for every choice. The program changed how it stores an expense, not what it prints.
  • Every hint now matches the objects: list[Expense] in f3, f4, f5_actions.py and main.py, and Expense in f2. Your editor suggests .description, .amount and .category everywhere.

Project files for this step: expense.py (the finished version) | f2_format_row.py | Optional drill: a class with __init__ and __repr__

Step-04: expense_book.py: ExpenseBook Holds the List

Step-04 slide: the expense list becomes an ExpenseBook. Before, main() holds the list: main() in main.py creates expenses as a list of Expense objects, add_expense() in f5_actions.py builds each Expense and appends it to that list, main() hands the list to every action, and list_expenses() takes the list and loops over it. After, the book holds the list: the ExpenseBook class in the new file expense_book.py creates self.expenses in __init__(), store_expense() builds each Expense, appends it to self.expenses and returns it, main() creates book = ExpenseBook() and hands the book to every action, and list_expenses() takes the book and loops over book.expenses. What changes: expense_book.py is new, with the ExpenseBook class and store_expense(); in main.py, main() makes the book and passes it to every action; in f5_actions.py, all five actions take the book: add_expense(), list_expenses(), show_total(), show_by_category() and filter_by_category()

File: expense_book.py, plus edits in main.py and f5_actions.py

What you build in this step. An ExpenseBook class that holds the list of expenses. The actions and main() now pass the book around instead of a bare list.

Concepts used:

  • Modules: from ... import, from expense_book import ExpenseBook in f5_actions.py and main.py (Section 07)
  • Type hints: list[...], dict[...], list[Expense] (Section 08)
  • Type hints: parameters and ->, book: ExpenseBook on all five actions (Section 08)
  • Classes: __init__ and self, the book starts with an empty list (Section 13)
  • Classes: methods, a function inside the class: store_expense() (Section 13)
  • Classes: create an object, book = ExpenseBook() in main() (Section 13)

4.1 expense_book.py: ExpenseBook Holds the List

Step-04 slide: expense_book.py in the center, the concepts used on the left (Classes: __init__ and self; Classes: methods; Type hints: list(...), dict(...)) and what we build on the right (ExpenseBook; self.expenses; store_expense())

Write it. Create expense_book.py:

expense_book.py
from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self) -> None:
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, keep it, and hand it back."""
        expense = Expense(description, amount, category)
        self.expenses.append(expense)
        return expense
  • self.expenses is the list, and it now lives inside the book.
  • store_expense() builds one Expense, adds it to the list, and returns it, so the caller can print it.
  • There is no file path yet. The book gets one in Step-07, when it has something to save.

4.2 main.py: One Book for Every Action

Step-04 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Type hints: parameters and ->; Classes: create an object; Classes: methods) and what we build on the right (Five actions; add_expense(); Four actions; main(); Choice 1 output)

Write it. First the book itself. In main.py, import the book in place of Expense, create one book in place of the expenses list, and pass it to every action. Here is the whole file, before and after:

main.py (before: passes a list)
from expense import Expense
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main() -> None:
    expenses: list[Expense] = []
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_total(expenses)
        elif choice == "4":
            show_by_category(expenses)
        elif choice == "5":
            filter_by_category(expenses)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()
main.py (after: passes one book)
from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main() -> None:
    book = ExpenseBook()
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()

What changed.

main.py (what changed)
-from expense import Expense
+from expense_book import ExpenseBook
 from f5_actions import (
 ...
 def main() -> None:
-    expenses: list[Expense] = []
+    book = ExpenseBook()
     while True:
 ...
         if choice == "1":
-            add_expense(expenses)
+            add_expense(book)
         elif choice == "2":
-            list_expenses(expenses)
+            list_expenses(book)
         elif choice == "3":
-            show_total(expenses)
+            show_total(book)
         elif choice == "4":
-            show_by_category(expenses)
+            show_by_category(book)
         elif choice == "5":
-            filter_by_category(expenses)
+            filter_by_category(book)
         elif choice == "6":
  • main() passes the book to every action, the same way it used to pass the list.
  • The actions still expect a list. The next sub-step starts from the error that causes.

4.3 f5_actions.py: add_expense() Takes the Book

Before the fix. Run the app, choose 1, and add an expense. Python stops inside add_expense():

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 28, in main
    add_expense(book)
    ~~~~~~~~~~~^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 30, in add_expense
    expenses.append(expense)
    ^^^^^^^^^^^^^^^
AttributeError: 'ExpenseBook' object has no attribute 'append'
  • Read it from the bottom up: an ExpenseBook has no append(). main() now passes the book, but add_expense() still treats it as a list.

The fix. At the top of f5_actions.py, add the book import just below from expense import Expense. Keep that one for now: the other four actions still use Expense in their hints.

f5_actions.py (the new import)
from expense_book import ExpenseBook

Then change add_expense(). Here is the whole function, before and after:

f5_actions.py (before: add_expense takes a list)
def add_expense(expenses: list[Expense]) -> None:
    """Prompt the user for a new expense and add it to the list."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = Expense(description, amount, category)

    expenses.append(expense)

    print(f"Added: {description} ({amount:,.2f}) [{category}]")
f5_actions.py (after: add_expense takes the book)
def add_expense(book: ExpenseBook) -> None:
    """Prompt the user for a new expense and add it to the book."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = book.store_expense(description, amount, category)

    print(f"Added: {expense.description} ({expense.amount:,.2f}) [{expense.category}]")

What changed.

f5_actions.py (what changed in add_expense)
-def add_expense(expenses: list[Expense]) -> None:
-    """Prompt the user for a new expense and add it to the list."""
+def add_expense(book: ExpenseBook) -> None:
+    """Prompt the user for a new expense and add it to the book."""
     description = input("Description: ").strip()
 ...
-    expense = Expense(description, amount, category)
+    expense = book.store_expense(description, amount, category)

-    expenses.append(expense)
-    print(f"Added: {description} ({amount:,.2f}) [{category}]")
+    print(f"Added: {expense.description} ({expense.amount:,.2f}) [{expense.category}]")
  • book.store_expense() builds the Expense and keeps it, so the expenses.append(expense) line goes, and the Added: message reads the three values back from the returned expense. The docstring now ends with book.

Run it. Run again, choose 1, add an expense, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 1 works again.

4.4 f5_actions.py: list_expenses() Takes the Book

Before the fix. Run the app, add an expense, and choose 2. Python stops inside list_expenses():

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 30, in main
    list_expenses(book)
    ~~~~~~~~~~~~~^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 42, in list_expenses
    for expense in expenses:
                   ^^^^^^^^
TypeError: 'ExpenseBook' object is not iterable
  • Read it from the bottom up: the loop tries to walk the book as if it were a list, and an ExpenseBook is not iterable.

The fix. Change list_expenses() so it takes the book. Here is the whole function, before and after:

f5_actions.py (before: list_expenses takes a list)
def list_expenses(expenses: list[Expense]) -> None:
    """Display all recorded expenses."""
    if not expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in expenses:
        print(format_row(expense))
f5_actions.py (after: list_expenses takes the book)
def list_expenses(book: ExpenseBook) -> None:
    """Display all recorded expenses."""
    if not book.expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in book.expenses:
        print(format_row(expense))

What changed.

f5_actions.py (what changed in list_expenses)
-def list_expenses(expenses: list[Expense]) -> None:
+def list_expenses(book: ExpenseBook) -> None:
     """Display all recorded expenses."""
-    if not expenses:
+    if not book.expenses:
         print("No expenses yet.")
 ...
-    for expense in expenses:
+    for expense in book.expenses:
         print(format_row(expense))
  • The list lives inside the book now, so both reads become book.expenses.

Run it. Run again, add an expense, choose 2, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 2 works again.

4.5 f5_actions.py: show_total() Takes the Book

Before the fix. Run the app, add an expense, and choose 3. Python stops inside show_total():

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 32, in main
    show_total(book)
    ~~~~~~~~~~^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 48, in show_total
    print(f"Total spent: {compute_total(expenses):,.2f}")
                          ~~~~~~~~~~~~~^^^^^^^^^^
  File ".../04-livecode-build-incremental/f3_compute_total.py", line 7, in compute_total
    for expense in expenses:
                   ^^^^^^^^
TypeError: 'ExpenseBook' object is not iterable
  • Read it from the bottom up: compute_total() in f3_compute_total.py loops over what it gets, and it got the book, not the list.

The fix. Change show_total() so it takes the book. Here is the whole function, before and after:

f5_actions.py (before: show_total takes a list)
def show_total(expenses: list[Expense]) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {compute_total(expenses):,.2f}")
f5_actions.py (after: show_total takes the book)
def show_total(book: ExpenseBook) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {compute_total(book.expenses):,.2f}")

What changed.

f5_actions.py (what changed in show_total)
-def show_total(expenses: list[Expense]) -> None:
+def show_total(book: ExpenseBook) -> None:
     """Display the total amount spent."""
-    print(f"Total spent: {compute_total(expenses):,.2f}")
+    print(f"Total spent: {compute_total(book.expenses):,.2f}")
  • compute_total() still wants the list, so the action passes book.expenses.

Run it. Run again, add an expense, choose 3, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 3 works again.

4.6 f5_actions.py: show_by_category() Takes the Book

Before the fix. Run the app, add an expense, and choose 4. Python stops inside show_by_category():

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 34, in main
    show_by_category(book)
    ~~~~~~~~~~~~~~~~^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 53, in show_by_category
    totals = compute_by_category(expenses)
  File ".../04-livecode-build-incremental/f4_compute_by_category.py", line 7, in compute_by_category
    for expense in expenses:
                   ^^^^^^^^
TypeError: 'ExpenseBook' object is not iterable
  • Read it from the bottom up: compute_by_category() in f4_compute_by_category.py got the book, not the list.

The fix. Change show_by_category() so it takes the book. Here is the whole function, before and after:

f5_actions.py (before: show_by_category takes a list)
def show_by_category(expenses: list[Expense]) -> None:
    """Display the total spent in each category."""
    totals = compute_by_category(expenses)

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")
f5_actions.py (after: show_by_category takes the book)
def show_by_category(book: ExpenseBook) -> None:
    """Display the total spent in each category."""
    totals = compute_by_category(book.expenses)

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")

What changed.

f5_actions.py (what changed in show_by_category)
-def show_by_category(expenses: list[Expense]) -> None:
+def show_by_category(book: ExpenseBook) -> None:
     """Display the total spent in each category."""
-    totals = compute_by_category(expenses)
+    totals = compute_by_category(book.expenses)
  • compute_by_category() still wants the list, so the action passes book.expenses.

Run it. Run again, add an expense, choose 4, then 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • Choice 4 works again.

4.7 f5_actions.py: filter_by_category() Takes the Book

Before the fix. Run the app, add an expense, choose 5, and type food. Python stops inside filter_by_category():

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: food
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 36, in main
    filter_by_category(book)
    ~~~~~~~~~~~~~~~~~~^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 69, in filter_by_category
    for expense in expenses
                   ^^^^^^^^
TypeError: 'ExpenseBook' object is not iterable
  • Read it from the bottom up: the list comprehension walks the book as if it were a list.

The fix. Change filter_by_category() so it takes the book. Here is the whole function, before and after:

f5_actions.py (before: filter_by_category takes a list)
def filter_by_category(expenses: list[Expense]) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in expenses
        if expense.category == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))
f5_actions.py (after: filter_by_category takes the book)
def filter_by_category(book: ExpenseBook) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in book.expenses
        if expense.category == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))

What changed.

f5_actions.py (what changed in filter_by_category)
-def filter_by_category(expenses: list[Expense]) -> None:
+def filter_by_category(book: ExpenseBook) -> None:
     """Display expenses that belong to one category."""
 ...
         expense
-        for expense in expenses
+        for expense in book.expenses
         if expense.category == wanted_category
  • The list comprehension now walks book.expenses.

Now no action uses Expense any more, so delete its import at the top:

f5_actions.py (the old import goes)
-from expense import Expense

Run every choice. Every action takes the book now:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: food
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • The output is the same as before, for every choice.

Project files for this step: expense_book.py (the finished version) | f5_actions.py (the finished version) | main.py (the finished version) | Optional drill: a class with methods

Step-05: expense_book.py: Two Helpers Become Methods

Step-05 slide: why move two helpers into the ExpenseBook. The ExpenseBook owns the expenses, so the ExpenseBook does the work on them. Before, the work is spread out: compute_total() in f3_compute_total.py and compute_by_category() in f4_compute_by_category.py each receive the list of expenses as a parameter, and filter_by_category() in f5_actions.py searches book.expenses on its own. After, the book does its own work: the actions in f5_actions.py ask book.compute_total(), book.compute_by_category() and book.filter(wanted_category), and the ExpenseBook class in expense_book.py holds self.expenses and the methods compute_total(), compute_by_category() and filter(). is_valid_amount() in f1 and format_row() in f2 stay as helpers: one checks the typed amount, the other formats one table row, and neither needs the book's list. One menu choice, choice 5 Show one category: the action asks the user with input(), asks the book with book.filter(wanted_category), the book finds the matches by looping over self.expenses, and the action shows the answer with print(format_row(expense)). Three reasons: the owner does the work; a new screen can use the same book, the command line now and a web app later, and in Step-11 the rich package changes the screen while expense_book.py stays the same; and one place for all three, since the total, by category and the matches now live in expense_book.py, where before they were in f3, f4 and f5

Step-05 slide: two helper files move into the ExpenseBook. Before, the helper files do the work: compute_total() in f3_compute_total.py takes the list of expenses as a parameter and adds up every amount, show_by_category() in f5_actions.py calls compute_by_category(book.expenses), and filter_by_category() in f5_actions.py builds its matches with a list comprehension over book.expenses. After, the book does the work: compute_total() is now a method of ExpenseBook in expense_book.py that takes self and loops over self.expenses, show_by_category() calls book.compute_by_category(), and the list comprehension moves into a new filter() method that takes a category and returns only the expenses in it. What changes: expense_book.py gets three new methods, compute_total(), compute_by_category() and filter(); in f5_actions.py the actions ask the book with book.compute_total(), book.compute_by_category() and book.filter(wanted_category); f3_compute_total.py and f4_compute_by_category.py are deleted, and their code lives in the class now

File: expense_book.py, plus f5_actions.py; delete f3_compute_total.py and f4_compute_by_category.py

What you build in this step. Your two helper functions move into the class as methods, and their two files are deleted. The list comprehension from filter_by_category moves into the class too.

Concepts used:

  • Dictionary method: .get() with a default, inside compute_by_category() (Section 05)
  • Accumulator: a running total, inside compute_total() (Section 06)
  • List comprehension: filter with if, inside filter() (Section 06)
  • Modules: from ... import, delete the imports of f3 and f4 (Section 07)
  • Classes: methods, compute_total(), compute_by_category() and filter() (Section 13)

5.1 expense_book.py: Two Helpers Become Methods

Step-05 slide: expense_book.py in the center, the concepts used on the left (Classes: methods; Accumulator: a running total; Dictionaries: .get() with a default; List comprehension: filter with if) and what we build on the right (compute_total(); compute_by_category(); filter(); One change)

Write it. Add three methods to ExpenseBook, under store_expense(). Here is the whole file, before and after:

expense_book.py (before: store_expense() only)
from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self) -> None:
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, keep it, and hand it back."""
        expense = Expense(description, amount, category)
        self.expenses.append(expense)
        return expense
expense_book.py (after: with compute_total(), compute_by_category() and filter())
from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self) -> None:
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, keep it, and hand it back."""
        expense = Expense(description, amount, category)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]

What changed.

expense_book.py (what changed)
         return expense
+    def compute_total(self) -> float:
+        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
+        total = 0.0
+        for expense in self.expenses:
+            total += expense.amount
+        return total
+    def compute_by_category(self) -> dict[str, float]:
+        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
+        totals: dict[str, float] = {}
+        for expense in self.expenses:
+            category = expense.category
+            totals[category] = totals.get(category, 0.0) + expense.amount
+        return totals
+    def filter(self, category: str) -> list[Expense]:
+        """Return only the expenses in one category."""
+        return [expense for expense in self.expenses if expense.category == category]
  • compute_total() is your compute_total function, moved into the class with the same name. The only change to the code: expenses became self.expenses. The docstring gains a sentence that says where the method came from.
  • compute_by_category() is your compute_by_category function, moved in the same way, with the same one-word change and the same new docstring sentence.
  • filter() holds the list comprehension from filter_by_category.

5.2 f5_actions.py: Call the Book's Methods

Step-05 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Classes: methods) and what we build on the right (show_total(); show_by_category(); filter_by_category(); Deleted; Choice 3 output)

Wire it in. The actions stop calling the two helper files and ask the book instead. Here is the whole f5_actions.py, before and after:

f5_actions.py (before: calls the f3 and f4 helpers)
from expense_book import ExpenseBook
from f1_is_valid_amount import is_valid_amount
from f2_format_row import format_row
from f3_compute_total import compute_total
from f4_compute_by_category import compute_by_category


def add_expense(book: ExpenseBook) -> None:
    """Prompt the user for a new expense and add it to the book."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = book.store_expense(description, amount, category)

    print(f"Added: {expense.description} ({expense.amount:,.2f}) [{expense.category}]")


def list_expenses(book: ExpenseBook) -> None:
    """Display all recorded expenses."""
    if not book.expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in book.expenses:
        print(format_row(expense))


def show_total(book: ExpenseBook) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {compute_total(book.expenses):,.2f}")


def show_by_category(book: ExpenseBook) -> None:
    """Display the total spent in each category."""
    totals = compute_by_category(book.expenses)

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")


def filter_by_category(book: ExpenseBook) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = [
        expense
        for expense in book.expenses
        if expense.category == wanted_category
    ]

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))
f5_actions.py (after: calls the book's methods)
from expense_book import ExpenseBook
from f1_is_valid_amount import is_valid_amount
from f2_format_row import format_row


def add_expense(book: ExpenseBook) -> None:
    """Prompt the user for a new expense and add it to the book."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    expense = book.store_expense(description, amount, category)

    print(f"Added: {expense.description} ({expense.amount:,.2f}) [{expense.category}]")


def list_expenses(book: ExpenseBook) -> None:
    """Display all recorded expenses."""
    if not book.expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in book.expenses:
        print(format_row(expense))


def show_total(book: ExpenseBook) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {book.compute_total():,.2f}")


def show_by_category(book: ExpenseBook) -> None:
    """Display the total spent in each category."""
    totals = book.compute_by_category()

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")


def filter_by_category(book: ExpenseBook) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = book.filter(wanted_category)

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))

What changed.

f5_actions.py (what changed)
 from f2_format_row import format_row
-from f3_compute_total import compute_total
-from f4_compute_by_category import compute_by_category
 ...
     """Display the total amount spent."""
-    print(f"Total spent: {compute_total(book.expenses):,.2f}")
+    print(f"Total spent: {book.compute_total():,.2f}")
 ...
     """Display the total spent in each category."""
-    totals = compute_by_category(book.expenses)
+    totals = book.compute_by_category()
 ...
-    matches = [
-        expense
-        for expense in book.expenses
-        if expense.category == wanted_category
-    ]
+    matches = book.filter(wanted_category)
  • The two imports of f3 and f4 go.
  • show_total() asks book.compute_total(), and show_by_category() asks book.compute_by_category().
  • In filter_by_category(), the list comprehension becomes one call, book.filter(wanted_category). The comprehension still exists: it lives in filter() inside the class now.

5.3 Delete f3_compute_total.py and f4_compute_by_category.py

Nothing imports them any more, so delete both files.

  • Your two helper files are not lost. Their code is inside the class now, and that is why this project has an f1 and an f2 but no f3 or f4.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2.00
Category: transport
Added: Bus (2.00) [transport]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 5.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
food                 3.50
transport            2.00

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: transport
Description          Category           Amount
Bus                  transport            2.00

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • The output is the same as before: the program changed its shape, not what it does.

Project files for this step: expense_book.py | f5_actions.py | Optional drill: a class with methods

Step-06: expense.py: Stamp Every Expense

Step-06 slide: two new things, datetime and __repr__. Every expense gets a time stamp, created_at, and a readable way to show itself. First, a time stamp with datetime: the book stamps each expense with the time as it stores it, in store_expense() in expense_book.py, with stamp = datetime.now().isoformat(timespec set to seconds) and expense = Expense(description, amount, category, stamp); datetime.now() is the current date and time, and .isoformat() turns it into text like 2026-09-16T21:50:17; in expense.py, __init__() stores it as a fourth value, self.created_at. Second, what is __repr__: a special method that returns the exact text Python shows for an object, at the Python prompt, in lists, and in print() when there is no __str__; in expense.py it returns Expense( with all four values, and !r puts quotes around text values like 'Coffee'. At a Python prompt, book.store_expense with Coffee, 3.50 and food shows expense.Expense object at a memory address without __repr__, which is only the file and class name and an address, and shows Expense(description='Coffee', amount=3.5, category='food', created_at='2026-09-16T21:50:17') with __repr__. Why a time stamp: every expense remembers when it was added, and in Step-07 created_at is saved to the file with the other three values. Why __repr__: without it, every expense shows up as expense.Expense object at a memory address, with no values at all; with it, you see your data everywhere Python shows an expense, so checking your work and fixing bugs gets easy

File: expense.py, plus store_expense() in expense_book.py and the finished f5_actions.py

What you build in this step. Every expense gets a fourth attribute, created_at, with the time it was added, and a __repr__ that shows all four values. expense.py and f5_actions.py are finished in this step.

Concepts used:

  • Dates: datetime, datetime.now() (Section 11)
  • Classes: __repr__, show every field (Section 13)
  • Classes: __init__ and self, a fourth value, created_at (Section 13)
  • Dates: .isoformat(), turn the time into text (New in this project)
  • f-strings: !r, quotes around text values (New in this project)

6.1 expense.py: created_at and __repr__

Step-06 slide: expense.py in the center, the concepts used on the left (Classes: __init__ and self; Classes: __repr__; f-strings: !r) and what we build on the right (created_at; __repr__(); !r; expense.py)

Write it. In expense.py, the class docstring names the fourth field, __init__ takes and stores created_at, and a new __repr__ shows all four values. Here is the whole file, before and after:

expense.py (before: three attributes)
class Expense:
    """One expense: what it was, how much, and which category."""

    def __init__(self, description: str, amount: float, category: str) -> None:
        self.description = description
        self.amount = amount
        self.category = category
expense.py (after: created_at and __repr__)
class Expense:
    """One expense: what it was, how much, which category, and when it was added."""

    def __init__(self, description: str, amount: float,
                 category: str, created_at: str) -> None:
        self.description = description
        self.amount = amount
        self.category = category
        self.created_at = created_at

    def __repr__(self) -> str:
        """Show every field, so printing an Expense at a Python prompt is readable."""
        return (f"Expense(description={self.description!r}, amount={self.amount!r}, "
                f"category={self.category!r}, created_at={self.created_at!r})")

What changed.

expense.py (what changed)
 class Expense:
-    """One expense: what it was, how much, and which category."""
+    """One expense: what it was, how much, which category, and when it was added."""

-    def __init__(self, description: str, amount: float, category: str) -> None:
+    def __init__(self, description: str, amount: float,
+                 category: str, created_at: str) -> None:
         self.description = description
 ...
         self.category = category
+        self.created_at = created_at
+    def __repr__(self) -> str:
+        """Show every field, so printing an Expense at a Python prompt is readable."""
+        return (f"Expense(description={self.description!r}, amount={self.amount!r}, "
+                f"category={self.category!r}, created_at={self.created_at!r})")
  • __repr__ returns text that shows every attribute. !r puts quotes around text values.
  • expense.py is finished now.

6.2 expense_book.py: Stamp the Time in store_expense()

Step-06 slide: expense_book.py in the center, the concepts used on the left (Dates: datetime; Dates: .isoformat()) and what we build on the right (store_expense(); datetime.now(); .isoformat(); f5_actions.py; At a Python prompt)

Before the fix. Run the app, choose 1, and add an expense. Python stops inside store_expense():

Before the fix (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 28, in main
    add_expense(book)
    ~~~~~~~~~~~^^^^^^
  File ".../04-livecode-build-incremental/f5_actions.py", line 26, in add_expense
    expense = book.store_expense(description, amount, category)
  File ".../04-livecode-build-incremental/expense_book.py", line 12, in store_expense
    expense = Expense(description, amount, category)
TypeError: Expense.__init__() missing 1 required positional argument: 'created_at'
  • Read it from the bottom up: Expense() now needs four values, and store_expense() still passes three. The fix is in store_expense().

The fix. In expense_book.py, import datetime at the top, above from expense import Expense, with a blank line between them, and stamp each new expense with the time inside store_expense(). Here is the whole file, before and after:

expense_book.py (before: store_expense() passes three values)
from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self) -> None:
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, keep it, and hand it back."""
        expense = Expense(description, amount, category)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]
expense_book.py (after: store_expense() stamps the time)
from datetime import datetime

from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self) -> None:
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, stamp it with the time, keep it, and hand it back."""
        stamp = datetime.now().isoformat(timespec="seconds")
        expense = Expense(description, amount, category, stamp)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]

What changed.

expense_book.py (what changed)
+from datetime import datetime
 from expense import Expense
 ...
     def store_expense(self, description: str, amount: float, category: str) -> Expense:
-        """Build one Expense, keep it, and hand it back."""
-        expense = Expense(description, amount, category)
+        """Build one Expense, stamp it with the time, keep it, and hand it back."""
+        stamp = datetime.now().isoformat(timespec="seconds")
+        expense = Expense(description, amount, category, stamp)
         self.expenses.append(expense)
  • datetime.now() is the current date and time. .isoformat(timespec="seconds") turns it into text like 2026-09-16T12:43:14.

Check it at a Python prompt. The screen does not change in this step, so look at an object directly. In a terminal in your build folder, type python3 to open the >>> prompt (Section 01):

At a Python prompt
>>> from expense_book import ExpenseBook
>>> book = ExpenseBook()
>>> book.store_expense("Coffee", 3.50, "food")
Expense(description='Coffee', amount=3.5, category='food', created_at='2026-09-16T21:50:17')
  • The prompt shows the object through __repr__. Your time stamp will be the time you run it.
  • ExpenseBook() takes no path yet. That comes in the next step.
  • Type exit() to leave the prompt before you run the app below.

6.3 f5_actions.py: The Finished Actions File

No code changes here: every action already takes the book, and the imports of f3 and f4 are gone. If your file still has its Milestone 1 comments, update the one above book.store_expense(...) in add_expense():

f5_actions.py (the comment inside add_expense)
    # The book builds the Expense object, stamps it with the time, and keeps it.
  • f5_actions.py is finished now.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Goodbye!
  • The menu output is the same as before.

Project files for this step: expense.py | expense_book.py | f5_actions.py | Optional drill: a datetime stamp

Step-07: expense_book.py: Save to JSON

Step-07 slide 1 of 2: save your expenses to a JSON file. The book gets a file path and a save() method, and choice 6 becomes Save and quit. Before, the expenses live only in memory, in self.expenses, and are gone when you quit. After, choice 6 writes them to data/expenses.json, next to your code. How save() writes the file, in expense_book.py: first the Expense objects in memory, self.expenses; then one dictionary for each expense, where the amount key gets expense.amount; then the dictionaries become JSON text with json.dumps(rows, indent=2); then the text goes into the file with write_text(). Why dictionaries: json.dumps can only write plain data like dictionaries and lists, not your own Expense objects. What you see: the menu shows 6) Save and quit, you choose 6, and the app prints Saved. Goodbye! After adding Coffee and choosing 6, data/expenses.json holds one entry with description Coffee, amount 3.5, category food and created_at 2026-09-16T21:50:17. Next, Step-08 loads this file back when the app starts, so your expenses survive a restart

Step-07 slide 2 of 2: the code changes in expense_book.py and main.py, with new lines in green and removed lines in amber. In expense_book.py the book gets a file path and a save() method: import json and from pathlib import Path are added; __init__(self) becomes __init__(self, path: Path) and stores self.path = path; the new save() method builds one dictionary per expense with the keys description, amount, category and created_at, makes the data folder with self.path.parent.mkdir(parents=True, exist_ok=True), and writes the file with self.path.write_text(json.dumps(rows, indent=2)). mkdir with exist_ok=True does nothing if the folder already exists, and indent=2 makes the file easy to read. In main.py the book gets its file and choice 6 saves: from pathlib import Path is added; the menu line 6) Quit becomes 6) Save and quit; book = ExpenseBook() becomes book = ExpenseBook(Path(__file__).parent / data / expenses.json); and in choice 6 the Goodbye print is replaced by book.save() and a print of Saved. Goodbye! Path(__file__).parent is the folder that holds main.py, so the file always lands next to your code

File: expense_book.py, plus the path, the menu line and choice 6 in main.py

What you build in this step. A save() method that writes every expense to data/expenses.json. Choice 6 becomes Save and quit.

Concepts used:

  • List comprehension: [... for ...], turn each object back into a dict (Section 06)
  • JSON: json.dumps, dicts to JSON text (Section 10)
  • Files: Path and write_text(), write the text to the file (Section 10)
  • Paths: __file__, save next to your script (Section 10)
  • Paths: mkdir(), make the data folder (Section 10)
  • Classes: methods, choice 6 calls book.save() (Section 13)

7.1 expense_book.py: save() Writes the JSON File

Step-07 slide: expense_book.py in the center, the concepts used on the left (JSON: json.dumps; Files: Path and write_text(); Paths: mkdir(); List comprehension: (... for ...)) and what we build on the right (save(); self.path; One dict per expense; indent=2)

Write it. In expense_book.py, add import json above from datetime import datetime, and from pathlib import Path below it, give __init__ a path, and add save() at the end of the class. Here is the whole file, before and after:

expense_book.py (before: no file)
from datetime import datetime

from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self) -> None:
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, stamp it with the time, keep it, and hand it back."""
        stamp = datetime.now().isoformat(timespec="seconds")
        expense = Expense(description, amount, category, stamp)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]
expense_book.py (after: a path and save())
import json
from datetime import datetime
from pathlib import Path

from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self, path: Path) -> None:
        self.path = path
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, stamp it with the time, keep it, and hand it back."""
        stamp = datetime.now().isoformat(timespec="seconds")
        expense = Expense(description, amount, category, stamp)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]

    def save(self) -> None:
        """Write every expense to the JSON file, making the data folder if it is missing."""
        rows = [{"description": expense.description, "amount": expense.amount,
                 "category": expense.category, "created_at": expense.created_at}
                for expense in self.expenses]
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(json.dumps(rows, indent=2))

What changed.

expense_book.py (what changed)
+import json
 from datetime import datetime
+from pathlib import Path
 ...
-    def __init__(self) -> None:
+    def __init__(self, path: Path) -> None:
+        self.path = path
         self.expenses: list[Expense] = []
 ...
         return [expense for expense in self.expenses if expense.category == category]
+    def save(self) -> None:
+        """Write every expense to the JSON file, making the data folder if it is missing."""
+        rows = [{"description": expense.description, "amount": expense.amount,
+                 "category": expense.category, "created_at": expense.created_at}
+                for expense in self.expenses]
+        self.path.parent.mkdir(parents=True, exist_ok=True)
+        self.path.write_text(json.dumps(rows, indent=2))
  • The book now knows where its file lives: self.path.
  • save() turns each object back into a dictionary, because json.dumps can only write plain data like dictionaries and lists.
  • mkdir(parents=True, exist_ok=True) makes the data folder, and does nothing if it already exists.
  • indent=2 makes the file easy to read.

7.2 main.py: The Data Path, and Choice 6 Saves

Step-07 slide: main.py in the center, the concepts used on the left (Paths: __file__; Classes: methods) and what we build on the right (Path(__file__).parent; data/expenses.json; Menu line 6; Choice 6)

Before the fix. Run the app. Python stops before the menu even appears:

Before the fix
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 44, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 23, in main
    book = ExpenseBook()
TypeError: ExpenseBook.__init__() missing 1 required positional argument: 'path'
  • ExpenseBook() now needs the path of its file, and main() still creates the book without one.

The fix. In main.py, import Path, give the book its file, rename choice 6 in the menu, and save before you say goodbye. Here is the whole file, before and after:

main.py (before: a book with no file)
from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Quit")


def main() -> None:
    book = ExpenseBook()
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()
main.py (after: the data path, and choice 6 saves)
from pathlib import Path

from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Save and quit")


def main() -> None:
    book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            book.save()
            print("Saved. Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()

What changed.

main.py (what changed)
+from pathlib import Path
 from expense_book import ExpenseBook
 ...
     print("5) Show one category")
-    print("6) Quit")
+    print("6) Save and quit")
 ...
 def main() -> None:
-    book = ExpenseBook()
+    book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
     while True:
 ...
         elif choice == "6":
-            print("Goodbye!")
+            book.save()
+            print("Saved. Goodbye!")
             break
  • Path(__file__).parent is the folder that holds main.py, so the file always lands next to your code, wherever you run it from.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2.00
Category: transport
Added: Bus (2.00) [transport]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!

Then open the file it wrote:

data/expenses.json
[
  {
    "description": "Coffee",
    "amount": 3.5,
    "category": "food",
    "created_at": "2026-09-16T21:50:17"
  },
  {
    "description": "Bus",
    "amount": 2.0,
    "category": "transport",
    "created_at": "2026-09-16T21:50:17"
  }
]
  • Your times will be different: each expense stores the moment you added it.

Project files for this step: expense_book.py | main.py | Optional drill: save JSON | Optional drill: save next to your code

Step-08: expense_book.py: Load It Back

Step-08 slide 1 of 2: the same trip, out and back. save() turns your objects into text, and load() turns that text back into objects. Saving, in save() from Step-07 on choice 6: the Expense objects in self.expenses become one dictionary per expense, in memory, holding description Coffee, amount 3.5, category food and created_at 2026-09-16T21:50:17; json.dumps() turns those dictionaries into JSON text, one long string; and write_text() writes that text to the file data/expenses.json. Loading, in load() from Step-08 when the app starts: read_text() reads the file into JSON text; json.loads() turns that text back into the same dictionaries, one per row; and Expense(...) builds one Expense object per row, back into self.expenses. Why the middle steps: a file holds text, and JSON only knows plain data, that is text, numbers, lists and dictionaries, so your Expense objects go out as dictionaries and come back as objects, and the same four keys travel both ways: description, amount, category and created_at

Step-08 slide 2 of 2: load it back, so your data survives a restart. A load() method reads the file into Expense objects, and main() calls it once, before the menu loop. Before, Step-07 writes the file but nobody reads it, so starting the app again leaves the book empty. After, book.load() fills the book from the file before the menu appears. How load() reads the file, in expense_book.py: first the file data/expenses.json, JSON text on disk; then read and parse with json.loads(self.path.read_text()); then one Expense per row, with for row in rows; and finally the objects sit in self.expenses, ready for the menu. On the first run there is no file yet, so if not self.path.exists() starts with an empty list and returns, which is normal and not an error. The whole new method is shown, and running the app again after Save and quit lists Coffee, food, 3.50, so the expense is still there, where Milestone 1 lost it on restart. One line in main.py does the wiring: book.load(), right after the book is created and before the menu loop starts

File: expense_book.py, plus one line in main.py

What you build in this step. A load() method that reads the file back into Expense objects when the program starts. Your data now survives a restart. expense_book.py is finished in this step.

Concepts used:

  • List comprehension: [... for ...], turn each dict back into an Expense (Section 06)
  • Loops: while True, the load runs once, before the menu loop starts (Section 06)
  • JSON: json.loads, JSON text back to dicts (Section 10)
  • Files: read_text() and .exists(), read the file, or start empty when it is missing (Section 10)
  • Classes: methods, main() calls book.load() (Section 13)

8.1 expense_book.py: load() Reads It Back

Step-08 slide: expense_book.py in the center, the concepts used on the left (JSON: json.loads; Files: read_text() and .exists(); List comprehension: (... for ...)) and what we build on the right (load(); Missing file; One Expense per dict; expense_book.py)

Write it. Add load() at the end of the class. Here is the whole file, before and after:

expense_book.py (before: save() only)
import json
from datetime import datetime
from pathlib import Path

from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self, path: Path) -> None:
        self.path = path
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, stamp it with the time, keep it, and hand it back."""
        stamp = datetime.now().isoformat(timespec="seconds")
        expense = Expense(description, amount, category, stamp)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]

    def save(self) -> None:
        """Write every expense to the JSON file, making the data folder if it is missing."""
        rows = [{"description": expense.description, "amount": expense.amount,
                 "category": expense.category, "created_at": expense.created_at}
                for expense in self.expenses]
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(json.dumps(rows, indent=2))
expense_book.py (after: with load())
import json
from datetime import datetime
from pathlib import Path

from expense import Expense


class ExpenseBook:
    """Holds the expenses and answers questions about them."""

    def __init__(self, path: Path) -> None:
        self.path = path
        self.expenses: list[Expense] = []

    def store_expense(self, description: str, amount: float, category: str) -> Expense:
        """Build one Expense, stamp it with the time, keep it, and hand it back."""
        stamp = datetime.now().isoformat(timespec="seconds")
        expense = Expense(description, amount, category, stamp)
        self.expenses.append(expense)
        return expense

    def compute_total(self) -> float:
        """Return the sum of every expense amount. This was Milestone 1's compute_total."""
        total = 0.0
        for expense in self.expenses:
            total += expense.amount
        return total

    def compute_by_category(self) -> dict[str, float]:
        """Return a dict of category -> total amount. This was Milestone 1's compute_by_category."""
        totals: dict[str, float] = {}
        for expense in self.expenses:
            category = expense.category
            totals[category] = totals.get(category, 0.0) + expense.amount
        return totals

    def filter(self, category: str) -> list[Expense]:
        """Return only the expenses in one category."""
        return [expense for expense in self.expenses if expense.category == category]

    def save(self) -> None:
        """Write every expense to the JSON file, making the data folder if it is missing."""
        rows = [{"description": expense.description, "amount": expense.amount,
                 "category": expense.category, "created_at": expense.created_at}
                for expense in self.expenses]
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(json.dumps(rows, indent=2))

    def load(self) -> None:
        """Read the JSON file back into Expense objects. A missing file just means no expenses yet."""
        if not self.path.exists():
            self.expenses = []
            return
        rows = json.loads(self.path.read_text())
        self.expenses = [Expense(row["description"], row["amount"],
                                 row["category"], row["created_at"]) for row in rows]

What changed.

expense_book.py (what changed)
         self.path.write_text(json.dumps(rows, indent=2))
+    def load(self) -> None:
+        """Read the JSON file back into Expense objects. A missing file just means no expenses yet."""
+        if not self.path.exists():
+            self.expenses = []
+            return
+        rows = json.loads(self.path.read_text())
+        self.expenses = [Expense(row["description"], row["amount"],
+                                 row["category"], row["created_at"]) for row in rows]
  • When the file does not exist yet, load() starts with an empty list and returns. That is normal on the first run.
  • json.loads turns the text back into a list of dictionaries, and the list comprehension builds one Expense from each.
  • expense_book.py is finished now.

8.2 main.py: Load on Start

Step-08 slide: main.py in the center, the concepts used on the left (Classes: methods; Loops: while True) and what we build on the right (main(); Before the loop; Second run)

Wire it in. In main.py, load the book before the loop. Here is the whole file, before and after:

main.py (before: starts empty)
from pathlib import Path

from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Save and quit")


def main() -> None:
    book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            book.save()
            print("Saved. Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()
main.py (after: loads on start)
from pathlib import Path

from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Save and quit")


def main() -> None:
    book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
    book.load()
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            book.save()
            print("Saved. Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()

What changed.

main.py (what changed)
     book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
+    book.load()
     while True:

Run it. First delete the data folder that Step-07 wrote, so this run starts with no file. Then add an expense and quit:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!

Then run it again and list the expenses:

Run it again (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!
  • The expense is still there. Milestone 1 lost it on restart; this program loads it back from the file.

Project files for this step: expense_book.py | main.py | Optional drill: load JSON safely

Step-09: main.py: Do Not Crash on a Broken File

Step-09 slide 1 of 2: a broken file must not crash your app. One try and except around book.load() turns a crash into a warning, and the app starts empty. Break it on purpose: data/expenses.json is cut short, so it is no longer valid JSON. Before the fix, running the app stops with a traceback through main.py, then expense_book.py, ending in json.decoder.JSONDecodeError, Expecting property name enclosed in double quotes. The fix, inside main() in main.py: try book.load(), then except json.JSONDecodeError, TypeError and KeyError together, print Warning: data file is corrupt; starting empty. and set book.expenses to an empty list. After the fix, the same broken file prints that warning, the menu appears, and choice 2 shows No expenses yet. Three errors with one reply: json.JSONDecodeError means the text is not valid JSON, TypeError means valid JSON in the wrong shape, and KeyError means a record is missing a field. A missing file is normal and load() handles it with an if and starts empty, while a broken file raises an error, so main() catches it

Step-09 slide 2 of 2: run it or import it, the __main__ guard. Two lines decide whether the menu starts: if __name__ equals __main__, and an indented main(). Before, the last line of main.py is a bare main(). After, the last two lines are the guard and the indented call. When you run it with python3 main.py, __name__ is __main__ in this file, the if is true, main() runs, and the menu starts. When something imports it with import main, __name__ is main instead, the if is false, main() does not run, and you get the functions with no menu. At a Python prompt, import main prints nothing, no menu and no prompt, and then calling main.show_menu() yourself prints the Expense Tracker 2.0 menu. The same file can now be a program to run and a module to import. You met this guard in Section 12; here it goes into your own app

File: main.py

What you build in this step. A try / except around load(), so a broken data file prints a warning instead of crashing the app. main.py is finished in this step.

Concepts used:

  • Error handling: try / except, around book.load() (Section 09)
  • Error handling: except (A, B), one reply for several errors (Section 09)
  • Exceptions: json.JSONDecodeError, the error a broken JSON file raises (New in this project)
  • The __main__ guard: run it or import it, if __name__ == "__main__": (Section 12)

9.1 main.py: Do Not Crash on a Broken File

Step-09 slide: main.py in the center, the concepts used on the left (Error handling: try / except; Error handling: except (A, B); Exceptions: json.JSONDecodeError) and what we build on the right (try around load(); Broken file; Printed warning; Choice 6)

Break it first. Open data/expenses.json and delete its last lines, so the text is no longer valid JSON. What is left looks like this:

data/expenses.json (broken on purpose)
[
  {
    "description": "Coffee",
    "amount": 3.5,

Before the fix. Run the program from Step-08 now. Python stops before the menu appears:

Before the fix
Traceback (most recent call last):
  File ".../04-livecode-build-incremental/main.py", line 48, in <module>
    main()
    ~~~~^^
  File ".../04-livecode-build-incremental/main.py", line 26, in main
    book.load()
    ~~~~~~~~~^^
  File ".../04-livecode-build-incremental/expense_book.py", line 54, in load
    rows = json.loads(self.path.read_text())
  File ".../json/__init__.py", line 352, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File ".../json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File ".../json/decoder.py", line 361, in raw_decode
    obj, end = self.scan_once(s, idx)
               ~~~~~~~~~~~~~~^^^^^^^^
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 5 column 1 (char 54)
  • The last line names the error: the text is not valid JSON. In the code below, json.JSONDecodeError is a shorter name for this same json.decoder.JSONDecodeError.

The fix. At the top of main.py, add import json. Inside main(), wrap the load in try / except. Here is the whole file, before and after:

main.py (before: load() can crash)
from pathlib import Path

from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Save and quit")


def main() -> None:
    book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
    book.load()
    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            book.save()
            print("Saved. Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


main()
main.py (after: try / except around load())
import json
from pathlib import Path

from expense_book import ExpenseBook
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


def show_menu() -> None:
    print()
    print("==== Expense Tracker 2.0 ====")
    print("1) Add expense")
    print("2) List expenses")
    print("3) Total spent")
    print("4) Spending by category")
    print("5) Show one category")
    print("6) Save and quit")


def main() -> None:
    book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")

    try:
        book.load()
    except (json.JSONDecodeError, TypeError, KeyError):
        print("Warning: data file is corrupt; starting empty.")
        book.expenses = []

    while True:
        show_menu()
        choice = input("Choose 1-6: ").strip()
        if choice == "1":
            add_expense(book)
        elif choice == "2":
            list_expenses(book)
        elif choice == "3":
            show_total(book)
        elif choice == "4":
            show_by_category(book)
        elif choice == "5":
            filter_by_category(book)
        elif choice == "6":
            book.save()
            print("Saved. Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 6.")


if __name__ == "__main__":
    main()

What changed.

main.py (what changed)
+import json
 from pathlib import Path
 ...
     book = ExpenseBook(Path(__file__).parent / "data" / "expenses.json")
-    book.load()
+    try:
+        book.load()
+    except (json.JSONDecodeError, TypeError, KeyError):
+        print("Warning: data file is corrupt; starting empty.")
+        book.expenses = []
     while True:
  • A MISSING file is normal, and load() handles it with an if. A BROKEN file raises an error, and main() catches it.
  • json.JSONDecodeError: the text is not valid JSON. TypeError: it is valid JSON but the wrong shape, like [1, 2, 3]. KeyError: a record is missing a field.
  • book.expenses = [] starts the app with an empty book.
  • The last two lines, if __name__ == "__main__": and the indented main(), are the __main__ guard. They arrive in 9.2.

Run it. With the same broken file:

Run it (repeated menus trimmed)
Warning: data file is corrupt; starting empty.

==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 2
No expenses yet.

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!
  • The app starts with a warning. Choosing 6 saves the empty book, which replaces the broken file.

9.2 main.py: Run It or Import It

Step-09 slide: main.py in the center, the concepts used on the left (The __main__ guard; Modules: import) and what we build on the right (if __name__ == "__main__":; python3 main.py; import main; main.show_menu(); main.py)

Write it. The file still ends with a bare main(), so the menu starts even when another file imports this one. Section 12's guard fixes that:

main.py (the last lines of the file)
-main()
+if __name__ == "__main__":
+    main()
  • When you run python3 main.py, Python sets __name__ to "__main__" in this file, the if is true, and main() starts the menu.
  • When another file imports it, __name__ is "main" instead, the if is false, and nothing starts. You get the functions without the menu.

Try it at a Python prompt. In your build folder, type python3, then import the file:

At a Python prompt
>>> import main
>>> main.show_menu()

==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
  • The import printed nothing: no menu, no prompt. Then calling show_menu() yourself printed the menu.
  • Type exit() to leave the prompt.
  • main.py is finished now.

Project files for this step: main.py | Optional drill: load JSON safely

Step-10: main.py: Run It All

Step-10 slide: main.py in the center, the concepts used on the left (Run a program: python3; Modules: from ... import; Classes: class; Files: write_text(), read_text(); Error handling: try / except) and what we build on the right (Choices 1 to 6; Total spent: 17.50; data/expenses.json; Restart; Broken file)

File: main.py

What you do in this step. You write no new code at all. You run your own finished tracker, use every menu choice, quit, start it again and watch your expenses come back.

Concepts used:

  • Run a program: python3, start the app you built (Section 02)
  • Modules: from ... import, main.py pulls in ExpenseBook and the five actions (Section 07)
  • Classes: class, Expense and ExpenseBook do the work behind the menu (Section 13)
  • Files: write_text(), read_text(), your expenses live in data/expenses.json (Section 10)
  • Error handling: try / except, a broken data file warns instead of crashing (Section 09)

Run your own code. Open a terminal in your build folder, 04-livecode-build-incremental/, and start it:

Run your finished tracker
python3 main.py

Use every choice. Add three expenses with choice 1, then try 2, 3, 4 and 5, and quit with 6:

Run it (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2.00
Category: transport
Added: Bus (2.00) [transport]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 1
Description: Lunch
Amount: 12.00
Category: food
Added: Lunch (12.00) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50
Bus                  transport            2.00
Lunch                food                12.00

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 17.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 4
food                15.50
transport            2.00

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: food
Description          Category           Amount
Coffee               food                 3.50
Lunch                food                12.00

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!
  • Choice 1, three times: Coffee, Bus and Lunch. Choice 2 prints all three in one aligned table.
  • Choice 3 adds every amount: Total spent: 17.50. Choice 4 splits the same money by category.
  • Choice 5 asks for one category and shows only the food rows.
  • Choice 6 is the one that changed. In Milestone 1 it only quit; now it saves first, then quits.
  • Two more replies worth trying: type abc at the Amount prompt and you get Invalid amount - enter a positive number like 12.50. Type 9 at the menu and you get Please choose a number from 1 to 6.

See what choice 6 wrote. Your expenses are now plain text on disk:

data/expenses.json (your own times will be different)
[
  {
    "description": "Coffee",
    "amount": 3.5,
    "category": "food",
    "created_at": "2026-09-21T07:12:02"
  },
  {
    "description": "Bus",
    "amount": 2.0,
    "category": "transport",
    "created_at": "2026-09-21T07:12:19"
  },
  {
    "description": "Lunch",
    "amount": 12.0,
    "category": "food",
    "created_at": "2026-09-21T07:12:35"
  }
]
  • One JSON object per expense, holding the four fields of an Expense.
  • created_at is the stamp store_expense() added when you typed the expense in.
  • 3.5 in the file and 3.50 on screen are the same number. The file holds the value, format_row() decides how it looks.

Start it again. Run python3 main.py a second time. Choose 2 straight away, then 3, then 6. You typed nothing in, and your three expenses are already there:

Run it again: your data comes back (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50
Bus                  transport            2.00
Lunch                food                12.00

==== Expense Tracker 2.0 ====
...
Choose 1-6: 3
Total spent: 17.50

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!
  • load() read the file before the first menu appeared. This is what the whole project is for: close the app, keep your data.

Break the file on purpose, one more time. You did this in Step-09. Do it again on the finished app: open data/expenses.json and delete everything after its first few lines, so the text is no longer valid JSON:

data/expenses.json (broken on purpose)
[
  {
    "description": "Coffee",
    "amount": 3.5,

Run it with the broken file. Choose 2, then 6. The app warns you and still starts:

Run it with the broken file (repeated menus trimmed)
Warning: data file is corrupt; starting empty.

==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 2
No expenses yet.

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!
  • One line of warning, then the menu. No traceback, and no crash.
  • Choice 6 then saves the empty book over the broken file, so data/expenses.json holds []. Add your three expenses again and the app is back to normal.

You are finished when:

  1. python3 main.py runs from your own folder, and every menu choice from 1 to 6 works.
  2. After choice 6, data/expenses.json holds your expenses, and the next run loads them. The broken-file run at the end leaves it holding [], so add your three expenses again first.
  3. A broken data/expenses.json prints the warning, and the app still starts.
  4. Your folder holds f1_is_valid_amount.py, f2_format_row.py, expense.py, expense_book.py, f5_actions.py and main.py, and no f3 or f4: Step-05 turned those two into ExpenseBook methods.

If something above did not work. Open the matching file in 01-orig-build-incremental/ and compare it with yours, line by line. That folder is the finished answer, and it is only there to compare against.

Look at what you made. Milestone 1 kept its expenses in one plain list that disappeared the moment the program ended. Your Milestone 2 has an Expense class, an ExpenseBook class that holds them and answers questions about them, two pure helpers, five actions, a menu driver, and a data file that outlives the program. You grew all of it yourself, one step at a time: four Milestone 1 files edited, two new files written, two deleted.

Project files for this step: main.py | 01-orig-build-incremental/

Step-11 (bonus): rich: Your App's First Installed Package

Step-11 slide 1 of 2: one venv, one package, a real table. Optional, and in its own folder: Section 12 meets your own app, and expense_book.py and main.py do not change at all. Set it up in the folder 05-livecode-build-incremental-with-rich: cd one level up, cd 05-livecode-build-incremental-with-rich, python3 -m venv .venv, source .venv/bin/activate, pip install rich. On Windows the source line activates from the Scripts folder instead, and your prompt then starts with (.venv), so you can see the toolbox is on. Write down what the project needs: pip freeze into requirements.txt gives rich==15.0.0 and the packages rich itself needs; anyone who clones the project runs pip install -r requirements.txt; and .venv/ goes into .gitignore, because it is rebuilt, never committed. If you skip the venv, the app stops before the menu: File f5_actions.py, line 3, in module, from rich.console import Console, ModuleNotFoundError: No module named 'rich'. Only the part that talks to the screen changes: f2_format_row.py changes with a new make_table(), f5_actions.py changes and prints one table, while expense.py, expense_book.py, f1_is_valid_amount.py and main.py do not change. One toolbox per project: turn it off again with deactivate and rich is gone, because it lives only inside this folder's .venv, never in your system Python

Step-11 slide 2 of 2: a table instead of printed lines. One new helper, make_table(), and the two actions print one table instead of a header and a row each. The new helper in f2_format_row.py: from rich.table import Table, then make_table() takes a list of Expense objects and returns a Table; it makes table = Table with the title Expenses, adds the columns Description, Category and Amount with justify set to right, loops for expense in expenses and calls table.add_row with expense.description, expense.category and the amount formatted with a comma and two decimals, then returns the table. Print it in f5_actions.py, in list_expenses() and filter_by_category(): the old import of format_row is removed, from rich.console import Console and from f2_format_row import make_table are added, and the header print, the loop over book.expenses and the print of format_row are all replaced by one line, Console().print(make_table(book.expenses)). Before, choice 2 prints lines: a Description, Category and Amount header, then Coffee food 3.50 and Bus transport 2.00. After, choice 2 prints a rich table titled Expenses, with the same three columns and the same two rows in a bordered box. The book never changed: the same ExpenseBook answers the same questions, only the screen code changed, which is exactly why Step-05 moved the work into the book

Optional, and in its own folder. The project is finished and needs nothing but Python. This step is where Section 12 meets your own app: one virtual environment, one installed package, and the list on screen becomes a real table.

Where you work. This whole step happens in 05-livecode-build-incremental-with-rich/, so your own 04-livecode-build-incremental/ stays exactly as you left it. That folder already holds the finished Step-11 code, and in f5_actions.py the Step-10 lines are kept as comments marked before Step-11, right above the new ones, so you can see exactly what changed. Make the environment, install the package, run it, and read along. The same code with its full docstrings is in 02-orig-build-incremental-with-rich/.

File: f2_format_row.py, plus f5_actions.py and a new requirements.txt

What you build in this step. A virtual environment inside that folder, rich installed into it, and a requirements.txt. The code change is already written for you to read: a make_table() helper that turns your expenses into a bordered table. expense.py, expense_book.py, f1_is_valid_amount.py and main.py do not change at all: only the part that talks to the screen does.

Concepts used:

  • Modules: from ... import, an installed package is imported like your own file (Section 07)
  • Virtual environments: python3 -m venv .venv, one clean toolbox per project (Section 12)
  • Packages: pip install, bring a package in from PyPI (Section 12)
  • Packages: pip freeze and requirements.txt, write down what your project needs (Section 12)

11.1 A virtual environment in the Step-11 folder

Step-11 slide: rich in the center, the concepts used on the left (Virtual environments: venv; Packages: pip install; Packages: pip freeze; Modules: from ... import) and what we build on the right (.venv; requirements.txt; make_table(); Choices 2 and 5; No venv; ExpenseBook)

Set it up. Step-01 moved you into 04-livecode-build-incremental/, so go up one level to the project folder first. Then go into the Step-11 folder, make the environment there, switch to it, and install the package:

Go up to the project folder first
cd ..
cd 05-livecode-build-incremental-with-rich
python3 -m venv .venv
source .venv/bin/activate
pip install rich

On Windows, the source line is .venv\Scripts\activate instead.

  • .venv is a folder created right there, next to main.py. It belongs to this project only.
  • Your prompt now starts with (.venv). That is how you know the project's own toolbox is switched on.

Write down what the project needs. pip freeze lists what is installed, and requirements.txt is how anyone else installs the same thing:

Still in 05-livecode-build-incremental-with-rich/
pip freeze > requirements.txt
02-orig-build-incremental-with-rich/requirements.txt
rich==15.0.0
  • Your own file has more lines than this one. pip freeze also writes down the packages rich needs to do its job, and that is normal.
  • Anyone who clones your project runs pip install -r requirements.txt and gets them all together.
  • Add .venv/ to your .gitignore: it is rebuilt from requirements.txt, never committed.

If you skip the venv. The app stops before the menu, because the package is not there:

What Python tells you (the end of the traceback)
    )
  File ".../05-livecode-build-incremental-with-rich/f5_actions.py", line 3, in <module>
    from rich.console import Console
ModuleNotFoundError: No module named 'rich'

11.2 f2_format_row.py: A Table Instead of a Line

Read it. In that same folder, open f2_format_row.py. format_row() stays as it is, and a new make_table() builds a rich table from the whole list:

f2_format_row.py (before: the finished Step-10 file)
"""f2: format_row, carried over from Milestone 1 and taught to read an object.

This is your own Milestone 1 file. Stage 2 changes three lines inside it: an expense is
an Expense object now, so the square brackets become dots. The f-string never changes.
"""
from expense import Expense


def format_row(expense: Expense) -> str:
    """Return one aligned table row for an expense."""
    desc = expense.description
    cat = expense.category
    amt = expense.amount
    return f"{desc:<20} {cat:<14} {amt:>10,.2f}"
02-orig-build-incremental-with-rich/f2_format_row.py (after: with make_table)
"""f2: format_row, carried over from Milestone 1 and taught to read an object.

This is your own Milestone 1 file. Stage 2 changes three lines inside it: an expense is
an Expense object now, so the square brackets become dots. The f-string never changes.
"""
from rich.table import Table

from expense import Expense


def format_row(expense: Expense) -> str:
    """Return one aligned table row for an expense."""
    desc = expense.description
    cat = expense.category
    amt = expense.amount
    return f"{desc:<20} {cat:<14} {amt:>10,.2f}"


def make_table(expenses: list[Expense]) -> Table:
    """Build a rich table with one row per expense."""
    table = Table(title="Expenses")
    table.add_column("Description")
    table.add_column("Category")
    table.add_column("Amount", justify="right")
    for expense in expenses:
        table.add_row(expense.description, expense.category, f"{expense.amount:,.2f}")
    return table

What changed.

02-orig-build-incremental-with-rich/f2_format_row.py (what changed)
 """
+from rich.table import Table
+
 from expense import Expense
 ...
     return f"{desc:<20} {cat:<14} {amt:>10,.2f}"
+
+
+def make_table(expenses: list[Expense]) -> Table:
+    """Build a rich table with one row per expense."""
+    table = Table(title="Expenses")
+    table.add_column("Description")
+    table.add_column("Category")
+    table.add_column("Amount", justify="right")
+    for expense in expenses:
+        table.add_row(expense.description, expense.category, f"{expense.amount:,.2f}")
+    return table
  • Table comes from the installed package, and its import sits above your own imports.
  • add_column("Amount", justify="right") keeps the money right-aligned, the job the f-string widths did.
  • format_row() is still there, so you can compare the two ways side by side.

11.3 f5_actions.py: Print the Table

See it wired in. Also in that folder, the two actions that printed a header and a row per expense now print one table:

f5_actions.py (before: the finished Step-10 file)
"""
f5_actions.py: Menu Actions

This is your own Milestone 1 file, edited stage by stage. Each function still:
- Gets input from the user.
- Calls the helpers.
- Displays the results.

What changed: every action now receives the ExpenseBook instead of a bare list, so
`expenses` became `book` in each signature. The imports of f3 and f4 are gone, because
those two helpers became ExpenseBook methods.

main.py imports these functions and calls them based on the user's menu choice.
"""

from expense_book import ExpenseBook
from f1_is_valid_amount import is_valid_amount
from f2_format_row import format_row


def add_expense(book: ExpenseBook) -> None:
    """Prompt the user for a new expense and add it to the book."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    # The book builds the Expense object, stamps it with the time, and keeps it.
    expense = book.store_expense(description, amount, category)

    print(f"Added: {expense.description} ({expense.amount:,.2f}) [{expense.category}]")


def list_expenses(book: ExpenseBook) -> None:
    """Display all recorded expenses."""
    if not book.expenses:
        print("No expenses yet.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in book.expenses:
        print(format_row(expense))


def show_total(book: ExpenseBook) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {book.compute_total():,.2f}")


def show_by_category(book: ExpenseBook) -> None:
    """Display the total spent in each category."""
    totals = book.compute_by_category()

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")


def filter_by_category(book: ExpenseBook) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = book.filter(wanted_category)

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")

    for expense in matches:
        print(format_row(expense))
02-orig-build-incremental-with-rich/f5_actions.py (after: printing a rich table)
"""
f5_actions.py: Menu Actions

This is your own Milestone 1 file, edited stage by stage. Each function still:
- Gets input from the user.
- Calls the helpers.
- Displays the results.

What changed: every action now receives the ExpenseBook instead of a bare list, so
`expenses` became `book` in each signature. The imports of f3 and f4 are gone, because
those two helpers became ExpenseBook methods.

main.py imports these functions and calls them based on the user's menu choice.
"""

from expense_book import ExpenseBook
from f1_is_valid_amount import is_valid_amount
from rich.console import Console

from f2_format_row import make_table


def add_expense(book: ExpenseBook) -> None:
    """Prompt the user for a new expense and add it to the book."""
    description = input("Description: ").strip()

    if description == "":
        description = "(no description)"

    amount_text = input("Amount: ").strip()

    if not is_valid_amount(amount_text):
        print("Invalid amount - enter a positive number like 12.50")
        return

    category = input("Category: ").strip().lower()

    if category == "":
        category = "uncategorized"

    amount = float(amount_text)

    # The book builds the Expense object, stamps it with the time, and keeps it.
    expense = book.store_expense(description, amount, category)

    print(f"Added: {expense.description} ({expense.amount:,.2f}) [{expense.category}]")


def list_expenses(book: ExpenseBook) -> None:
    """Display all recorded expenses."""
    if not book.expenses:
        print("No expenses yet.")
        return

    Console().print(make_table(book.expenses))


def show_total(book: ExpenseBook) -> None:
    """Display the total amount spent."""
    print(f"Total spent: {book.compute_total():,.2f}")


def show_by_category(book: ExpenseBook) -> None:
    """Display the total spent in each category."""
    totals = book.compute_by_category()

    if not totals:
        print("No expenses yet.")
        return

    for category, total in totals.items():
        print(f"{category:<14} {total:>10,.2f}")


def filter_by_category(book: ExpenseBook) -> None:
    """Display expenses that belong to one category."""
    wanted_category = input("Category to show: ").strip().lower()

    matches = book.filter(wanted_category)

    if not matches:
        print(f"No expenses in '{wanted_category}'.")
        return

    Console().print(make_table(matches))

What changed.

02-orig-build-incremental-with-rich/f5_actions.py (what changed)
 from f1_is_valid_amount import is_valid_amount
-from f2_format_row import format_row
+from rich.console import Console
+
+from f2_format_row import make_table
 ...
-    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")
-
-    for expense in book.expenses:
-        print(format_row(expense))
+    Console().print(make_table(book.expenses))
 ...
-    print(f"{'Description':<20} {'Category':<14} {'Amount':>10}")
-
-    for expense in matches:
-        print(format_row(expense))
+    Console().print(make_table(matches))
  • Console().print(...) is how rich writes to the screen. A plain print() would show the table object, not the table.
  • Both the list view and the one-category view call the same make_table().
  • ExpenseBook never changes. The book computes, and only the actions talk to the screen.

11.4 Run It with the Table

Run it. Still inside 05-livecode-build-incremental-with-rich/, with (.venv) on your prompt:

In 05-livecode-build-incremental-with-rich/
python3 main.py

Add two expenses, choose 2, then 5 for food, then 6:

What you see (repeated menus trimmed)
==== Expense Tracker 2.0 ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Save and quit
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2
Category: transport
Added: Bus (2.00) [transport]

==== Expense Tracker 2.0 ====
...
Choose 1-6: 2
              Expenses              
┏━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┓
┃ Description ┃ Category  ┃ Amount ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━┩
│ Coffee      │ food      │   3.50 │
│ Bus         │ transport │   2.00 │
└─────────────┴───────────┴────────┘

==== Expense Tracker 2.0 ====
...
Choose 1-6: 5
Category to show: food
             Expenses              
┏━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┓
┃ Description ┃ Category ┃ Amount ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━┩
│ Coffee      │ food     │   3.50 │
└─────────────┴──────────┴────────┘

==== Expense Tracker 2.0 ====
...
Choose 1-6: 6
Saved. Goodbye!
  • The same program, the same data, a better screen.
  • Turn the environment off again with deactivate. Run the app then, and the package is gone: rich lives only inside this folder's .venv.
  • 02-orig-build-incremental-with-rich/ holds the same code with its full docstrings, if you want the longer explanations.

Project files for this step: f2_format_row.py (with make_table) | f5_actions.py (printing the table) | requirements.txt | the Step-11 folder

Files in this project

  • 01-orig-build-incremental/: the finished files from Step-01 to Step-09, with f1_is_valid_amount.py and f2_format_row.py carried over from Milestone 1, the new expense.py and expense_book.py, the edited f5_actions.py and main.py, and a README.md with the build order.
  • 02-orig-build-incremental-with-rich/ (optional): the same tracker finished with rich, the answer for Step-11.
  • 03-orig-warmup/ (optional): six drills, each practicing one new skill on its own (01 covers two). Not needed to follow the steps above.
  • 04-livecode-build-incremental/: your starting point, the six Milestone 1 files without comments, each function with its one-line docstring. Build the project here, and compare with 01-orig-build-incremental/.
  • 05-livecode-build-incremental-with-rich/ (optional): the Step-11 folder. It holds the finished Step-11 code with short docstrings, and f5_actions.py keeps the Step-10 lines as comments so you can see both sides. You add the virtual environment and rich here.

Next

Milestone Project 2 conclusion: what you built. One tracker, six files, and the ideas from Sections 07 to 13 working together, and your data is still there after you quit. The six files and the job each one has: expense.py is one expense, an object with four attributes and a __repr__; expense_book.py is the book, it holds the list and does the work on it; f1_is_valid_amount.py checks the amount the user typed; f2_format_row.py turns one expense into one aligned row; f5_actions.py holds the five actions behind the menu; and main.py has the menu, the loop, the save on quit and the guard. What it does when you run it: adds an expense stamped with created_at, lists them all one aligned row each, totals the spending with book.compute_total(), totals it per category, shows one category with book.filter(), and saves to data/expenses.json and quits, then loads it back on the next run. The ideas you used and where each one came from: Section 13 classes, __init__, self and methods; Section 08 type hints on every function; Section 10 Path, write_text, read_text and json; Section 09 try and except, and one except for several errors; Section 11 datetime and .isoformat(); Section 07 your own modules and from ... import; and Section 12 the __main__ guard, plus venv and pip in the bonus. Where this goes next: your ExpenseBook has no input() and no print(), so the same class can sit behind a web API. Next in the StackSimplify family, and this project is the foundation they build on: the Complete Python Bootcamp, Python for Machine Learning, and the MLOps Courses

You now have the Python foundation for the rest of the StackSimplify family: the Complete Python Bootcamp, Python for Machine Learning, and the MLOps courses. Because your ExpenseBook has no input() or print(), the same logic is ready to sit behind a web API in a later course, with your beginner code serving real requests.

Prefer to learn by watching?

The full video course teaches every concept on screen and builds the projects with you, step by step.

Get the Video Course


Milestone Project 1: CLI Expense Tracker

🎉 New Course
Python for Absolute Beginners
$15.99 $84.99 81% OFF
Get the Video Course