Reading a Whole Program
This is the page the whole book has been building towards: one complete, useful program, expenses.py, read from top to bottom. It reads your bank statement and totals your spending by category, reports any row it could not read, converts a foreign amount at today's exchange rate with a fallback when the network is down, and runs as a command-line tool with two commands. It is about a hundred and fifty lines long, and every line is something an earlier page taught.
Think of reading a finished recipe after learning each technique on its own: the knife work, the sauce, the timing. Every step is familiar. What is new is seeing them work together to make a meal.
What It Does
Before any code, here is what it does. The summary command reads a statement and prints the totals; the statement below is the one from Chapter 8, bad pharmacy row included. The convert command converts an amount into your home currency, which is dollars unless the environment variable from Chapter 10 says otherwise.
(.venv) you@mac python-course % python expenses.py summary statement.csv Spent in total: 177.75 food: 58.50 transport: 45.00 home: 61.75 fun: 12.50 Skipped 1 row(s): line 8: the amount is empty (.venv) you@mac python-course % python expenses.py convert 86.40 EUR 86.40 is about USD 98.95, using the rate of 2026-09-23
The numbers match every earlier chapter: 177.75 spent, 58.50 of it on food, and one row reported rather than silently dropped. The converted amount depends on the day's rate, so yours will differ.
Part 1: What It Needs
The file opens with a docstring, the same idea as a function's docstring from Chapter 5 but for the whole file: what the program is and how to run it. Then the imports, standard modules first, then the one installed package, with a blank line between the groups, which is the usual convention. Then two constants, in capitals because they never change (Chapter 5): the service's address from Chapter 10 and the file where the last rate is saved.
"""Look at your spending: total a bank statement, or convert an amount. Usage: python expenses.py summary statement.csv python expenses.py convert 86.40 --currency EUR """ import argparse import csv import json import os import sys from dataclasses import dataclass from decimal import Decimal, InvalidOperation from pathlib import Path import requests RATES_URL = "https://api.frankfurter.dev/v2/rates" SAVED_RATES = Path("rates.json")
Part 2: The Shape of a Row, and Reading an Amount
A Transaction is a frozen dataclass (Chapter 11): one row of the statement, with named fields, a readable printout, and no way to change it by accident once it is read. parse_amount is the function from Chapter 8. It turns the text of an amount into a Decimal (Chapter 2), and raises a ValueError with a reason a person can act on when it cannot.
@dataclass(frozen=True) class Transaction: """One row of a bank statement.""" date: str description: str amount: Decimal category: str def parse_amount(text): """Return the amount as a Decimal, or raise ValueError saying why not.""" if not text.strip(): raise ValueError("the amount is empty") try: return Decimal(text) except InvalidOperation: raise ValueError(f"'{text}' is not an amount")
Part 3: Reading the Statement
read_statement opens the file with with and UTF-8 (Chapter 7), reads it with csv.DictReader, and numbers the rows from 2 with enumerate so the line numbers match the file (Chapter 8). A bad amount is caught precisely, recorded with its line number, and skipped with continue (Chapter 4). It hands back two things at once, the transactions and the skipped rows, as a tuple that the caller unpacks (Chapter 6).
def read_statement(path): """Return the statement's transactions and a list of rows that were skipped.""" transactions = [] skipped = [] with open(path, encoding="utf-8", newline="") as f: for line, row in enumerate(csv.DictReader(f), start=2): try: amount = parse_amount(row["amount"]) except ValueError as error: skipped.append(f"line {line}: {error}") continue transactions.append( Transaction(row["date"], row["description"], amount, row["category"]) ) return transactions, skipped
Part 4: The Report
spending_by_category is the dictionary total from Chapter 6, with get and a default doing the "first time this category appears" check in one line. Spending is negative in the file, so subtracting it adds a positive amount. summary then puts the pieces together and prints: the total, each category, and, only if there were any, the skipped rows.
def spending_by_category(transactions): """Return total spending per category, as positive amounts.""" totals = {} for t in transactions: if t.amount < 0: totals[t.category] = totals.get(t.category, Decimal("0")) - t.amount return totals def summary(path): """Print the spending report for one statement.""" transactions, skipped = read_statement(path) totals = spending_by_category(transactions) print(f"Spent in total: {sum(totals.values())}") for category, total in totals.items(): print(f" {category}: {total}") if skipped: print(f"Skipped {len(skipped)} row(s):") for reason in skipped: print(f" {reason}")
Part 5: The Conversion
These three functions are Chapter 10's rate script, grown slightly. The rate is now for any currency into your home currency, read from an environment variable with a default. The saved rate records which pair it was for, so a saved euro rate is never used for a pound. The timeout, the status check, the precise except (now also covering an answer in an unexpected shape), the honest message about where the rate came from, and the clean exit when there is nothing to fall back on are all exactly as before.
def fetch_rate(currency, home): """Ask the service for today's rate from currency to home, and save it.""" response = requests.get(RATES_URL, params={"base": currency, "quotes": home}, timeout=5) response.raise_for_status() latest = response.json()[0] saved = {"date": latest["date"], "rate": str(latest["rate"]), "pair": f"{currency}/{home}"} SAVED_RATES.write_text(json.dumps(saved), encoding="utf-8") return saved["date"], Decimal(saved["rate"]) def saved_rate(currency, home): """Return the last saved date and rate for this pair, or None.""" if not SAVED_RATES.exists(): return None saved = json.loads(SAVED_RATES.read_text(encoding="utf-8")) if saved.get("pair") != f"{currency}/{home}": return None return saved["date"], Decimal(saved["rate"]) def convert(amount_text, currency): """Print an amount converted into the home currency.""" home = os.environ.get("HOME_CURRENCY", "USD") amount = parse_amount(amount_text) try: date, rate = fetch_rate(currency, home) source = f"the rate of {date}" except (requests.RequestException, KeyError, IndexError) as error: print(f"Could not get today's rate: {type(error).__name__}") last = saved_rate(currency, home) if last is None: sys.exit("No saved rate to fall back on. Try again when online.") date, rate = last source = f"the rate saved on {date}" print(f"{currency} {amount} is about {home} {amount * rate:.2f}, using {source}")
Part 6: The Command Line and main
main builds the two subcommands with argparse (Chapter 10), reads the command line, and calls the right function. It also catches the two mistakes a person running the tool is likely to make: a statement that does not exist, and an amount that is not a number. Each becomes one plain sentence and an exit code of 1 instead of a traceback. The last two lines are the guard from Chapter 9, so the file can be both run and imported, which is exactly what the tests below rely on.
def main(): parser = argparse.ArgumentParser(description="Look at your spending.") commands = parser.add_subparsers(dest="command", required=True) summary_cmd = commands.add_parser("summary", help="total a bank statement") summary_cmd.add_argument("statement", help="the CSV file exported by your bank") convert_cmd = commands.add_parser("convert", help="convert an amount to your home currency") convert_cmd.add_argument("amount", help="the amount, such as 86.40") convert_cmd.add_argument("--currency", default="EUR", help="the currency it was paid in") args = parser.parse_args() if args.command == "summary": try: summary(args.statement) except FileNotFoundError: sys.exit(f"Cannot open {args.statement}: no such file.") else: try: convert(args.amount, args.currency) except ValueError as error: sys.exit(f"Cannot use that amount: {error}") if __name__ == "__main__": main()
(.venv) you@mac python-course % python expenses.py summary missing.csv Cannot open missing.csv: no such file. (.venv) you@mac python-course % python expenses.py convert eighty Cannot use that amount: 'eighty' is not an amount
The Tests
Because the file can be imported, its functions can be tested with pytest (this chapter's Topic 2). These three tests check the parts that do calculation, where a mistake would silently produce wrong numbers: reading an amount, refusing an empty one, and counting only money spent. None of them needs a file or the network, because those functions take values in and return values out, the habit Chapter 5 started. Naming the test file in the command runs only these tests, and not the ones left over from the pytest topic.
from decimal import Decimal import pytest from expenses import Transaction, parse_amount, spending_by_category def test_parse_amount_reads_text(): assert parse_amount("-54.30") == Decimal("-54.30") def test_parse_amount_refuses_empty_text(): with pytest.raises(ValueError): parse_amount(" ") def test_spending_counts_only_money_spent(): transactions = [ Transaction("2026-09-03", "Coffee", Decimal("-4.20"), "food"), Transaction("2026-09-05", "Salary", Decimal("2400.00"), "income"), ] assert spending_by_category(transactions) == {"food": Decimal("4.20")}
(.venv) you@mac python-course % python -m pytest test_expenses.py ============================= test session starts ============================== platform darwin -- Python 3.15.0, pytest-9.1.1, pluggy-1.6.0 rootdir: /Users/you/python-course collected 3 items test_expenses.py ... [100%] ============================== 3 passed in 0.07s ===============================
Where to Take It Next
A finished program is a starting point. A monthly budget per category with a warning when it is exceeded is a dictionary and an if. A report of several months is a loop over several statements. A chart of spending is one package away. Each extension uses what you already know, which is the real measure of having learned something.
- "Real programs are written top to bottom in one go." This one grew in pieces across the whole book, and most real programs grow the same way.
- "A hundred and fifty lines is too long to understand." Split into named functions, it reads like a table of contents, one short job at a time.
- "Finished means it will never change." It is a starting point: budgets, charts and more categories are each a small, well-understood step.
- Reading a whole program and understanding every line is the proof this book promised on its first page.
- It is also a template: the same shape, small functions, a main, a guard and tests, fits your next script too.
Knowledge Check
Which chapter's idea makes a bad row show up as "line 8: the amount is empty"?
- Chapter 8's skip-and-report handling of bad data
- Chapter 3's if and else, used to hide the row
- Chapter 11's frozen dataclass, which rejects bad rows
- Chapter 10's JSON, which stores the bad rows
What does the program do when the rate service cannot be reached?
- It stops with a traceback so the user can see the problem
- It uses a rate of one, so the amount is unchanged
- It uses the saved rate for that pair and says how old it is
- It keeps retrying the request until it succeeds
Why can test_expenses.py import functions from expenses.py without running the tool?
- Because pytest removes the main function before importing
- Because tests can only see functions, never other code
- Because the tests pass empty command-line arguments
- Because main() is called only under the __name__ guard
Why does saved_rate check the saved pair?
- Because JSON cannot store more than one rate at a time
- So a rate saved for one currency is never used for another
- Because the service returns rates in a random order
- So that the file is deleted when the currency changes
You got correct