Skip to content

Milestone Project 1: CLI Expense Tracker

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

Your first capstone. Build a small command-line Expense Tracker that keeps a list of expenses in memory and lets the user add, view, and summarize them through a menu. It is built with only Sections 01-07 (variables, strings and f-strings, conditionals, collections, loops, functions, input), so it works as a checkpoint right after Functions. Two small things in it are new, isdecimal() and lining up table columns, and the Rules below explain both. You do not need try/except, files, or classes yet.

Step-00: what you will build. Seven steps build the Expense Tracker (CLI), through the five menu actions (Add expense, List expenses, Total spent, Spending by category, Show one category), the functions behind each, and the step that builds each

Milestone Project 1 concept map

Project 1: which folder is which. Four folders in milestone-projects/milestone-project-1/, and one of them is yours to type in. build-incremental is the answer: the finished files for Steps 01 to 06, your answer key. build-incremental-lecture is where you build: six empty files, and you build Steps 01 to 06 in them. solution is one file: the whole program in expense_tracker.py, which you compare at Step-07. warmup is optional: six short drills, one per skill, an optional extra. In short, everything you type goes in build-incremental-lecture, and you compare it with build-incremental as you go

Step-00: What You Will Build

This page builds the whole project one file at a time. Step-00 is the assignment. Step-01 to Step-07 build the app: first main.py and the five stub actions it imports, then one helper per step from Step-02 to Step-05, then the last action in Step-06. From Step-01 on, the app runs at the end of every step. Step-07 runs the finished program and compares it with the one-file solution.

What you build

A menu that repeats until the user quits:

  1. Add expense: ask for a description, an amount, and a category, then store it.
  2. List expenses: print every expense in a neat, aligned table.
  3. Total spent: print the sum of all amounts.
  4. Spending by category: print the total for each category.
  5. Show one category: ask for a category and print only its expenses.
  6. Quit.

What you practice

  • Writing functions as small, testable building blocks
  • A list of dictionaries as the data model (one dict per expense)
  • Loops, conditionals, f-string tables, and validating input()
  • Importing a function from another file (Section 07, Step-11)

Rules

  • Validate the amount WITHOUT try/except: accept a positive number like 12 or 12.50; reject 0, negatives, and anything else with a friendly message and return to the menu. Use string methods to check the digits, then float() to check it is above zero. Section 03 taught isdigit(); use its stricter sibling isdecimal() here, because isdigit() also says True for characters like a superscript two that float() cannot read.
  • An empty category becomes uncategorized; an empty description becomes (no description).
  • Store categories in lowercase, so Food and food are the same category.
  • Show money with 2 decimals and thousands commas: the f-string spec :,.2f.
  • One new f-string trick (the only f-string spec here that Section 03 did not teach): a width + alignment in the format spec. :<20 left-aligns a value in a 20-wide column, :<14 does the same in 14, :>10 right-aligns in 10, and :>10,.2f does the same for money. A single space between the columns keeps them apart when a value fills its whole width. That is how the table columns line up.
  • Keep the data in memory as a list of dicts (one dict per expense).

Sample run

The menu prints again before every Choose 1-6: prompt. Here is a short session with two expenses, then the total (3.50 + 2.00 = 5.50):

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

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. Choose 6 to quit.

For the finished version, run it from this project folder:

Run the finished program
python3 solution/expense_tracker.py

Test data

Five sample expenses you can use for testing.

Test data (five expenses)
expenses = [
    {"description": "Lunch", "amount": 250, "category": "food"},
    {"description": "Bus", "amount": 100, "category": "transport"},
    {"description": "Dinner", "amount": 500, "category": "food"},
    {"description": "Taxi", "amount": 300, "category": "transport"},
    {"description": "Coffee", "amount": 150, "category": "food"},
]

How to build it

We build the app in three small layers, and the program RUNS at the end of every step:

Layer Files Its job
Pure helpers f1 to f4 turn data into a value, with no input() and no print()
Actions f5_actions.py what each menu choice DOES: wraps input() and print() around the helpers
Menu driver main.py show_menu() + main(): show the menu, read a choice, call the action

One step at a time; the table shows what you write and what the app can do after it:

Step You write Then the app can
Step-01 main.py: the menu, the loop and the if / elif chain, plus f5_actions.py with five stub actions run the menu, and quit
Step-02 f1_is_valid_amount.py, wired to choice 1 add an expense
Step-03 f2_format_row.py, wired to choice 2 print a neat table
Step-04 f3_compute_total.py, wired to choice 3 add everything up
Step-05 f4_compute_by_category.py, wired to choice 4 break it down by category
Step-06 the last action in f5_actions.py, wired to choice 5 show one category
Step-07 nothing new: run it all, then read solution/expense_tracker.py everything

The rule we never break: build a tiny piece, RUN it, see it work, then add the next piece. "Test" always means run the app and look at the output.

Build it yourself first

Each step below shows the code you write. Try writing it on your own before you read the code: the effort is what makes it stay in your memory. If you want some practice first, the optional drills in warmup/ each practice one piece of the project on its own.

File: main.py and f5_actions.py (here you write main.py in full and f5_actions.py as five stubs; the second link opens the finished file)

What you build in this step. The menu driver and the five actions it calls: main.py shows the menu, reads a choice and calls the matching action, and f5_actions.py holds those actions as stubs, placeholder functions that only print which option you picked. Both files at once, because main.py imports the other one and cannot run without it.

Concepts used:

  • Output: print(), the banner and the six menu lines (Section 02)
  • Output: print(a, b), several values in one call: print("Expenses:", expenses) (Section 02)
  • Escape sequence: \n, the escape that prints a blank line first (Section 02)
  • User input: input() and .strip(), read the menu choice (Sections 02, 03)
  • Conditionals: if / elif / else, one branch per menu choice (Section 04)
  • Lists: expenses = [], the one list every action shares (Section 05)
  • Loops: while True and break, repeat the menu until choice 6 (Section 06)
  • Functions: def and call, show_menu(), main() and the five stubs (Section 07)
  • Parameters: (expenses), each stub takes the shared list (Section 07)
  • Modules: your own .py file, f5_actions.py, which main.py imports (Section 07)
  • Modules: from ... import, the five actions from f5_actions.py (Section 07)

Before you start. Build in the build-incremental-lecture/ folder of this project. It already holds the six files you write, main.py, f1 to f4 and f5_actions.py, all empty, so you fill in one file per step. The build-incremental/ folder next to it holds the same six files finished: do not type into it, compare your work with it as you go.

Start the build
cd build-incremental-lecture

1.1 main.py: The Menu Shell

Step-01 slide: main.py in the center, the concepts used on the left (Output: print(); User input: input() and .strip(); Conditionals: if / elif / else; Lists: expenses = an empty list; Loops: while True and break; Functions: def and call; Modules: from ... import) and what we build on the right (show_menu(); main(); expenses; Choices 1 to 5; Choice 6; Any other input; Five imported names)

Write it. Open the empty main.py and type:

main.py
"""main.py: the menu driver.

Shows the menu, reads a choice, and calls the matching action. The
actions live in f5_actions.py; the pure helpers live in f1..f4. The same
program in ONE file is in ../solution/expense_tracker.py.
"""
from f5_actions import (
    add_expense,
    list_expenses,
    show_total,
    show_by_category,
    filter_by_category,
)


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")


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()
  • show_menu() prints the banner and the six options. The blank print() gives a little space each time the menu comes back.
  • expenses = [] is the ONE list the whole program shares. It is passed into every action, so an action can read it or add to it.
  • while True: shows the menu and reads a choice, forever. .strip() removes stray spaces, so " 6 " still counts as 6.
  • The choice is compared as TEXT ("6", not 6), because input() always returns a string.
  • break on choice 6 is the only way out of the loop. The last line, main(), starts the program.

Warning

break is the ONLY way out of while True. Forget it, and choice 6 never stops the loop. If that happens, press Ctrl+C to stop the program.

Step-01 slide: f5_actions.py in the center, the concepts used on the left (Functions: def and call; Parameters: (expenses); Modules: your own .py file; Output: print(a, b); Escape sequence: \n) and what we build on the right (add_expense(); list_expenses(); show_total(); show_by_category(); filter_by_category(); Expenses: an empty list)

Then fill in the empty f5_actions.py with five stubs. Each one only prints which option you picked and the current list:

f5_actions.py (the five stubs)
"""
f5_actions.py: Menu Actions

This module contains the functions that run when the user selects a menu option.

Each function:
- Gets input from the user.
- Calls helper functions from f1 through f4.
- Displays the results.
- Updates the expenses list when needed.

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


def add_expense(expenses):
    print("\n--> Option 1 selected: Add Expense")
    print("Expenses:", expenses)


def list_expenses(expenses):
    print("\n--> Option 2 selected: List Expenses")
    print("Expenses:", expenses)


def show_total(expenses):
    print("\n--> Option 3 selected: Total Spent")
    print("Expenses:", expenses)


def show_by_category(expenses):
    print("\n--> Option 4 selected: Spending by Category")
    print("Expenses:", expenses)


def filter_by_category(expenses):
    print("\n--> Option 5 selected: Show One Category")
    print("Expenses:", expenses)
  • main.py imports these five names. Right now they are stubs, but the import never changes: later steps replace the stub BODIES, one at a time.
  • A stub prints the option and the expenses list, so you can SEE the menu calling the right function before any feature exists.
  • Every stub takes the same one parameter, expenses, because main() passes its list to every action it calls.

Run it.

Run the menu shell
python3 main.py
Run it (repeated menus trimmed)
==== Expense Tracker ====
1) Add expense
2) List expenses
3) Total spent
4) Spending by category
5) Show one category
6) Quit
Choose 1-6: 1

--> Option 1 selected: Add Expense
Expenses: []

==== Expense Tracker ====
...
Choose 1-6: 2

--> Option 2 selected: List Expenses
Expenses: []

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

No feature works yet, and that is the point: a program that runs and does nothing is a much better start than a program that does everything and will not start.

Project files for this step: main.py | f5_actions.py (the finished version) | Optional drill: the menu loop and its stubs

Step-02: f1_is_valid_amount.py: Validate the Amount

File: f1_is_valid_amount.py, plus the add_expense action in f5_actions.py

What you build in this step. A helper that decides whether the text a user typed is a real amount, and the Add action that uses it. After this step, choice 1 adds an expense.

Concepts used:

  • User input: input(), read the description, amount and category (Section 02)
  • Type conversion: float(), turn the checked amount text into a number (Section 02)
  • String methods: .strip(), .split(), trim the spaces, then split the amount at the dot (Sections 03, 05)
  • String methods: .strip(), .lower(), clean the typed description and category (Section 03)
  • Conditionals: if / elif / else, no dot, one dot, or anything else (Section 04)
  • Conditionals: if, if not, the empty-text defaults and the bad-amount check (Section 04)
  • Logical operators: and, not, decimal_parts[0].isdecimal() and decimal_parts[1].isdecimal(), then if not is_valid_format: (Section 04)
  • Lists: len() and [0], len(decimal_parts) and decimal_parts[0] (Section 05)
  • Dictionaries and lists: {...}, .append(), one dict per expense, added to the list (Section 05)
  • Return values: return, is_valid_amount() returns True or False (Section 07)
  • Early return: return, leave add_expense() when the amount is not valid (Section 07)
  • Modules: from ... import, the helper import at the top of f5_actions.py (Section 07)
  • String method: .isdecimal(), the stricter sibling of Section 03's .isdigit() (New in this project)

2.1 f1_is_valid_amount.py: Validate the Amount

Step-02 slide: f1_is_valid_amount.py in the center, the concepts used on the left (String methods: .strip(), .split(); String method: .isdecimal(); Lists: len() and (0); Conditionals: if / elif / else; Logical operators: and, not; Type conversion: float(); Return values: return) and what we build on the right (is_valid_amount(); Spaces trimmed; Whole number; One dot; Not digits; Not above zero; Safe float())

Write it. Open the empty f1_is_valid_amount.py and type:

f1_is_valid_amount.py
"""f1: is_valid_amount lives here.

main.py imports it. Data in, True or False out: no input(), no print(). You will see it
work the moment you wire it into the menu and run the app.
"""


def is_valid_amount(amount_text):
    """True if amount_text is a positive number like '12' or '12.50' (rejects 0 and negatives).

    Uses string methods and float(). No try/except (that is Section 09).
    """
    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
    # every character is a digit now, so float() is safe: a real amount is above zero
    return float(amount_text) > 0
  • .split(".") cuts the text at each dot: "12" gives one part, "12.50" gives two, "1.2.3" gives three.
  • One part must be all digits; two parts must both be all digits; anything else is not a number.
  • .isdecimal() is True only for digits, so "-5" and "abc" fail. We use it instead of .isdigit(), which also accepts characters like a superscript two that float() cannot read.
  • The early return False leaves before float(), so float() only ever sees digits, with at most one dot between them.
  • A real expense is above zero, so "0" and "0.00" fail too.

A pure helper prints nothing when you run it on its own, so wire it into the menu and run the app.

2.2 f5_actions.py: Wire Up add_expense()

Step-02 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; User input: input(); String methods: .strip(), .lower(); Conditionals: if, if not; Early return: return; Type conversion: float(); Dictionaries and lists: {...}, .append()) and what we build on the right (add_expense(); The import; Empty description; Bad amount; Category; One expense; Choice 1 output)

Wire it in. In f5_actions.py, add the import at the top, just below the docstring, and replace the add_expense stub:

f5_actions.py (add this import, then replace the add_expense stub)
from f1_is_valid_amount import is_valid_amount


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)

    # Create one expense dictionary and add it to the expenses list.
    expense = {
        "description": description,
        "amount": amount,
        "category": category,
    }

    expenses.append(expense)

    print(f"Added: {description} ({amount:,.2f}) [{category}]")
  • It checks the amount before it asks for the category. If the amount is not valid, it prints a friendly message and returns to the menu, and nothing is added.
  • An empty description becomes (no description), and an empty category becomes uncategorized.
  • .lower() makes Food and food the same category.
  • float(amount_text) is safe here, because f1 already approved the text.
  • expenses.append(expense) adds the dict to the SAME list main() created, so every other action sees it.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker ====
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 ====
...
Choose 1-6: 1
Description: Bus
Amount: -5
Invalid amount - enter a positive number like 12.50

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

Warning

Check the amount BEFORE calling float(). float("abc") crashes with a ValueError, and try/except does not arrive until Section 09.

Project files for this step: f1_is_valid_amount.py | f5_actions.py | Optional drill: validate a number | Optional drill: a list of dicts

Step-03: f2_format_row.py: One Aligned Row

File: f2_format_row.py, plus the list_expenses action in f5_actions.py

What you build in this step. A helper that turns one expense into one neatly lined-up row, and the List action that prints them as a table. This step also teaches a new technique: a width and alignment in an f-string.

Concepts used:

  • f-strings: f"{value}", build the row text (Section 03)
  • Format spec: :,.2f, thousands commas and two decimals (Section 03)
  • Truthy and falsy: if not, an empty list is falsy, so if not expenses: (Section 04)
  • Dictionary lookup: expense["key"], the description, category and amount (Section 05)
  • Loops: for over a list, one row per expense (Section 06)
  • Return values: return, hand the row back (Section 07)
  • Early return: return, leave list_expenses() when there is nothing to show (Section 07)
  • Modules: from ... import, the format_row import in f5_actions.py (Section 07)
  • Width and alignment: :<20 :>10, line up the columns (New in this project)

Why widths? Here are the same two rows, first printed without widths, then with them:

Without widths
Description Category Amount
Coffee food 3.50
Train ticket transport 1,234.50
With widths
Description          Category           Amount
Coffee               food                 3.50
Train ticket         transport        1,234.50

3.1 f2_format_row.py: One Aligned Row

Step-03 slide: f2_format_row.py in the center, the concepts used on the left (Dictionary lookup: expense("key"); f-strings: f"{value}"; Width and alignment: :<20 :>10; Format spec: :,.2f; Return values: return) and what we build on the right (format_row(); Three columns; Column widths; Amount; A pure helper)

Write it. Open the empty f2_format_row.py and type:

f2_format_row.py
"""f2: format_row lives here.

main.py imports it. You will see it in action when the List action prints a table.
"""


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}"
  • {desc:<20} puts the description in a field 20 characters wide, left-aligned (<).
  • {cat:<14} puts the category in a 14-wide field, also left-aligned.
  • {amt:>10,.2f} puts the amount in a 10-wide field, right-aligned (>), with 2 decimals and thousands commas. Right-aligned numbers line up on the decimal point.
  • Every row uses the same widths, so the columns line up when you print several rows.

3.2 f5_actions.py: Wire Up list_expenses()

Step-03 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Truthy and falsy: if not; Early return: return; Loops: for over a list; Width and alignment: :<20 :>10) and what we build on the right (list_expenses(); The import; Empty list; Header row; One row per expense)

Wire it in. In f5_actions.py, add the import and replace the list_expenses stub:

f5_actions.py (add this import, then replace the list_expenses stub)
from f2_format_row import format_row


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))
  • if not expenses: catches the empty list: it prints No expenses yet. and returns.
  • The header uses the SAME widths as the rows (<20, <14, >10), so each title sits right on top of its column.
  • Then one format_row(expense) line is printed per expense.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker ====
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 ====
...
Choose 1-6: 1
Description: Coffee
Amount: 3.50
Category: food
Added: Coffee (3.50) [food]

==== Expense Tracker ====
...
Choose 1-6: 1
Description: Train ticket
Amount: 1234.50
Category: transport
Added: Train ticket (1,234.50) [transport]

==== Expense Tracker ====
...
Choose 1-6: 2
Description          Category           Amount
Coffee               food                 3.50
Train ticket         transport        1,234.50

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

Tip

Keep the header widths IDENTICAL to the row widths. If they differ, the columns no longer line up and the table looks broken.

Project files for this step: f2_format_row.py | f5_actions.py | Optional drill: an f-string table

Step-04: f3_compute_total.py: Add Every Amount

File: f3_compute_total.py, plus the show_total action in f5_actions.py

What you build in this step. A helper that adds up every amount, and the Total action that prints the sum. After this step, choice 3 works.

Concepts used:

  • Data types: float, total = 0.0 (Section 02)
  • f-strings: f"{value}", the Total spent line (Section 03)
  • Format spec: :,.2f, thousands commas and two decimals (Section 03)
  • Augmented assignment: +=, grow the running total (Section 04)
  • Dictionary lookup: expense["key"], expense["amount"] (Section 05)
  • Loops: for over a list, visit every expense (Section 06)
  • Accumulator: a running total, start at zero and add each amount (Section 06)
  • Functions: def and call, show_total() calls compute_total() (Section 07)
  • Modules: from ... import, the compute_total import in f5_actions.py (Section 07)
  • Return values: return, hand the number back (Section 07)

4.1 f3_compute_total.py: Add Every Amount

Step-04 slide: f3_compute_total.py in the center, the concepts used on the left (Data types: float; Loops: for over a list; Dictionary lookup: expense("key"); Augmented assignment: +=; Accumulator: a running total; Return values: return) and what we build on the right (compute_total(); total; Each expense; Empty list; A pure helper)

Write it. Open the empty f3_compute_total.py and type:

f3_compute_total.py
"""f3: compute_total lives here.

main.py imports it. You will see it work when the Total action prints the sum.
"""


def compute_total(expenses):
    """Return the sum of every expense amount."""
    total = 0.0
    for expense in expenses:
        total += expense["amount"]
    return total
  • total = 0.0 starts the running total at zero. It is a float because amounts have decimals.
  • The loop visits each expense, and total += expense["amount"] adds its amount to the total.
  • After the loop, return total hands back the sum. This pattern (start at zero, add each one, return) is called an accumulator.

4.2 f5_actions.py: Wire Up show_total()

Step-04 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Functions: def and call; f-strings: f"{value}"; Format spec: :,.2f) and what we build on the right (show_total(); The import; No expenses; Choice 3 output)

Wire it in. In f5_actions.py, add the import and replace the show_total stub:

f5_actions.py (add this import, then replace the show_total stub)
from f3_compute_total import compute_total


def show_total(expenses):
    """Display the total amount spent."""
    print(f"Total spent: {compute_total(expenses):,.2f}")
  • The helper computes and the action prints: compute_total() returns the number, and print() shows it with an f-string.
  • :,.2f adds thousands commas and two decimals, so a big total reads as 1,234.50.
  • There is no empty-list check here: with no expenses, compute_total() returns 0.0 and the line prints Total spent: 0.00.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker ====
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 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2.00
Category: transport
Added: Bus (2.00) [transport]

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

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

Project files for this step: f3_compute_total.py | f5_actions.py | Optional drill: totals

Step-05: f4_compute_by_category.py: Totals per Category

File: f4_compute_by_category.py, plus the show_by_category action in f5_actions.py

What you build in this step. A helper that keeps one running total per category in a dict, and the action that prints them. After this step, choice 4 works.

Concepts used:

  • Truthy and falsy: if not, an empty dict is falsy, so if not totals: (Section 04)
  • Dictionaries: totals = {}, one running total per category (Section 05)
  • Dictionary lookup: expense["key"], expense["category"] and expense["amount"] (Section 05)
  • Dictionary method: .get() with a default, totals.get(category, 0.0) (Section 05)
  • Loops: for over a list, visit every expense (Section 06)
  • Loops: for with .items(), each category with its total (Sections 05, 06)
  • Accumulator: a running total, each category adds up its own total (Section 06)
  • Return values: return, hand the dict back (Section 07)
  • Early return: return, leave show_by_category() when there is nothing to show (Section 07)
  • Modules: from ... import, the compute_by_category import in f5_actions.py (Section 07)
  • Width and alignment: :<14 :>10, the same columns as the table (New in this project)

5.1 f4_compute_by_category.py: Totals per Category

Step-05 slide: f4_compute_by_category.py in the center, the concepts used on the left (Dictionaries: totals = {}; Loops: for over a list; Dictionary lookup: expense("key"); Dictionary method: .get() with a default; Accumulator: a running total; Return values: return) and what we build on the right (compute_by_category(); New category; Same category; Empty list; A pure helper)

Write it. Open the empty f4_compute_by_category.py and type:

f4_compute_by_category.py
"""f4: compute_by_category lives here.

main.py imports it. You will see it work when the Spending-by-category action runs.
"""


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
  • totals = {} starts empty. Its keys will be category names, and its values will be running totals.
  • totals.get(category, 0.0) returns the total so far for that category, or 0.0 the first time we see it.
  • So the first food expense adds to zero, and every later food expense adds to what is already there. Because .get() gives 0.0 for a category it has not seen yet, we do not need to check first whether the category is already in the dictionary.

Test data. Five sample expenses for testing:

Test data (five expenses)
expenses = [
    {"description": "Lunch", "amount": 250, "category": "food"},
    {"description": "Bus", "amount": 100, "category": "transport"},
    {"description": "Dinner", "amount": 500, "category": "food"},
    {"description": "Taxi", "amount": 300, "category": "transport"},
    {"description": "Coffee", "amount": 150, "category": "food"},
]

5.2 f5_actions.py: Wire Up show_by_category()

Step-05 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Truthy and falsy: if not; Early return: return; Loops: for with .items(); Width and alignment: :<14 :>10) and what we build on the right (show_by_category(); The import; Empty dict; One line per category; Choice 4 output)

Wire it in. In f5_actions.py, add the import and replace the show_by_category stub:

f5_actions.py (add this import, then replace the show_by_category stub)
from f4_compute_by_category import compute_by_category


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}")
  • It asks the helper for the totals dict. An empty dict means there are no expenses yet.
  • .items() gives each category with its total, printed with the same widths as the category and amount columns of the Step-03 table, :<14 and :>10,.2f.
  • There is no header line here: choice 4 prints one line per category, the category and its total, and nothing else.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker ====
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 ====
...
Choose 1-6: 1
Description: Lunch
Amount: 12.00
Category: food
Added: Lunch (12.00) [food]

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

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

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

Note

Plain totals[category] would crash with a KeyError the first time a category appears. The 0.0 default in totals.get(category, 0.0) is what lets a brand-new category start from zero.

Project files for this step: f4_compute_by_category.py | f5_actions.py | Optional drill: totals

File: f5_actions.py

What you build in this step. The fifth and last action, which shows only one category, and then the finished f5_actions.py read top to bottom. After this step, every menu choice works.

Concepts used:

  • User input: input().strip().lower(), read and clean the typed category (Sections 02, 03)
  • f-strings: f"{value}", the No expenses in '...' message (Section 03)
  • Truthy and falsy: if not, an empty list is falsy, so if not matches: (Section 04)
  • List comprehension: filter with if, keep only the typed category (Section 06)
  • Functions: def and call, the five actions main.py calls (Section 07)
  • Parameters: (expenses), every action takes the same list (Section 07)
  • Early return: return, leave when there are no matches; the rows reuse format_row from Step-03 (Section 07)
  • Modules: from ... import, the four helper imports at the top of the file (Section 07)
  • Width and alignment: :<20 :>10, the same header as Step-03 (New in this project)

6.1 f5_actions.py: The Last Action, filter_by_category()

Step-06 slide: f5_actions.py in the center, the concepts used on the left (User input: input().strip().lower(); List comprehension: filter with if; Truthy and falsy: if not; Early return: return; f-strings: f"{value}"; Width and alignment: :<20 :>10) and what we build on the right (filter_by_category(); matches; No matches; Matches)

Wire it in. In f5_actions.py, replace the filter_by_category stub. It reuses format_row, which is already imported, so no new import is needed:

f5_actions.py (replace the filter_by_category stub)
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))
  • The typed category gets .strip().lower(), the same cleaning add_expense gave every stored category, so Food matches food.
  • The list comprehension reads: "every expense in expenses whose category equals wanted_category". The result is a new list that holds only the matching expenses.
  • No matches? A friendly message, then back to the menu. Otherwise the same header and rows as the List action.

Run it.

Run it (repeated menus trimmed)
==== Expense Tracker ====
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 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2.00
Category: transport
Added: Bus (2.00) [transport]

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

==== Expense Tracker ====
...
Choose 1-6: 5
Category to show: travel
No expenses in 'travel'.

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

6.2 f5_actions.py: The Finished File

Step-06 slide: f5_actions.py in the center, the concepts used on the left (Modules: from ... import; Functions: def and call; Parameters: (expenses)) and what we build on the right (Four imports; Five actions; Every action; Helpers; Three layers)

Nothing new is written here. This is the finished f5_actions.py in one piece, so you can compare it with the file you grew step by step. All four helper imports sit together at the top.

f5_actions.py
"""
f5_actions.py: Menu Actions

This module contains the functions that run when the user selects a menu option.

Each function:
- Gets input from the user.
- Calls helper functions from f1 through f4.
- Displays the results.
- Updates the expenses list when needed.

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

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)

    # Create one expense dictionary and add it to the expenses list.
    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))
  • Every action has the same shape: call a helper, then print. Two of them, add_expense and filter_by_category, also read input() first.
  • That gives the program three layers, the pure / action / driver split: the helpers f1 to f4 only compute, the actions here wrap input() and print() around the helpers, and main.py shows the menu and picks which action to run. The same split carries into Milestone Project 2.
  • expenses = [] is created ONCE in main() and the same list is passed into every action. That is how the program remembers your expenses while it runs, with no files and no globals.
How the files connect
main.py  --imports-->  f5_actions.py  --imports-->  f1..f4_*.py

Project files for this step: f5_actions.py | Optional drill: filter a list

Step-07: expense_tracker.py: Run It All and Compare the Solution

Step-07 slide: expense_tracker.py in the center, the concepts used on the left (Run a program: python3; Functions: def and call; Modules: from ... import; Entry point: main()) and what we build on the right (Choices 1 to 6; Six files; expense_tracker.py; No imports; Same output)

File: solution/expense_tracker.py

What you do in this step. Run the finished program and use every menu choice in one session. Then read solution/expense_tracker.py: the same program written in ONE file.

Concepts used:

  • Run a program: python3, run the layered build, then the one-file solution (Section 02)
  • Functions: def and call, every function in one file (Section 07)
  • Modules: from ... import, the layered build imports; the one-file solution needs none (Section 07)
  • Entry point: main(), a main() function that starts the program (New in this project)

Run it. From your build folder (or from build-incremental/):

Run the finished build
python3 main.py
Run it (repeated menus trimmed)
==== Expense Tracker ====
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 ====
...
Choose 1-6: 1
Description: Bus
Amount: 2.00
Category: transport
Added: Bus (2.00) [transport]

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

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

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

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

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

You are finished when:

  1. Each pure helper (f1 to f4) runs on its own with no error and prints nothing, because a helper never prints.
  2. python3 main.py runs, and every menu choice from 1 to 6 works in one session.
  3. Your folder holds all six files: f1, f2, f3, f4, f5_actions, main. (Python may also make a __pycache__ folder there when it imports your files; leave it alone.)

The same program in one file. Only after you finish, read the solution. It holds the four helpers, the five actions, show_menu() and main() together, with no imports between files:

solution/expense_tracker.py
"""Milestone Project 1: CLI Expense Tracker (solution).

Uses only Sections 01-07: input(), f-strings, conditionals, lists,
dicts, loops and functions. No try/except (09), no files (10), no
classes (13).
"""


def is_valid_amount(amount_text):
    """True if amount_text is a positive number like '12' or '12.50' (rejects 0 and negatives).

    Uses string methods and float(). No try/except (that is Section 09).
    """
    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
    # every character is a digit now, so float() is safe: a real amount is above zero
    return float(amount_text) > 0


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


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


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}"


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)

    # Create one expense dictionary and add it to the expenses list.
    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))


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")


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()
  • Same behavior, two ways to organize it. The layered build keeps each file small, which is how larger programs are organized, and lets you run the app after every step from Step-02 on.
  • The one-file solution needs no imports: everything is in one file, top to bottom.
  • Both print exactly the same session: run the solution with the same inputs and compare. Step-01 moved you into build-incremental-lecture, so go up one level to the project folder first.
Run the one-file solution (go up to the project folder first)
cd ..
python3 solution/expense_tracker.py

Project files for this step: solution/expense_tracker.py | build-incremental/

Files in this project

  • warmup/ (optional): six drills, one per skill, each practicing one piece of the project on its own. Not needed to follow the steps above.
  • build-incremental/: the finished files from Step-01 to Step-06 (f1 to f4, f5_actions.py, main.py), plus a README.md with the build order. Compare your build-incremental-lecture/ files with it as you go.
  • build-incremental-lecture/: the same six files as build-incremental/, empty. Build the project here while you follow along, and compare with build-incremental/.
  • solution/expense_tracker.py: the finished program in one file (compare last).

Next

Milestone Project 2 grows this tracker into an object-oriented app that saves your data to a file so it survives between runs.

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


Section 13: Classes and OOP Basics Next: Milestone Project 2: Persistent Expense Tracker

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