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
- Data: a list of expenses, each a dictionary with an id, an amount, a category and a note. Stored in a JSON file.
- Commands:
add,list,total. - Functions:
loadandsavefor the file,add_expenseto check and add,totals_by_categoryto calculate, andmainto connect the command line to these. - Rules: an amount must be above zero. Category names are stored in title case, so
foodandFoodare the same. - 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
argparsereads the command line and builds the help text for you. Runpython l15_tracker.py --helpto see it.add_subparserscreates commands. Each command,add,listandtotal, has its own arguments.type=floatmakes 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.--filelets 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, andloadreturned an empty list. - Each
addprints the id and the saved values. The categoryfoodwas saved asFood. - The
listoutput is aligned with f-string formatting. Thetotaloutput is sorted from the biggest to the smallest, with anAllline 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
argparsewith 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
deletecommand, and a--monthoption forlist. - Add the date to each expense with
datetime.date.today().isoformat(). - Export a CSV report with the
csvmodule. - Store the data in SQLite with the
sqlite3module 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
- Type in
l15_tracker.pyand the test file. Run the commands from the output above with a--fileof your own. - Add the
deletecommand: it takes an id and removes that expense, with an error message if the id does not exist. Write a test for it. - Write a test that checks
totals_by_categoryfor an empty list. - 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.