Chapter 12

Putting It Together

Eleven chapters of pieces. This one builds a single tool out of them: a script that reads the log, works out how the latest year went against the goal, writes a summary a person can read and one a program can, tidies the notes folder, and backs the whole thing up in a zip. Nothing in it is new. Every line is something from an earlier chapter.

What it does

Terminal
$ python reading-report.py
Latest year: 2026
  7 books finished, average rating 4.14
  goal is 24, so: behind
Archived 4 notes.
Wrote summary.txt, summary.json, notes-backup.zip.

Reading and grouping the log

Load the records with csv.DictReader from Chapter 7, then group them by the year in the finished date:

import csv

def load_books(path="reading-log.csv"):
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.DictReader(f))

def by_year(books):
    years = {}
    for book in books:
        if not book["finished"]:
            continue
        year = book["finished"][:4]
        years.setdefault(year, []).append(book)
    return years

books = load_books()
years = by_year(books)

for year in sorted(years):
    count = len(years[year])
    rated = [int(b["rating"]) for b in years[year] if b["rating"]]
    average = sum(rated) / len(rated) if rated else 0
    print(f"{year}: {count} books, average {average:.2f}")
Output
2025: 1 books, average 5.00
2026: 7 books, average 4.14

book["finished"][:4] is the first four characters of 2026-06-01, the year. setdefault(year, []) gets the list for that year, creating an empty one the first time. The rating conversion skips the blanks, the way Chapter 7 did.

Checking the goal

Read the goal from settings.json with json.load from Chapter 8. The script reports on the most recent year in the log rather than the calendar year, so its output doesn't change just because time passed:

import json

def load_settings(path="settings.json"):
    with open(path, encoding="utf-8") as f:
        return json.load(f)

settings = load_settings()
goal = settings["books_per_year_goal"]

latest = max(years)
finished = len(years[latest])
verdict = "on track" if finished >= goal else "behind"
print(f"{latest}: {finished} of {goal}, {verdict}")
Output
2026: 7 of 24, behind

Writing the summaries

One file for a person, one for a program, both through the write_atomically helper from Chapter 3:

import json
import os
from pathlib import Path

def write_atomically(path, text):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(text)
    os.replace(tmp, path)

def write_summaries(years, goal):
    latest = max(years)
    books = years[latest]
    rated = [int(b["rating"]) for b in books if b["rating"]]

    report = {
        "year": latest,
        "finished": len(books),
        "average_rating": round(sum(rated) / len(rated), 2) if rated else None,
        "goal": goal,
        "on_track": len(books) >= goal,
    }
    write_atomically("summary.json", json.dumps(report, indent=2) + "\n")

    lines = [
        f"Reading summary for {latest}",
        f"  finished: {report['finished']} books",
        f"  average rating: {report['average_rating']}",
        f"  goal: {goal} ({'on track' if report['on_track'] else 'behind'})",
    ]
    write_atomically("summary.txt", "\n".join(lines) + "\n")

write_summaries(years, goal)
print(Path("summary.txt").read_text(encoding="utf-8"), end="")
Output
Reading summary for 2026
  finished: 7 books
  average rating: 4.14
  goal: 24 (behind)

Archiving and backing up

Move each finished book's note into notes/archive/ with shutil.move from Chapter 9, then zip the archive with zipfile from Chapter 11:

import shutil
import zipfile
from pathlib import Path

def archive_notes(books):
    archive = Path("notes/archive")
    archive.mkdir(parents=True, exist_ok=True)
    moved = 0
    for book in books:
        if not book["finished"]:
            continue
        stem = book["title"].lower().replace(" ", "-")
        note = Path("notes") / f"{stem}.txt"
        if note.exists():
            shutil.move(note, archive / note.name)
            moved += 1
    return moved

def backup_archive(zip_path="notes-backup.zip"):
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
        for note in sorted(Path("notes/archive").glob("*.txt")):
            z.write(note, arcname=note.name)

moved = archive_notes(books)
backup_archive()

print(f"archived {moved} notes")
with zipfile.ZipFile("notes-backup.zip") as z:
    print(f"backup holds {len(z.namelist())} notes")
Output
archived 4 notes
backup holds 5 notes

Four notes moved out of notes/, joining the one already in the archive, and all five went into the zip.

The whole script

Assembled, with a guard so a missing input fails with a clear message instead of a traceback:

import csv
import json
import os
import shutil
import zipfile
from pathlib import Path


def load_books(path="reading-log.csv"):
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.DictReader(f))


def load_settings(path="settings.json"):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def by_year(books):
    years = {}
    for book in books:
        if book["finished"]:
            years.setdefault(book["finished"][:4], []).append(book)
    return years


def write_atomically(path, text):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(text)
    os.replace(tmp, path)


def write_summaries(years, goal):
    latest = max(years)
    books = years[latest]
    rated = [int(b["rating"]) for b in books if b["rating"]]
    report = {
        "year": latest,
        "finished": len(books),
        "average_rating": round(sum(rated) / len(rated), 2) if rated else None,
        "goal": goal,
        "on_track": len(books) >= goal,
    }
    write_atomically("summary.json", json.dumps(report, indent=2) + "\n")
    lines = [
        f"Reading summary for {latest}",
        f"  finished: {report['finished']} books",
        f"  average rating: {report['average_rating']}",
        f"  goal: {goal} ({'on track' if report['on_track'] else 'behind'})",
    ]
    write_atomically("summary.txt", "\n".join(lines) + "\n")


def archive_notes(books):
    archive = Path("notes/archive")
    archive.mkdir(parents=True, exist_ok=True)
    moved = 0
    for book in books:
        if not book["finished"]:
            continue
        note = Path("notes") / (book["title"].lower().replace(" ", "-") + ".txt")
        if note.exists():
            shutil.move(note, archive / note.name)
            moved += 1
    return moved


def backup_archive(zip_path="notes-backup.zip"):
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
        for note in sorted(Path("notes/archive").glob("*.txt")):
            z.write(note, arcname=note.name)


def main():
    try:
        books = load_books()
        goal = load_settings()["books_per_year_goal"]
    except FileNotFoundError as e:
        print(f"missing input: {e.filename}")
        return

    years = by_year(books)
    latest = max(years)
    finished = len(years[latest])

    print(f"Latest year: {latest}")
    rated = [int(b["rating"]) for b in years[latest] if b["rating"]]
    average = round(sum(rated) / len(rated), 2) if rated else None
    print(f"  {finished} books finished, average rating {average}")
    print(f"  goal is {goal}, so: {'on track' if finished >= goal else 'behind'}")

    write_summaries(years, goal)
    moved = archive_notes(books)
    backup_archive()
    print(f"Archived {moved} notes.")
    print("Wrote summary.txt, summary.json, notes-backup.zip.")


if __name__ == "__main__":
    main()

Every function came out of an earlier chapter. main is just the order to call them in, plus the try around the two inputs so a missing file prints one line instead of a stack trace.

Practice

Try each of these before you read the solution under it.

  1. Write books_to_go(report) that takes the report dict from write_summaries and returns how many more books are needed to hit the goal, or 0 if it's already met.
  2. Write by_author(books) that returns a dict of author name to number of books in the log.
  3. Write restore(zip_path) that unpacks notes-backup.zip back into notes/archive/, skipping any member whose file is already there.

Solutions

1.

def books_to_go(report):
    return max(0, report["goal"] - report["finished"])

print(books_to_go({"goal": 24, "finished": 7}))
print(books_to_go({"goal": 24, "finished": 30}))
Output
17
0

2. One pass, dict.get to count.

import csv

def by_author(books):
    counts = {}
    for book in books:
        author = book["author"]
        counts[author] = counts.get(author, 0) + 1
    return counts

sample = [{"author": "Le Guin"}, {"author": "Le Guin"}, {"author": "Gibson"}]
print(by_author(sample))

with open("reading-log.csv", newline="", encoding="utf-8") as f:
    counts = by_author(list(csv.DictReader(f)))
print(len(counts), "authors,", sum(counts.values()), "books")
Output
{'Le Guin': 2, 'Gibson': 1}
10 authors, 10 books

3. Read each member, write it only if the target file is missing.

import zipfile
from pathlib import Path

def restore(zip_path):
    archive = Path("notes/archive")
    archive.mkdir(parents=True, exist_ok=True)
    restored = 0
    with zipfile.ZipFile(zip_path) as z:
        for name in z.namelist():
            target = archive / name
            if not target.exists():
                target.write_bytes(z.read(name))
                restored += 1
    return restored

# build a backup, delete a note, restore it
with zipfile.ZipFile("backup.zip", "w") as z:
    for note in Path("notes/archive").glob("*.txt"):
        z.write(note, arcname=note.name)

(Path("notes/archive") / "the-left-hand-of-darkness.txt").unlink()
print("restored:", restore("backup.zip"))
Output
restored: 1

Where this leaves you

That's the book. You opened files and closed them safely, read and wrote text, handled the encoding, worked with paths, found files, parsed CSV and JSON, moved and deleted things, went down to raw bytes, and packed it all into a zip. And you just built a real tool from every bit of it. There's one short section left: where to go from here.