BeginnerModerate

Expense Analyzer

Build a command-line tool that reads a CSV of expenses and a JSON budget configuration, aggregates spending by category, flags categories over budget, and reports on it — without crashing on a single malformed row.

Skills you’ll practice

  • CSV and JSON parsing
  • pathlib
  • aggregation
  • reporting
  • configuration
  • error handling
  • testing

The Beginner tier’s second project: a program that reads real-shaped, imperfect input data and still produces a trustworthy report. Files and Structured Data’s worked example loaded a config file and summed some expenses; this project takes that idea and asks for a program that handles the failure paths a one-off worked example is allowed to skip — a malformed row, an unknown category, a missing file — without either crashing or silently reporting a wrong number.

Scenario

You’ve been handed a CSV export of someone’s expenses and a JSON file describing their budget per category. Build a command-line program that reads both, reports total spending per category and overall, and flags any category that’s gone over its budget — while treating “a data-quality problem exists in this row” and “a bug exists in my program” as two different situations that need two different responses.

Requirements

Stage 1: load configuration and records

  • Read a JSON config file (see Fixtures) describing known categories and each one’s budget limit, using pathlib.Path and the json module.
  • Read a CSV file of expense rows (date, category, amount, description) using the csv module.
  • If either file is missing, print a clear, specific error message (which file, and that it’s missing) and exit without a raw traceback.

Stage 2: validate every row

  • Every CSV value arrives as a string. Convert amount to a number explicitly; a row where it can’t be converted (empty, non-numeric, "N/A") is invalid.
  • A row naming a category that isn’t in the config is also invalid — the report has no budget figure to compare it against.
  • Invalid rows must not crash the program and must not silently disappear: skip each one, and keep a count (and ideally which line and why) so the final report can say “3 rows skipped” instead of quietly under-reporting spending.

Stage 3: aggregate

  • Compute total spending per category and overall total spending across all valid rows.
  • For each category with a configured budget, compute the percentage of budget used and whether it’s over budget.
  • A category present in the config but with no matching expense rows should report zero spending, not raise a KeyError or vanish from the report.

Stage 4: report

  • Print a human-readable report: per-category totals, percentage of budget used, an explicit over-budget flag, and the overall total.
  • Also write the same information to a JSON (or CSV) report file, so the result is usable by something other than a human reading the terminal.
  • Report the skipped-row count from Stage 2 somewhere in the output — a report that hides its own data-quality problems is less trustworthy than one that states them.

Stage 5: tests

  • Test the row-validation logic directly: a well-formed row is accepted; a bad-amount row and an unknown-category row are both rejected, each for the reason your code actually checks for (two separate test cases, not one that just checks “invalid rows are rejected”).
  • Test the aggregation math against a small, hand-computed set of rows where you know the correct totals and percentages in advance.
  • Test the end-to-end flow with pytest’s tmp_path fixture: write a config and CSV to a temporary directory, run your program’s main function against them, and assert on the report’s content.

Fixtures

Outputbudget.json
{
  "categories": {
    "food": 300,
    "transport": 100,
    "entertainment": 50
  }
}
Outputexpenses.csv
date,category,amount,description
2026-03-01,food,42.50,Groceries
2026-03-03,transport,18.00,Bus pass
2026-03-05,food,notanumber,Corrupted export row
2026-03-07,entertainment,65.00,Concert ticket
2026-03-10,utilities,40.00,Category not in budget.json

Acceptance checklist

  • A missing config or CSV file produces a specific, readable error message, not a raw traceback.
  • A row with a non-numeric amount is skipped, counted, and does not crash the program.
  • A row with an unrecognized category is skipped, counted, and does not crash the program.
  • A configured category with zero matching rows reports $0 spent, not an error.
  • The report states total spending per category, overall total, percentage of budget used per category, which categories are over budget, and how many rows were skipped.
  • The report is written to a file as well as printed.
  • pytest passes on: at least two distinct row-validation tests, one aggregation-math test with hand-computed expected values, and one end-to-end test using tmp_path.
  • ruff format --check and ruff check both pass on your source files.

Common places to get stuck

  • Summing strings instead of numbers. Every value read from a CSV row is a str, including amount. Adding un-converted strings either raises a TypeError or, if you reach for string concatenation by mistake, silently produces the wrong kind of result. Convert explicitly, once, in the validation step — not scattered across every place amount is later used.
  • Reaching for dict["category"] where dict.get("category") was needed, or vice versa. A plain budget["utilities"] on the fixture above raises KeyError for the very row you’re supposed to be gracefully rejecting; that lookup belongs inside your validation check, not after it’s already assumed to have passed.
  • Counting a skipped row as $0 spent instead of leaving it out of the total entirely. Those are different things — an unnumbered amount contributes nothing to the sum by omission, not because its value was coerced to zero.

Stuck? Reveal one hint at a time.

  1. Hint 1

    Convert amount to a float inside your row-validation function, wrapped in a try/except ValueError — that single conversion attempt is both your type coercion and your check for "is this a valid number," instead of validating and converting as two separate steps that can disagree with each other.

  2. Hint 2

    For the unknown-category case, check category in config["categories"] before doing any lookup that assumes it's already there — checking membership first is what turns a potential KeyError into a deliberate, testable rejection.

  3. Hint 3

    For the end-to-end test, write the fixture CSV and JSON content directly into tmp_path (tmp_path / "expenses.csv").write_text(...) inside the test itself, rather than pointing at a fixture file elsewhere on disk — that keeps the test's expected totals and its input data next to each other, so a future edit to one is obviously incomplete without the other.

Extension ideas

  • Break totals down by month as well as by category, using the date column.
  • Accept a --currency style formatting option and render totals with a currency symbol and two decimal places.
  • Add a “near budget” warning (for example, 90% or more of budget used) distinct from the hard over-budget flag.
  • Support more than one CSV file in a single run (e.g., several months’ exports), with totals aggregated across all of them.

Debugging journal

Before moving on: describe one case where your program produced a number that was plausible but wrong — not a crash, a wrong total that looked reasonable enough you might not have noticed it without a hand-computed test to compare against. Which of your Stage 5 tests caught it (or would have, if you’d written it first)? Testing and Quality’s point about boundary-case thinking applies directly here: the empty-category and malformed-row cases are exactly the boundaries a happy-path test alone would miss.

See one reference approach (not the only correct one)

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

Keeping row validation as a pure function — no file I/O, no printing — is what makes Stage 5’s tests possible to write without needing a real file on disk for every case:

Pythonanalyzer.py — validation isolated from I/O
def validate_row(row: dict[str, str], categories: dict[str, float]) -> tuple[str, float] | None:
    """Return (category, amount) if row is valid, or None if it should be skipped."""
    category = row["category"]
    if category not in categories:
        return None

    try:
        amount = float(row["amount"])
    except ValueError:
        return None

    return category, amount
Pythontest_analyzer.py
from analyzer import validate_row


def test_validate_row_accepts_well_formed_row():
    row = {"category": "food", "amount": "42.50"}
    assert validate_row(row, categories={"food": 300}) == ("food", 42.50)


def test_validate_row_rejects_non_numeric_amount():
    row = {"category": "food", "amount": "notanumber"}
    assert validate_row(row, categories={"food": 300}) is None


def test_validate_row_rejects_unknown_category():
    row = {"category": "utilities", "amount": "40.00"}
    assert validate_row(row, categories={"food": 300}) is None

Both rejection cases return the same None, which is enough for the program to decide “skip this row” — but the two tests stay separate because they’re rejecting for different reasons, and a future change that accidentally breaks one shouldn’t be able to hide behind the other still passing.

Rubric

Dimension What it’s assessing
Correctness Aggregation math and over-budget flags are right against hand-computed expected values.
Clarity Validation, aggregation, and reporting are separate, individually testable pieces.
Resilience Malformed rows, unknown categories, and missing files are handled, not just the golden path.
Usability The report clearly states totals, budget status, and how many rows were skipped and why.

Next step

Reusable Package and CLI takes either this project or the CLI Quiz or Habit Tracker and turns it from a script you run with python analyzer.py into an installable package with its own terminal command — the Beginner tier’s last project.