Capstone 1: A command-line expense tracker¶
~3 hours ยท prerequisite: Parts 1 to 4 (variables, loops, lists, dictionaries, functions, files, and exceptions)
Goal¶
Build a program that you run in a terminal. It reads commands such as add and list, saves each expense to a file called expenses.json, and still shows those expenses the next time it starts.
Requirements and acceptance checks¶
Your program is done when every row below passes. Run each check in a folder that holds expense.py. The > prompt appears before each reply, so look for the text after it.
| # | Requirement | Acceptance check |
|---|---|---|
| 1 | Read commands in a loop until quit. |
printf 'quit\n' \| python3 -I expense.py prints Bye. and exits with status 0. |
| 2 | add AMOUNT CATEGORY NOTE... stores an expense and prints its id. |
After add 12.5 food lunch, the output is Added expense 1. |
| 3 | The amount must be a positive, finite number. | add 0 food x prints Amount must be a positive number. and stores nothing. |
| 4 | Missing arguments print a usage line. | add 5 food prints Usage: add AMOUNT CATEGORY NOTE. |
| 5 | list prints ID AMOUNT CATEGORY NOTE, one expense per line, with two decimals. |
After add 12.5 food lunch, list prints 1 12.50 food lunch. |
| 6 | list with no expenses prints No expenses yet. |
On an empty file, list prints that line. |
| 7 | total prints the sum. total CATEGORY sums one category. |
add 12.5 food a, add 3.25 food b, then total food prints Total: 15.75. |
| 8 | delete ID removes one expense. An unknown id prints an error. |
delete 9 prints No expense with id 9. |
| 9 | Expenses are saved to expenses.json after each change and loaded at start. |
Stop the program, start it again, and list shows the same rows. |
| 10 | An unknown command prints Unknown command: NAME. |
fly prints Unknown command: fly. |
| 11 | End of input stops the program without a traceback. | printf 'list\n' \| python3 -I expense.py ends after the list. |
Suggested file layout¶
expense-tracker/
expense.py # the whole program
expenses.json # created by the program after the first change
Keep everything in one file at first. Split it into modules only after the program works, and remember that python3 -I does not add the script's folder to sys.path, so sibling modules will not be found.
Milestones¶
- Command loop. Write
while True:that callsinput("> ")and stops onquit. Hint: catchEOFErrorfor requirement 11. - Parse one add. Split the line with
str.split(). Hint:float(text)raisesValueErrorfor bad text, andmath.isfiniterejectsinfandnan. - Store expenses as dictionaries. Each expense needs
id,amount,category, andnote. Hint: the next id is the largest id in use plus one, whichmax(..., default=0)gives you. - Save and load. Use
json.dumpandjson.load. Hint: if the file does not exist yet, return an empty list before you open it. - List and total. Use an f-string with
:.2f. Hint:sumaccepts a generator, such ase["amount"] for e in chosen. - Delete. Build a new list without the target, and then assign it with
expenses[:] = .... Hint: the slice assignment changes the list thatmainis holding. - Error paths. Walk through requirements 3, 4, 8, and 10 by hand, and check each message.
Check it¶
Run this in an empty folder:
$ printf 'add 12.5 food lunch with ada
add 3.25 food tea
list
total food
quit
' | python3 -I expense.py
> Added expense 1.
> Added expense 2.
> 1 12.50 food lunch with ada
2 3.25 food tea
> Total: 15.75
> Bye.
The > prompt appears before each reply, because input writes its prompt even when the input comes from a pipe.
Reference solution¶
Try the milestones first. Open this only after you have a version of your own, or when you are stuck on a milestone.
Reference solution
import json import math import os
DATA_FILE = "expenses.json"
def load_expenses(path=DATA_FILE): if not os.path.exists(path): return [] with open(path, encoding="utf-8") as f: return json.load(f)
def save_expenses(expenses, path=DATA_FILE): with open(path, "w", encoding="utf-8") as f: json.dump(expenses, f, indent=2)
def parse_amount(text): try: value = float(text) except ValueError: return None if not math.isfinite(value) or value <= 0: return None return value
def next_id(expenses): return max((e["id"] for e in expenses), default=0) + 1
def handle(line, expenses): """Run one command. Return False when the program should stop.""" parts = line.split() if not parts: return True command, args = parts[0], parts[1:]
if command == "quit":
print("Bye.")
return False
if command == "add":
if len(args) < 3:
print("Usage: add AMOUNT CATEGORY NOTE")
return True
amount = parse_amount(args[0])
if amount is None:
print("Amount must be a positive number.")
return True
expense = {
"id": next_id(expenses),
"amount": amount,
"category": args[1],
"note": " ".join(args[2:]),
}
expenses.append(expense)
save_expenses(expenses)
print(f"Added expense {expense['id']}.")
elif command == "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(f"{e['id']} {e['amount']:.2f} {e['category']} {e['note']}")
elif command == "total":
chosen = [e for e in expenses if not args or e["category"] == args[0]]
print(f"Total: {sum(e['amount'] for e in chosen):.2f}")
elif command == "delete":
if len(args) != 1 or not args[0].isdigit():
print("Usage: delete ID")
return True
target = int(args[0])
remaining = [e for e in expenses if e["id"] != target]
if len(remaining) == len(expenses):
print(f"No expense with id {target}.")
return True
expenses[:] = remaining
save_expenses(expenses)
print(f"Deleted expense {target}.")
else:
print(f"Unknown command: {command}")
return True
def main(): expenses = load_expenses() while True: try: line = input("> ") except EOFError: print() break if not handle(line, expenses): break
if name == "main": main()