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¶
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:
- Add expense: ask for a description, an amount, and a category, then store it.
- List expenses: print every expense in a neat, aligned table.
- Total spent: print the sum of all amounts.
- Spending by category: print the total for each category.
- Show one category: ask for a category and print only its expenses.
- 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 like12or12.50; reject0, negatives, and anything else with a friendly message and return to the menu. Use string methods to check the digits, thenfloat()to check it is above zero. Section 03 taughtisdigit(); use its stricter siblingisdecimal()here, becauseisdigit()also saysTruefor characters like a superscript two thatfloat()cannot read. - An empty category becomes
uncategorized; an empty description becomes(no description). - Store categories in lowercase, so
Foodandfoodare 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.
:<20left-aligns a value in a 20-wide column,:<14does the same in 14,:>10right-aligns in 10, and:>10,.2fdoes 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):
==== 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]
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:
The menu appears. Type a number and press Enter. Choose 6 to quit.
For the finished version, run it from this project folder:
Test data¶
Five sample expenses you can use for testing.
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.
Step-01: main.py and f5_actions.py: The Menu Shell and Five Stubs¶
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 Trueandbreak, repeat the menu until choice 6 (Section 06) - Functions:
defand call,show_menu(),main()and the five stubs (Section 07) - Parameters:
(expenses), each stub takes the shared list (Section 07) - Modules: your own
.pyfile,f5_actions.py, whichmain.pyimports (Section 07) - Modules:
from ... import, the five actions fromf5_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.
1.1 main.py: The Menu Shell¶
Write it. Open the empty main.py and type:
"""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 blankprint()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 as6.- The choice is compared as TEXT (
"6", not6), becauseinput()always returns a string. breakon choice6is 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.
1.2 f5_actions.py: Five Stub Actions¶
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: 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.pyimports 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
expenseslist, so you can SEE the menu calling the right function before any feature exists. - Every stub takes the same one parameter,
expenses, becausemain()passes its list to every action it calls.
Run it.
==== 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(), thenif not is_valid_format:(Section 04) - Lists:
len()and[0],len(decimal_parts)anddecimal_parts[0](Section 05) - Dictionaries and lists:
{...},.append(), one dict per expense, added to the list (Section 05) - Return values:
return,is_valid_amount()returnsTrueorFalse(Section 07) - Early return:
return, leaveadd_expense()when the amount is not valid (Section 07) - Modules:
from ... import, the helper import at the top off5_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¶
Write it. Open the empty f1_is_valid_amount.py and type:
"""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()isTrueonly for digits, so"-5"and"abc"fail. We use it instead of.isdigit(), which also accepts characters like a superscript two thatfloat()cannot read.- The early
return Falseleaves beforefloat(), sofloat()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()¶
Wire it in. In f5_actions.py, add the import at the top, just below the docstring, and 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 becomesuncategorized. .lower()makesFoodandfoodthe same category.float(amount_text)is safe here, becausef1already approved the text.expenses.append(expense)adds the dict to the SAME listmain()created, so every other action sees it.
Run it.
==== 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, soif not expenses:(Section 04) - Dictionary lookup:
expense["key"], the description, category and amount (Section 05) - Loops:
forover a list, one row per expense (Section 06) - Return values:
return, hand the row back (Section 07) - Early return:
return, leavelist_expenses()when there is nothing to show (Section 07) - Modules:
from ... import, theformat_rowimport inf5_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:
3.1 f2_format_row.py: One Aligned Row¶
Write it. Open the empty f2_format_row.py and type:
"""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()¶
Wire it in. In f5_actions.py, add the import and 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 printsNo 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.
==== 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:
forover a list, visit every expense (Section 06) - Accumulator: a running total, start at zero and add each amount (Section 06)
- Functions:
defand call,show_total()callscompute_total()(Section 07) - Modules:
from ... import, thecompute_totalimport inf5_actions.py(Section 07) - Return values:
return, hand the number back (Section 07)
4.1 f3_compute_total.py: Add Every Amount¶
Write it. Open the empty f3_compute_total.py and type:
"""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.0starts 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 totalhands 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()¶
Wire it in. In f5_actions.py, add the import and 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, andprint()shows it with an f-string. :,.2fadds thousands commas and two decimals, so a big total reads as1,234.50.- There is no empty-list check here: with no expenses,
compute_total()returns0.0and the line printsTotal spent: 0.00.
Run it.
==== 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, soif not totals:(Section 04) - Dictionaries:
totals = {}, one running total per category (Section 05) - Dictionary lookup:
expense["key"],expense["category"]andexpense["amount"](Section 05) - Dictionary method:
.get()with a default,totals.get(category, 0.0)(Section 05) - Loops:
forover a list, visit every expense (Section 06) - Loops:
forwith.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, leaveshow_by_category()when there is nothing to show (Section 07) - Modules:
from ... import, thecompute_by_categoryimport inf5_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¶
Write it. Open the empty f4_compute_by_category.py and type:
"""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, or0.0the first time we see it.- So the first
foodexpense adds to zero, and every laterfoodexpense adds to what is already there. Because.get()gives0.0for 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:
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()¶
Wire it in. In f5_actions.py, add the import and 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
totalsdict. An empty dict means there are no expenses yet. .items()gives eachcategorywith itstotal, printed with the same widths as the category and amount columns of the Step-03 table,:<14and:>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.
==== 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
Step-06: f5_actions.py: The Last Action and the Finished File¶
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, soif not matches:(Section 04) - List comprehension: filter with
if, keep only the typed category (Section 06) - Functions:
defand call, the five actionsmain.pycalls (Section 07) - Parameters:
(expenses), every action takes the same list (Section 07) - Early return:
return, leave when there are no matches; the rows reuseformat_rowfrom 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()¶
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:
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 cleaningadd_expensegave every stored category, soFoodmatchesfood. - The list comprehension reads: "every
expenseinexpenseswhose category equalswanted_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.
==== 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¶
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: 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_expenseandfilter_by_category, also readinput()first. - That gives the program three layers, the pure / action / driver split: the helpers
f1tof4only compute, the actions here wrapinput()andprint()around the helpers, andmain.pyshows the menu and picks which action to run. The same split carries into Milestone Project 2. expenses = []is created ONCE inmain()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.
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¶
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:
defand 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(), amain()function that starts the program (New in this project)
Run it. From your build folder (or from build-incremental/):
==== 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:
- Each pure helper (
f1tof4) runs on its own with no error and prints nothing, because a helper never prints. python3 main.pyruns, and every menu choice from 1 to 6 works in one session.- 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:
"""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.
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 (f1tof4,f5_actions.py,main.py), plus aREADME.mdwith the build order. Compare yourbuild-incremental-lecture/files with it as you go.build-incremental-lecture/: the same six files asbuild-incremental/, empty. Build the project here while you follow along, and compare withbuild-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.
Section 13: Classes and OOP Basics Next: Milestone Project 2: Persistent Expense Tracker

















