Chapter 11

Putting It Together

Ten chapters, one idea at a time: values, names, decisions, loops, collections, functions, modules, files, and handling what breaks. This chapter assembles them into one real script, track.py, that loads your expense history, lets you add to it, and saves it back.

The shape

Every version of this script does the same three things, in order: load what's already there, act on it, save the result. That's the whole plan. Everything below is one of those three steps, or a small piece that supports one of them.

The functions, assembled

These are every one of them from earlier chapters, gathered in one place. Nothing here is new, and notice as you read: not one of these functions calls input(). Each takes exactly the values it needs as arguments and hands back a result with return, which is exactly why every one of them can be tested for real, right here, the same way the rest of this book has been.

def load_expenses(path):
    expenses = []
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                raw_amount, category, note = line.split(",")
                expenses.append({
                    "amount": float(raw_amount),
                    "category": category,
                    "note": note,
                })
    except FileNotFoundError:
        pass
    return expenses

def save_expenses(path, expenses):
    with open(path, "w") as f:
        for entry in expenses:
            f.write(f"{entry['amount']},{entry['category']},{entry['note']}\n")

def add_expense(expenses, amount, category, note=""):
    expenses.append({"amount": amount, "category": category, "note": note})
    return expenses

def total(expenses):
    running = 0
    for entry in expenses:
        running += entry["amount"]
    return running

def expenses_over(expenses, amount):
    matches = []
    for entry in expenses:
        if entry["amount"] > amount:
            matches.append(entry)
    return matches

One more, new but built from the same pieces as everything above: a short summary of a list of entries.

def report(expenses):
    lines = [f"{len(expenses)} expenses, total {total(expenses):.2f}"]
    by_category = {}
    for entry in expenses:
        by_category[entry["category"]] = (
            by_category.get(entry["category"], 0) + entry["amount"]
        )
    for category, amount in by_category.items():
        lines.append(f"  {category}: {amount:.2f}")
    return "\n".join(lines)

report() reuses total() rather than adding up amounts a second way, and builds up a dict of per-category totals with the same "default, then update" pattern from Chapter 9's practice. f"{amount:.2f}" is new: the :.2f part formats a number to exactly two decimal places, which is why every dollar amount below prints with two digits even when the underlying float doesn't carry a trailing zero.

One more small function, using the by-category totals report() already builds, to show which category is costing the most:

def top_category(expenses):
    by_category = {}
    for entry in expenses:
        by_category[entry["category"]] = (
            by_category.get(entry["category"], 0) + entry["amount"]
        )
    return max(by_category, key=by_category.get)

max() normally compares the values you hand it directly, which would try to compare category names alphabetically here, not what you want. key=by_category.get tells max() to compare each category by looking up its total in the dict first, and hand back whichever key had the largest one. This is the same key= idea sorted() and .sort() use when you want to sort by something other than the plain values themselves.

Trying it end to end

Load the fixture history, add one new entry, and check the whole thing works together, all still with no input() anywhere in sight:

expenses = load_expenses("expenses.txt")
add_expense(expenses, 6.00, "coffee", "treat")
print(report(expenses))
Output
6 expenses, total 108.35
  coffee: 19.25
  groceries: 77.10
  transit: 12.00
big = expenses_over(expenses, 20)
print(len(big))
Output
2
print(top_category(expenses))
Output
groceries
save_expenses("final.txt", expenses)
reloaded = load_expenses("final.txt")
print(len(reloaded))
Output
6

Everything above ran, checked, and passed, exactly like every other example in this book. That's the whole point of keeping input() out of these functions: there was nothing here that couldn't be checked.

The part that talks to a person

Here's the rest of track.py: a main() function that asks for one new expense and wires it into everything above, wrapped in the main-guard from Chapter 8. This is the one piece of the script this book's build can't run for you, because it genuinely needs someone at a keyboard.

def main():
    path = "expenses.txt"
    expenses = load_expenses(path)

    raw_amount = input("Amount: ")
    category = input("Category: ")
    note = input("Note (optional): ")

    try:
        amount = float(raw_amount)
    except ValueError:
        print(f"'{raw_amount}' doesn't look like a number, nothing saved.")
        return

    add_expense(expenses, amount, category, note)
    save_expenses(path, expenses)

    print()
    print(report(expenses))


if __name__ == "__main__":
    main()
Terminal
$ python track.py
Amount: 6.00
Category: coffee
Note (optional): treat

6 expenses, total 108.35
  coffee: 19.25
  groceries: 77.10
  transit: 12.00

Look at how little main() actually does: it calls three functions to collect input, one try/except to convert and validate it, and two more functions, already tested above, to record and save it. Nearly the entire script is the tested part. Only the handful of lines that talk to a person are the untested part, and that's exactly the boundary this book has been building toward since Chapter 2 first raised it.

Where this leaves you

You've read the complete listing of a real, working Python script and seen every part of it doing a job you understand: loading a file, converting and validating input, updating a list of dictionaries, handling a bad value without crashing, and saving the result. Nothing in track.py is more advanced than what the last ten chapters covered. The closing chapter has a few ways to extend it yourself, and points to where the parts you didn't cover here, classes, decorators, a real database, live in the rest of the series.