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
$ 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}")
2025: 1 books, average 5.00
2026: 7 books, average 4.14book["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}")
2026: 7 of 24, behindWriting 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="")
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")
archived 4 notes
backup holds 5 notesFour 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.
- Write
books_to_go(report)that takes the report dict fromwrite_summariesand returns how many more books are needed to hit the goal, or0if it's already met. - Write
by_author(books)that returns a dict of author name to number of books in the log. - Write
restore(zip_path)that unpacksnotes-backup.zipback intonotes/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}))
17
02. 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")
{'Le Guin': 2, 'Gibson': 1}
10 authors, 10 books3. 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"))
restored: 1Where 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.