Unit 9 Project: Harden a Script You Already Wrote

Same engine, now with a dashboard and seatbelts

Advanced 15 min

In this lesson

This project applies the whole unit to the script you built in Units 7 and 8: fetch records, clean them, store them. It works. What it cannot do is tell you what happened when it runs unattended, survive one bad record, or describe its own data. Four passes fix that, and you can see the difference at each step.

Explain it like I’m 5

Same engine, but now it has a dashboard, seatbelts, and a manual.

The starting point

Here is the script as it stands, written the way most working code starts out. Nothing about it is stupid, and every line of it is a liability once it runs somewhere you are not watching.

Example · before.py
def load(raw_rows):
    books = []
    for row in raw_rows:
        try:
            books.append({"title": row[0], "year": int(row[1])})
        except Exception:
            pass                       # a bad row just disappears
    print("done")
    return books
Works on good input. Says nothing about bad input.

Four passes over the same file

Take them one at a time, running the script after each. The order matters: logging first means every later step has a way to report itself.

  1. Logging. Replace print with leveled logs. Totals at INFO, each rejected row at WARNING.
  2. Error handling. Replace the bare except with a custom exception raised for each specific failure, caught per row so one bad record does not end the run.
  3. Dataclasses. Turn the dicts into a real Book type with named fields.
  4. Type hints. Annotate the functions, run the checker, fix what it finds.

Build the hardened version. Convert each row into a Book, raise InvalidRow for a missing title or an unparseable year, log a WARNING for each rejection and an INFO total at the end, and return the good books.

import logging, sys
from dataclasses import dataclass

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s",
                    stream=sys.stdout, force=True)

RAW = [("Dune", "1965"), ("", "1984"), ("Neuromancer", "nineteen"), ("Snow Crash", "1992")]

@dataclass
class Book:
    title: str
    year: int

class InvalidRow(Exception):
    pass

def to_book(row):
    # TODO: raise InvalidRow for a missing title or an unparseable year
    return Book(row[0], int(row[1]))

def load(rows):
    books = []
    # TODO: convert each row, warn and skip on InvalidRow, log the total
    return books

for book in load(RAW):
    print(book)

What the annotated version looks like

The last pass adds the hints. They change nothing at runtime, and they are what let a checker verify the pieces fit: that to_book really does return a Book, and that callers of load get a list of them.

Example · after.py
def to_book(row: tuple[str, str]) -> Book:
    """Convert one raw row into a Book, or raise InvalidRow."""
    ...

def load(rows: list[tuple[str, str]]) -> list[Book]:
    """Convert every row it can, logging and skipping the rest."""
    ...
The signatures now describe the data flowing through.

Common mistake: Rewriting the script instead of improving it

Why it happens:

Once you start changing things, a clean rewrite feels tempting.

How to fix it:

Make the four passes separately and run the script after each. A rewrite loses working behavior you had forgotten was deliberate.

Common mistake: Adding logging and then never reading the log

Why it happens:

The work felt complete once the calls were in place.

How to fix it:

Run it and read the output. That is how you find out you are logging every row and nothing useful, or the reverse.

Common mistake: Catching InvalidRow around the whole loop rather than per row

Why it happens:

One try around everything is less typing.

How to fix it:

Put the try inside the loop. Outside it, the first bad row ends the entire run — exactly what the exception was meant to prevent.

Common mistake: Annotating the types while leaving the error handling alone

Why it happens:

Hints are the most visible of the four changes.

How to fix it:

Hints do not catch bad data, only mismatched code. A well-annotated script with except Exception: pass is still silently losing records.

Why should the try/except go inside the loop rather than around it?

Which of the four passes would catch a caller passing a list where a tuple was expected?

What should the log show after a run that dropped two of four rows?

Mini exercise (hard)

Extend the hardened loader to store its results. After building the Book list, write them into a SQLite table with parameterized inserts, log how many were stored, then read them back and confirm the round trip returned equal objects.

Give it a shot. Complete the code and press Run; there’s nothing to download or configure.

import sqlite3
from dataclasses import dataclass

@dataclass
class Book:
    title: str
    year: int

books = [Book("Dune", 1965), Book("Snow Crash", 1992)]

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE books (title TEXT, year INTEGER)")
# TODO: insert each book with placeholders, then commit

loaded = []  # TODO: read the rows back as Book objects

print(len(loaded))
print(loaded == books)

What to learn next

You hardened a working script in four passes: leveled logging so an unattended run explains itself, a custom exception caught per row so one bad record cannot end the batch, dataclass records in place of loose dicts, and annotations describing what flows through each function. Same behavior on good input, completely different behavior on bad.

That completes Unit 9. Look back at what you wrote and you will notice the retry loop, the connection cleanup, and the row-by-row conversion are patterns you now repeat at every call site. Unit 10 is about deleting that repetition with iterators, decorators, and context managers. When you’re ready, keep building. Ready to apply all three units at once? Collect API Data Into a Database joins the fetching, the storing and these durability habits into one script you could schedule.