Final Project: A Command Line Expense Tracker, Tests and Next Steps

Python for Beginners: Learn to Code with Real Projects · Objects, Libraries and Projects · 10 min read · Updated

Expense tracker flow

  1. You type a command, such as add 250 food, in the terminal.
  2. argparse reads it, converts the amount to a number and shows help or an error for wrong input.
  3. Small functions check the rules, add the expense or work out the totals. They are easy to test.
  4. The data is saved to a JSON file, so it is still there the next time you run the program.

In this last lesson, you join everything into one useful program: an expense tracker that you run from the terminal. It adds expenses, lists them and shows totals by category. It saves data in a JSON file, checks the input, reports errors in a friendly way and has unit tests. The program uses functions, dictionaries, files, exceptions and the standard library, which are the skills you practised in the earlier lessons.

Plan before you code

  1. Data: a list of expenses, each a dictionary with an id, an amount, a category and a note. Stored in a JSON file.
  2. Commands: add, list, total.
  3. Functions: load and save for the file, add_expense to check and add, totals_by_category to calculate, and main to connect the command line to these.
  4. Rules: an amount must be above zero. Category names are stored in title case, so food and Food are the same.
  5. Safety: a missing file means an empty list, and bad input gives a clear message, not a traceback.

The program

Notice that most of the logic is in small functions that do not print anything. That makes them easy to test. The main function is the only one that talks to the user.

"""A small expense tracker for the command line.

Examples:
  python l15_tracker.py add 250 Food "Lunch"
  python l15_tracker.py list
  python l15_tracker.py total
"""
import argparse
import json
from pathlib import Path

DEFAULT_FILE = Path("expenses.json")


def load(file):
    try:
        return json.loads(Path(file).read_text(encoding="utf-8"))
    except FileNotFoundError:
        return []


def save(file, items):
    Path(file).write_text(json.dumps(items, indent=2), encoding="utf-8")


def add_expense(items, amount, category, note=""):
    if amount <= 0:
        raise ValueError("Amount must be more than zero")
    item = {"id": len(items) + 1, "amount": round(amount, 2), "category": category.title(), "note": note}
    items.append(item)
    return item


def totals_by_category(items):
    totals = {}
    for item in items:
        totals[item["category"]] = totals.get(item["category"], 0) + item["amount"]
    return dict(sorted(totals.items(), key=lambda pair: pair[1], reverse=True))


def build_parser():
    parser = argparse.ArgumentParser(description="Track your spending")
    parser.add_argument("--file", default=str(DEFAULT_FILE), help="where to keep the data")
    sub = parser.add_subparsers(dest="command", required=True)

    add = sub.add_parser("add", help="add an expense")
    add.add_argument("amount", type=float)
    add.add_argument("category")
    add.add_argument("note", nargs="?", default="")

    sub.add_parser("list", help="show all expenses")
    sub.add_parser("total", help="show totals by category")
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    items = load(args.file)

    if args.command == "add":
        try:
            item = add_expense(items, args.amount, args.category, args.note)
        except ValueError as error:
            print("Error:", error)
            return 1
        save(args.file, items)
        print(f"Saved #{item['id']}: {item['amount']:.2f} on {item['category']}")
    elif args.command == "list":
        if not items:
            print("No expenses yet")
        for item in items:
            print(f"{item['id']:>3}  {item['category']:<8}{item['amount']:>9.2f}  {item['note']}")
    elif args.command == "total":
        for category, total in totals_by_category(items).items():
            print(f"{category:<8}{total:>9.2f}")
        print(f"{'All':<8}{sum(i['amount'] for i in items):>9.2f}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Understanding argparse

  • argparse reads the command line and builds the help text for you. Run python l15_tracker.py --help to see it.
  • add_subparsers creates commands. Each command, add, list and total, has its own arguments.
  • type=float makes argparse convert the amount and show an error if it is not a number. That is input validation that you did not have to write.
  • nargs="?" makes the note optional, with an empty text as the default.
  • --file lets you choose where the data is kept. We use it in the test run below, so it does not touch your real data.

Run it

$ python l15_tracker.py --file demo.json list
No expenses yet
$ python l15_tracker.py --file demo.json add 250 food "Lunch"
Saved #1: 250.00 on Food
$ python l15_tracker.py --file demo.json add 120.5 travel Metro
Saved #2: 120.50 on Travel
$ python l15_tracker.py --file demo.json add 99 food "Snacks"
Saved #3: 99.00 on Food
$ python l15_tracker.py --file demo.json list
  1  Food       250.00  Lunch
  2  Travel     120.50  Metro
  3  Food        99.00  Snacks
$ python l15_tracker.py --file demo.json total
Food       349.00
Travel     120.50
All        469.50
$ python l15_tracker.py --file demo.json add -5 food
Error: Amount must be more than zero
(exit code 1)
$ python l15_tracker.py --file demo.json add abc food
usage: l15_tracker.py add [-h] amount category [note]
l15_tracker.py add: error: argument amount: invalid float value: 'abc'
(exit code 2)
  • The first call shows No expenses yet, because the file does not exist yet, and load returned an empty list.
  • Each add prints the id and the saved values. The category food was saved as Food.
  • The list output is aligned with f-string formatting. The total output is sorted from the biggest to the smallest, with an All line that you can check by hand: 349.00 plus 120.50 is 469.50.
  • A negative amount is rejected by our own check, and the exit code is 1. A non-number is rejected by argparse with exit code 2. Exit codes let other programs tell whether yours worked.

Test it with unittest

Tests are small programs that check your code. When you change the code later, you run the tests, and they tell you quickly if you broke something. unittest is part of the standard library. Each method that starts with test is one check. assertEqual compares an actual value with the one you expect. assertRaises checks that an error happens when it should.

import unittest
from l15_tracker import add_expense, totals_by_category


class TrackerTests(unittest.TestCase):
    def test_add_expense_sets_id_and_title_case(self):
        items = []
        item = add_expense(items, 250, "food", "Lunch")
        self.assertEqual(item["id"], 1)
        self.assertEqual(item["category"], "Food")

    def test_zero_amount_is_rejected(self):
        with self.assertRaises(ValueError):
            add_expense([], 0, "Food")

    def test_totals_are_grouped_and_sorted(self):
        items = []
        add_expense(items, 100, "Travel")
        add_expense(items, 300, "Food")
        add_expense(items, 50, "Travel")
        self.assertEqual(totals_by_category(items), {"Food": 300, "Travel": 150})


if __name__ == "__main__":
    unittest.main()
python -m unittest -v test_l15_tracker
test_add_expense_sets_id_and_title_case ... ok
test_totals_are_grouped_and_sorted ... ok
test_zero_amount_is_rejected ... ok

Ran 3 tests

OK

The real output also shows the timing and slightly longer test names, and the order is alphabetical. The tests work because the logic is in functions that do not print. This is the main lesson of the project: separate the thinking from the talking.

Ideas to extend the project

  • Add a delete command, and a --month option for list.
  • Add the date to each expense with datetime.date.today().isoformat().
  • Export a CSV report with the csv module.
  • Store the data in SQLite with the sqlite3 module instead of a JSON file.
  • Make a small web version with Flask or FastAPI. Both are installed with pip.

Where to go next

Choose a direction and build one real project in it. For data and analysis, learn pandas, matplotlib and basic statistics, and our SQL course is a good companion because most data is in databases. For web back ends, learn Flask or FastAPI, and our Node.js and Express course shows the same ideas in another language. For automation and testing, learn more about files, scheduling, and a test tool such as pytest. Our career roadmaps show how these skills fit into jobs. Whatever you choose, build small projects regularly, read other people's code, and read the official Python documentation, which is clear and free.

Your task

  1. Type in l15_tracker.py and the test file. Run the commands from the output above with a --file of your own.
  2. Add the delete command: it takes an id and removes that expense, with an error message if the id does not exist. Write a test for it.
  3. Write a test that checks totals_by_category for an empty list.
  4. Write down what you want to build next, and what you need to learn to build it.

How to check it worked

  • The commands produce the same output as shown, apart from your own data.
  • All your unit tests pass, including the new ones.
  • A wrong command, such as a negative amount, gives a clear message and no traceback.

Frequently asked questions

Why keep the logic in functions that do not print?

Functions that only take values and return results are easy to test and reuse. Put printing and user interaction in a thin outer layer such as main.

What should I learn after these basics?

Pick a direction: data (pandas), web (Flask or FastAPI), or automation and testing (pytest). Build one real project in it and read the official documentation.