Learn
05.6 | PROJECT: AN EXPENSE TRACKER
01 | DEFINE A SMALL, COMPLETE PRODUCT
Download the Python project and tests
The tracker lets one local user add, list and total fictional expenses, filter a monthly total and export CSV. It saves each accepted addition immediately so reopening the program restores previous records. It uses CHF only, avoiding a meaningless total across different currencies.
Each record has a real date, a category from food/travel/learning/other, a positive amount from 0.01 to 10000.00 with at most two decimal places, and a trimmed note of one to eighty characters. Duplicate entries are allowed: two similar purchases may be separate expenses.
A missing save starts a new tracker. An existing unreadable or invalid save stops startup. The program should never announce a successful addition when its save failed. These are observable requirements you can test.
02 | KNOW THE FOUR FILES
model.py owns validation and calculations. storage.py owns JSON persistence and CSV export. tracker.py owns prompts and command handling. test_tracker.py contains automated checks. Keep them together and run tracker.py.
The program uses only Python’s standard library. It does not require a package installation, account or network connection. Saved data belongs beside tracker.py, anchored with pathlib rather than whichever folder happened to launch the process.
The small browser model later in this page keeps only session data. It demonstrates the rules but is not a substitute for testing Python’s actual file persistence.
| Module | Inputs and responsibilities | Outputs or effects |
|---|---|---|
| model.py | Raw fields and checked records | Validated dictionaries, cents, totals |
| storage.py | Paths and record lists | Loaded records or written files |
| tracker.py | User commands | Feedback and committed saved additions |
| test_tracker.py | Fixed cases and temporary folders | Repeatable evidence |
03 | CONVERT DECIMAL TEXT TO INTEGER CENTS
The amount parser uses re.fullmatch to enforce plain ASCII digits with an optional decimal point followed by one or two digits. The non-capturing group (?:...) groups that optional decimal part without storing an extra regex capture. A question mark makes that group optional.
partition(".") splits the text into whole part, separator and fractional part. ljust(2, "0") pads a one-digit fraction: "5" becomes "50". Combining whole × 100 and fractional cents avoids binary float arithmetic. A range check then rejects zero and excessive amounts.
The parser intentionally rejects signs, commas, exponent notation, NaN, infinity and a third decimal. It does not silently round them. Surrounding whitespace is stripped. This grammar is a learning contract, not a universal rule for every financial application.
"""Validation and calculations for a small, single-currency tracker."""
from datetime import date
import re
CURRENCY = "CHF"
CATEGORIES = ("food", "travel", "learning", "other")
MAX_CENTS = 1_000_000
def parse_amount(text):
"""Return integer cents for 0.01 to 10000.00, with at most two decimals."""
text = text.strip()
if not re.fullmatch(r"[0-9]+(?:\.[0-9]{1,2})?", text):
raise ValueError("Use plain digits and at most two decimal places.")
whole, dot, fraction = text.partition(".")
cents = int(whole) * 100 + int(fraction.ljust(2, "0") or "0")
if not 1 <= cents <= MAX_CENTS:
raise ValueError("Amount must be from 0.01 to 10000.00 CHF.")
return cents
04 | VALIDATE RECORDS FROM BOTH INPUT AND FILES
build_expense converts the user’s field text into a candidate dictionary. validate_expense also checks saved dictionaries after JSON decoding. This matters because someone can edit the file outside the application.
The record validator requires exactly the four named fields and the expected types. type(amount_cents) is int intentionally rejects True, because bool is otherwise a subclass of int. Dates must pass structure and calendar checks; categories must be permitted; notes must meet presence and length rules.
Successful validation returns a checked copy with canonical date text and a trimmed note. It does not establish that an expense is factually genuine. The project trusts the small local dataset and does not provide authentication or encryption.
def parse_date(text):
"""Validate a real date in the exact YYYY-MM-DD format."""
text = text.strip()
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text):
raise ValueError("Use date format YYYY-MM-DD.")
return date.fromisoformat(text).isoformat()
def parse_month(text):
text = text.strip()
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", text):
raise ValueError("Use month format YYYY-MM.")
date(int(text[:4]), int(text[5:]), 1)
return text
def validate_expense(record):
"""Return a checked copy; reject wrong fields and unsuitable saved values."""
fields = {"date", "category", "amount_cents", "note"}
if type(record) is not dict or set(record) != fields:
raise ValueError("Each expense needs date, category, amount_cents and note.")
if type(record["date"]) is not str or type(record["category"]) is not str:
raise ValueError("Date and category must be text.")
if type(record["amount_cents"]) is not int:
raise ValueError("Saved amount_cents must be an integer, not a Boolean.")
if not 1 <= record["amount_cents"] <= MAX_CENTS:
raise ValueError("Saved amount is outside the allowed range.")
if record["category"] not in CATEGORIES:
raise ValueError("Choose food, travel, learning or other.")
if type(record["note"]) is not str or not 1 <= len(record["note"].strip()) <= 80:
raise ValueError("Use a note containing 1 to 80 characters after trimming.")
return {"date": parse_date(record["date"]),
"category": record["category"],
"amount_cents": record["amount_cents"],
"note": record["note"].strip()}
def build_expense(day, category, amount, note):
return validate_expense({"date": parse_date(day),
"category": category.strip().lower(),
"amount_cents": parse_amount(amount), "note": note})
def money(cents):
"""Format a non-negative integer count of cents, without float arithmetic."""
return f"{CURRENCY} {cents // 100}.{cents % 100:02d}"
def total_cents(expenses, month=None):
if month is not None:
month = parse_month(month)
return sum(row["amount_cents"] for row in expenses
if month is None or row["date"].startswith(month + "-"))
05 | DISTINGUISH MISSING FROM DAMAGED SAVES
load_expenses catches only FileNotFoundError for the new-tracker case. Other access problems, invalid UTF-8, malformed JSON and unsuitable record structures reach the application’s startup handler. It prints a failure and stops before accepting new data.
This protects a damaged existing save from being mistaken for an empty list that the next addition would overwrite. The user can inspect or recover the file before restarting. A successful parse alone is not enough: each record still passes validation.
This is one local process and one data file, not a multi-user database. File-size limits, concurrent writers and stronger recovery guarantees would need additional design.
"""JSON persistence and a CSV export for one local user."""
from pathlib import Path
import csv
import json
from tempfile import NamedTemporaryFile
from model import validate_expense
def load_expenses(path):
path = Path(path)
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except FileNotFoundError:
return []
if type(data) is not list:
raise ValueError("Saved data must be a list of expense records.")
return [validate_expense(row) for row in data]
06 | SAVE A CHECKED CANDIDATE, THEN COMMIT
save_expenses validates every proposed record before opening an output file. It writes a new temporary file in the destination’s directory, closes it and then replaces the save path. NamedTemporaryFile supplies a fresh temporary name; delete=False lets the closed file remain available for replacement.
The temporary file is in the same directory so replacement stays on the same filesystem in this design. finally removes a leftover temporary file if the normal path did not already move it. This reduces the risk of truncating the old save before the new document is ready.
It is not a backup, a multi-process transaction or a guarantee against power failure. Production durability could need additional techniques such as syncing and retained versions. The project’s application accepts the new in-memory list only after the save operation succeeds.
def save_expenses(path, expenses):
"""Validate first, then replace the destination after closing a new file."""
checked = [validate_expense(row) for row in expenses]
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = None
try:
with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent,
prefix=path.name + ".", suffix=".tmp",
delete=False) as file:
temporary_path = Path(file.name)
json.dump(checked, file, ensure_ascii=False, indent=2)
temporary_path.replace(path)
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
07 | KEEP CSV EXPORT SEPARATE
The export writes a header and one row per record with amount_chf formatted to two decimal places. DictWriter handles commas and quotes in notes. Opening uses newline="" and UTF-8.
The CSV is a readable export rather than the saved source of truth. Editing it does not change expenses.json. The application asks before replacing an existing export, but the export itself is not an atomic save operation.
Use fictional practice notes. Spreadsheet tools can interpret some leading characters in cells as formulas; CSV quoting alone does not stop that. This simple export is intended for controlled learning data, not arbitrary untrusted spreadsheet content.
def export_csv(path, expenses):
"""Write a human-readable export; do not use this CSV as the saved database."""
checked = [validate_expense(row) for row in expenses]
with Path(path).open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=["date", "category", "amount_chf", "note"])
writer.writeheader()
for row in checked:
cents = row["amount_cents"]
writer.writerow({"date": row["date"], "category": row["category"],
"amount_chf": f"{cents // 100}.{cents % 100:02d}",
"note": row["note"]})
08 | COORDINATE THE COMMAND LOOP
The application accepts add, list, total, month, export and quit. add collects fields, validates a record and creates candidate = expenses + [record]. A failed check returns to the menu without changing expenses. A failed save also leaves that accepted list unchanged.
list creates a date-sorted display without rearranging the stored arrival order. total adds integer cents. month validates a canonical month and selects matching date prefixes. EOFError and KeyboardInterrupt are handled at the main boundary so previously saved additions remain available.
Save this code as tracker.py. The other modules must be beside it. The default path arguments are created when the function definition executes, which is fine here because they are fixed Path objects rather than mutable record lists.
"""Run this file to start the command-line expense tracker."""
from pathlib import Path
from model import build_expense, money, total_cents
from storage import load_expenses, save_expenses, export_csv
BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "expenses.json"
EXPORT_PATH = BASE_DIR / "expenses.csv"
def date_key(record):
return record["date"]
def run_tracker(data_path=DATA_PATH, export_path=EXPORT_PATH):
try:
expenses = load_expenses(data_path)
except (OSError, ValueError, UnicodeError) as error:
print("Cannot safely load saved data:", error)
print("No records changed. Check the file before restarting.")
return False
print("Expense tracker | CHF only | use fictional practice data")
while True:
command = input("add / list / total / month / export / quit: ").strip().lower()
if command == "quit":
print("Finished. Successful additions were saved immediately.")
return True
if command == "add":
day = input("Date YYYY-MM-DD: ")
category = input("Category food/travel/learning/other: ")
amount = input("Amount CHF, 0.01 to 10000.00: ")
note = input("Note, 1 to 80 characters: ")
try:
record = build_expense(day, category, amount, note)
except ValueError as error:
print("Not added:", error)
continue
candidate = expenses + [record]
try:
save_expenses(data_path, candidate)
except OSError as error:
print("Save failed; expense was not added:", error)
continue
expenses = candidate
print("Added and saved:", money(record["amount_cents"]))
elif command == "list":
if not expenses:
print("No expenses yet.")
for number, row in enumerate(sorted(expenses, key=date_key), start=1):
print(f"{number}. {row['date']} | {row['category']} | "
f"{money(row['amount_cents'])} | {row['note']}")
elif command == "total":
print("Total:", money(total_cents(expenses)))
elif command == "month":
month = input("Month YYYY-MM: ")
try:
amount = total_cents(expenses, month)
except ValueError as error:
print("Invalid month:", error)
else:
print("Month total:", money(amount))
elif command == "export":
if Path(export_path).exists():
if input("Replace existing CSV export? Type yes: ").strip().lower() != "yes":
print("Export cancelled.")
continue
try:
export_csv(export_path, expenses)
except OSError as error:
print("Export failed:", error)
else:
print("CSV export written to:", export_path)
else:
print("Unknown command. Choose one of the displayed words.")
def main():
try:
run_tracker()
except (EOFError, KeyboardInterrupt):
print("\nStopped. Previously saved additions remain on disk.")
if __name__ == "__main__":
main()
09 | TRY THE SESSION-ONLY MODEL
Add a fictional expense. The model checks dates, categories, note length and decimal-text amounts, then shows a total and month filter. It does not save to your device, and its records disappear when the page is reloaded.
10 | RUN THE TEST SUITE AND A REAL REOPEN
Open a terminal in the project folder. Run python tracker.py, or python3 tracker.py if that is your system’s command. Use python -m unittest discover -v to run test_tracker.py. The tests use temporary directories and fixed dates, avoiding your actual save.
Add an expense of 0.10 and another of 0.20, quit and reopen. The total should be CHF 0.30. Add a next-month expense and check that the month filter excludes it. Export a note containing a comma and inspect it through a CSV reader.
The bundled suite includes fourteen test methods with multiple subcases. It checks accepted and rejected amounts, calendar dates, records, totals, reloads, malformed data, unchanged saves after validation failure and CSV field preservation. More tests would be needed for new requirements.
11 | EXTEND WITH A TESTED REQUIREMENT
A category summary is a useful next step: add a pure helper receiving checked records and returning totals by category. Test an empty list, one category and repeated categories before adding a command.
Editing or deleting expenses requires an identity rule. Current display numbers are sorted positions, not permanent record IDs. Decide how to identify a record before accepting a delete command.
Multi-currency totals, shared accounts and financial reporting are outside this project’s scope. Keep improvements small, document their effects on saved data and rerun earlier tests after each change.
PUT YOUR SKILLS TO WORK
Use the Section 5 challenges to practise these ideas independently. Work in your Python editor, predict results and keep a short test record.
Section 5 challenges Python course navigation