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:

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.