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.Pathand thejsonmodule. - Read a CSV file of expense rows (
date,category,amount,description) using thecsvmodule. - 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
amountto a number explicitly; a row where it can’t be converted (empty, non-numeric,"N/A") is invalid. - A row naming a
categorythat 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
KeyErroror 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’stmp_pathfixture: 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
{
"categories": {
"food": 300,
"transport": 100,
"entertainment": 50
}
}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.jsonAcceptance checklist
- A missing config or CSV file produces a specific, readable error message, not a raw traceback.
- A row with a non-numeric
amountis skipped, counted, and does not crash the program. - A row with an unrecognized
categoryis 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.
-
pytestpasses on: at least two distinct row-validation tests, one aggregation-math test with hand-computed expected values, and one end-to-end test usingtmp_path. -
ruff format --checkandruff checkboth 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, includingamount. Adding un-converted strings either raises aTypeErroror, 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 placeamountis later used. - Reaching for
dict["category"]wheredict.get("category")was needed, or vice versa. A plainbudget["utilities"]on the fixture above raisesKeyErrorfor 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.
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.
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.
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
datecolumn. - Accept a
--currencystyle 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:
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, amountfrom 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 NoneBoth 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.