Skip to content

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

  1. Command loop. Write while True: that calls input("> ") and stops on quit. Hint: catch EOFError for requirement 11.
  2. Parse one add. Split the line with str.split(). Hint: float(text) raises ValueError for bad text, and math.isfinite rejects inf and nan.
  3. Store expenses as dictionaries. Each expense needs id, amount, category, and note. Hint: the next id is the largest id in use plus one, which max(..., default=0) gives you.
  4. Save and load. Use json.dump and json.load. Hint: if the file does not exist yet, return an empty list before you open it.
  5. List and total. Use an f-string with :.2f. Hint: sum accepts a generator, such as e["amount"] for e in chosen.
  6. Delete. Build a new list without the target, and then assign it with expenses[:] = .... Hint: the slice assignment changes the list that main is holding.
  7. 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()

Sources