Python for Humans · Draft v0.1 · Nov 1, 2026 · Draft position: chapter 25 · Build a checked CSV expense summary with a preview before saving.
Automate an Expense Report
Automation
- You should already know
- Functions, dictionaries, assertions, file handling and imports; CSV rows from The Web.
- You'll build
- A repeatable expense summary that previews category totals before saving.
- You'll be able to
- Validate a small export, separate calculation from file operations and check repeated runs.
- You need
- Your local Python setup, any plain-text editor and uv. No third-party packages are required.
Adding the same categories by hand every week gets tedious. A small program can repeat the arithmetic while you check whether its rules match the report you wanted.
We'll use invented purchases, with amounts in whole cents. The program reads a CSV, adds each category and previews the report. It saves only when you answer yes. This continues the independent expense exercises from Files and Modules; you can also start here with the provided sample.
Prepare a Small Export
Make a separate project inside your course folder:
mkdir expense-automation cd expense-automation uv init --bare --vcs none --no-workspace --python 3.14 uv python pin 3.14
Save the invented expense CSV as expenses.csv in this folder, or create it in your editor:
category,amount_cents food,1250 travel,300 food,625
Work out the totals by hand before writing code: food should total 1,875 cents and travel 300 cents. Our rule counts purchases, accepts zero and rejects negative amounts. A bank export containing refunds needs a different stated rule.
Calculate Before Writing Files
Create expense_report.py with the complete version below, or download it. Start by reading category_totals() and tracing the first two rows. The CSV reader supplies strings, so the amount needs int() before addition.
import csv def category_totals(rows): totals = {} for row in rows: if None in row or row['category'] is None or row['amount_cents'] is None: raise ValueError('Each expense needs exactly two fields.') category = row['category'].strip() if category == '': raise ValueError('Each expense needs a category.') amount = int(row['amount_cents']) if amount < 0: raise ValueError('This purchase report expects nonnegative amounts.') if category in totals: totals[category] = totals[category] + amount else: totals[category] = amount return totals def format_totals(totals): if totals == {}: return 'No expenses.\n' report = '' for category in totals: report = report + f'{category}: {totals[category]} cents\n' return report def main(): with open('expenses.csv', newline='', encoding='utf-8') as source: rows = csv.DictReader(source) if rows.fieldnames != ['category', 'amount_cents']: raise ValueError('Expected the header: category,amount_cents') totals = category_totals(rows) report = format_totals(totals) print('Report preview:') print(report, end='') answer = input('Save this as expense_totals.txt? (yes/no) ') if answer == 'yes': with open('expense_totals.txt', 'w', encoding='utf-8') as destination: destination.write(report) print('Saved expense_totals.txt.') else: print('Preview finished without saving.') if __name__ == '__main__': main()
The helper accepts rows and returns totals. It opens no files. As in the text counter, a dictionary accumulates a value for each category; a second purchase adds to an existing total.
csv.DictReader uses the header for its dictionary keys. This program expects exactly the two columns shown, in that order. A row with too few or too many fields is rejected. Whitespace around a category is removed; an empty category is rejected. int() rejects missing or fractional amounts rather than guessing how to interpret them.
The None checks detect incomplete fields. An extra CSV field appears under a None key, so None in row detects that case. These checks describe this small export format; adapting another export starts with examining its columns and units.
format_totals() builds a string without writing it. Categories keep the order of their first appearance. The empty-input report says No expenses. explicitly.
Preview, Decline, Then Save
Run:
uv run expense_report.py
The preview should include:
Report preview: food: 1875 cents travel: 300 cents
Answer no. Confirm that no output file was created. Run again and answer yes, then open expense_totals.txt. It should contain the same category lines as the preview. Close your editor, reopen the report and check that the saved text remains.
Change the last purchase from 625 to 700. Predict the new total, run again and decline to save. The preview should show 1,950 cents for food, while the saved report still shows 1,875. Run once more and answer yes to replace that report.
Reading and validation finish before the save question. Temporarily change an amount to twelve: the program should raise ValueError before offering to save. Read the traceback, restore the number and rerun. The input file is always read; write mode is used only for the separate output.
Keep Repeatable Checks
Save this as check_expenses.py, or download it:
from expense_report import category_totals, format_totals def test_totals(): rows = [ {'category': 'food', 'amount_cents': '1250'}, {'category': 'travel', 'amount_cents': '300'}, {'category': 'food', 'amount_cents': '625'}, ] assert category_totals(rows) == {'food': 1875, 'travel': 300} assert category_totals([]) == {} assert category_totals([{'category': 'food', 'amount_cents': '0'}]) == {'food': 0} assert format_totals({'food': 1875}) == 'food: 1875 cents\n' assert format_totals({}) == 'No expenses.\n' def test_rejected_amounts(): for amount in ['-10', 'twelve', '12.50']: try: category_totals([{'category': 'food', 'amount_cents': amount}]) except ValueError: pass else: raise AssertionError('An invalid amount was accepted.') if __name__ == '__main__': test_totals() test_rejected_amounts() print('Expense checks passed.')
Run uv run check_expenses.py. Importing the helper shouldn't ask a question or read a CSV because the main guard protects those actions. The tests use small inputs whose answers we know.
The rejection check uses a try/except you've met before. If conversion or our rule raises ValueError, that is the expected result. The else after except runs only when no exception occurred, so it raises an assertion for an invalid amount that slipped through.
Check the whole workflow too: save a report, rerun with the same input and save again. The report should contain the same lines once, rather than appending a second copy. Try a CSV containing only the header: the preview should say No expenses..
Make One Change Yourself
Choose one change before opening a tutor:
- Add a grand total below the category lines. For the original sample it should be 2,175 cents. Add a check for an empty input too.
- Normalize category capitalization, so
Foodandfoodcombine. State the new rule and check it with a tiny input. - Add a count of purchases costing at least 1,000 cents, reusing the boundary cases from Testing.
Keep the calculation separate from the question and file writing. You should be able to check the new rule without saving a report.
A later extension could accept the input filename as a command-line argument or run on a schedule. First, make this small program repeatable with an input you can verify. For another export, identify its column names, amount units and refund rules before changing the reader.
What I've Learned
Explain which parts read, calculate, preview and save. Show a check for repeated categories and describe what happens to the existing report when you decline a save or an amount is invalid.