Overview
By the end of today you will have built, tested and containerised a small async FastAPI service backed by a SQL database, and you will be able to explain each piece out loud. Plan for about 10 hours including breaks; every block commits something to one Git repo, so you can stop and resume anywhere.
How to use this guide
- Each block has a 10-minute mini-lesson, then exercises, then a self-check. Read the lesson once, then spend most of the time typing.
- Type the code yourself. Pasting teaches nothing; the typos are where learning happens.
- Exercises marked Core are required. Stretch exercises are for when you finish early. If a block runs 50% over, skip its Stretch items and move on.
- Run everything. If a snippet does not behave as described, that is the exercise.
- Commit at the end of every block with the message suggested in the table.
Schedule
| Block | Topic | Time | Commit message |
|---|---|---|---|
| 0 | Setup | 20 min | chore: project skeleton |
| 1 | Python: classes, dataclasses, generators, type hints | 90 min | feat: python fundamentals drills |
| 2 | Async Python: async def, asyncio.gather | 60 min | feat: async drills |
| 3 | HTTP and JSON: status codes, timeouts, retries | 60 min | feat: resilient http client |
| 4 | FastAPI: validation, errors, tests | 100 min | feat: notes api with tests |
| 5 | SQL and SQLAlchemy: joins, indexes, ORM | 90 min | feat: sqlalchemy persistence |
| 6 | Git, Docker, CI: Compose and a test pipeline | 90 min | ci: docker compose and github actions |
| 7 | Capstone: integrate everything | 60 min | feat: capstone |
| 8 | Checkpoint and self-assessment | 20 min | none |
Take a 10-minute break after every block and a proper meal break after Block 4.
What you need installed: Python 3.11 or newer, Git, a GitHub account, and Docker Desktop (or Docker Engine plus the Compose plugin). Check with python3 --version, git --version and docker compose version.
Block 0: Setup (20 min)
One repo, one virtual environment, one folder per stage. A virtual environment is a private copy of Python plus its own installed packages, so this project's dependencies cannot clash with any other project on your machine.
mkdir ai-prereqs && cd ai-prereqs
git init
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install fastapi "uvicorn[standard]" httpx sqlalchemy pytest pytest-asyncio
pip freeze > requirements.txt
mkdir drills app tests
touch app/__init__.py
printf ".venv/\n__pycache__/\n*.db\n.pytest_cache/\n" > .gitignore
git add . && git commit -m "chore: project skeleton"
You should end with this layout:
ai-prereqs/
drills/ blocks 1-3 scratch files
app/ blocks 4-5 FastAPI service
tests/ pytest files
requirements.txt
.gitignore
Exercise 0.1 (Core): prove the venv works
- Run
which python(Windows:where python). The path must contain.venv. - Run
deactivate, thenwhich pythonagain. Note the difference. - Re-activate and run
pip list. Why is the list short? - Delete nothing, but answer in one sentence: why is
.venv/in.gitignorewhilerequirements.txtis committed?
Done when: you can explain that the venv is rebuilt from requirements.txt on any machine, so the folder itself never needs sharing.
Block 1: Python fundamentals (90 min)
Goal: write small, typed, well-structured Python without looking things up. Work in drills/py_basics.py.
Mini-lesson (10 min): read, then run each snippet
- A class bundles data with the functions that act on it.
__init__sets up state,__repr__controls how it prints,@propertyexposes a computed value as an attribute. - A dataclass writes
__init__,__repr__and__eq__for you from annotated fields. Mutable defaults needfield(default_factory=...). - A generator is a function that uses
yield. It produces values one at a time, on demand, so it can stream data far larger than memory. - Type hints do nothing at runtime. They document intent and let tools like mypy and your editor catch mistakes early.
from dataclasses import dataclass, field
from typing import Iterator
@dataclass
class Book:
title: str
pages: int
tags: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if self.pages <= 0:
raise ValueError("pages must be positive")
class Counter:
def __init__(self) -> None:
self._n = 0
@property
def value(self) -> int:
return self._n
def incr(self) -> None:
self._n += 1
def __repr__(self) -> str:
return f"Counter({self._n})"
def evens(limit: int) -> Iterator[int]:
n = 0
while n < limit:
yield n
n += 2
maybe_title: str | None = None # a value that may be missing
Try print(Book("Dune", 412)), list(evens(10)), and next(evens(10)) before moving on.
Exercise 1.1 (Core, 15 min): a validated dataclass
Create Task with title: str, done: bool = False and tags: list[str]. An empty or whitespace-only title must raise ValueError. Then deliberately write tags: list[str] = [] and read the error Python gives you. Fix it with default_factory and explain in one sentence why the error exists.
Exercise 1.2 (Core, 20 min): a class with behaviour
Create TaskList holding Task objects with these methods:
add(task)appends a task.complete(title)marks the first task with that title done, and raisesKeyErrorif none exists.pending, a@propertyreturning the list of tasks not done.__len__and__iter__, solen(tl)andfor t in tlwork.
Exercise 1.3 (Core, 20 min): generators
- Write
read_lines(path: str) -> Iterator[str]that opens a file and yields each stripped, non-empty line. Createsample.txtwith blank lines to test it. - Write
batched(items: Iterable[T], n: int) -> Iterator[list[T]]that yields lists of up tonitems. Python 3.12 shipsitertools.batched, but write your own. - Measure the laziness:
import sys
big_list = [i for i in range(10_000_000)]
big_gen = (i for i in range(10_000_000))
print(sys.getsizeof(big_list), sys.getsizeof(big_gen))
Write down both numbers and why they differ so much. This is why generators matter when you stream tokens or rows later in the roadmap.
Hint for batched: declare T = TypeVar("T") (import TypeVar from typing) above the function, so the annotation says the output items have the same type as the input items.
Exercise 1.4 (Core, 15 min): type hints with a checker
Add group_by_tag(tasks: Iterable[Task]) -> dict[str, list[Task]]. Annotate every function in the file, including -> None where nothing is returned. Then run:
pip install mypy && pip freeze > requirements.txt
mypy drills/py_basics.py
Introduce one wrong call such as Task(title=5) in a scratch file and confirm mypy flags it even though Python would run it.
Exercise 1.5 (Stretch, 10 min)
Add Task.from_dict(cls, data: dict[str, object]) -> "Task" as a @classmethod. Then try @dataclass(frozen=True) on Task and see what breaks when you set task.done = True. Use dataclasses.replace to work around it.
Self-check: save as drills/check_block1.py and run it from drills/
from py_basics import Task, TaskList, batched, read_lines, group_by_tag
assert Task("a").tags is not Task("b").tags
try:
Task(" ")
except ValueError:
pass
else:
raise AssertionError("empty title should fail")
tl = TaskList()
tl.add(Task("a", tags=["x"]))
tl.add(Task("b", tags=["x", "y"]))
tl.complete("a")
assert len(tl) == 2
assert [t.title for t in tl.pending] == ["b"]
assert [t.title for t in tl] == ["a", "b"]
assert sorted(group_by_tag(tl)) == ["x", "y"]
assert list(batched([1, 2, 3, 4, 5], 2)) == [[1, 2], [3, 4], [5]]
lines = read_lines("sample.txt")
assert iter(lines) is lines # generators are their own iterators
print("block 1 ok")
Explain out loud, without notes
- Why is a mutable default like
[]rejected in a dataclass, and what doesdefault_factorydo? - What is the difference between a function that returns a list and one that yields?
- What does
str | Nonepromise the reader, and what does mypy do with it?
Commit: feat: python fundamentals drills.
Block 2: Async Python (60 min)
Goal: explain what async def does and run many I/O calls concurrently with asyncio.gather. Work in drills/async_drills.py.
Mini-lesson (10 min)
async defdefines a coroutine function. Calling it does not run the body; it returns a coroutine object that something must await.awaitpauses the current coroutine and hands control back to the event loop, which runs other coroutines until the awaited thing finishes.asyncio.run(main())starts the loop and runs one top-level coroutine.- This is concurrency, not parallelism: one thread, many tasks taking turns whenever one is waiting on I/O. It helps with network and disk waits, and does nothing for CPU-heavy work.
asyncio.gather(a, b, c)runs the awaitables concurrently and returns their results in the order you passed them.- A blocking call such as
time.sleepfreezes the whole loop. Useawait asyncio.sleepinstead.
import asyncio
import time
async def fetch(name: str, delay: float) -> str:
await asyncio.sleep(delay) # stands in for a network call
return f"{name} done after {delay}s"
async def main() -> None:
start = time.perf_counter()
results = await asyncio.gather(fetch("a", 1), fetch("b", 1), fetch("c", 1))
print(results, f"{time.perf_counter() - start:.1f}s") # about 1.0s, not 3.0s
asyncio.run(main())
Exercise 2.1 (Core, 10 min): sequential versus concurrent
- Time three
await fetch(...)calls made one after another. Expect about 3 seconds. - Write
async def fetch_all(delays: list[float]) -> list[str]usingasyncio.gatherand time it for[1, 1, 1]. Expect about 1 second. - Call
fetch("x", 1)withoutawaitand read the "coroutine was never awaited" warning. Say what it means in your own words.
Exercise 2.2 (Core, 5 min): break the loop on purpose
Inside fetch, replace await asyncio.sleep(delay) with time.sleep(delay) and rerun the gather example. It now takes about 3 seconds. Explain why gather could not help, then restore the original line.
Exercise 2.3 (Core, 15 min): failures in a batch
Write a flaky(i: int) coroutine that raises RuntimeError when i is 3 and otherwise returns i * 10. Gather flaky(0) to flaky(4) two ways:
- Default: observe that the first exception propagates out of
gather. - With
return_exceptions=True: get a list mixing results and exception objects. Print successes and failures separately usingisinstance(r, Exception).
Write one sentence on when you would want each behaviour.
Exercise 2.4 (Core, 15 min): timeouts and a concurrency limit
- Wrap a 3-second
fetchinasync with asyncio.timeout(1.5):and catchTimeoutError. - Write
async def fetch_limited(delays: list[float], limit: int) -> list[str]that usesasyncio.Semaphore(limit)so no more thanlimitcalls run at once. Ten 1-second calls withlimit=3should take about 4 seconds, because 10 calls in groups of 3 need 4 rounds.
This is the pattern you will use to call an LLM API for 100 inputs without hitting rate limits.
Exercise 2.5 (Stretch, 10 min)
Build a producer and consumer with asyncio.Queue: one task puts 10 items on the queue, three consumer tasks take items and "process" them with a short sleep. Shut down cleanly with queue.join().
Self-check: save as drills/check_block2.py
import asyncio
import time
from async_drills import fetch_all, fetch_limited
async def main() -> None:
t = time.perf_counter()
out = await fetch_all([1, 1, 1])
assert len(out) == 3 and time.perf_counter() - t < 1.5
t = time.perf_counter()
await fetch_limited([1] * 10, limit=3)
assert 3.5 < time.perf_counter() - t < 4.6
print("block 2 ok")
asyncio.run(main())
Explain out loud, without notes
- What does calling an
async deffunction return, and what makes it actually run? - Why are three gathered one-second waits done in about one second?
- Name a task where async gives no speed-up, and say why.
Commit: feat: async drills.
Block 3: HTTP and JSON (60 min)
Goal: call an unreliable HTTP API safely. You will set timeouts, read status codes, retry only what is worth retrying, and parse JSON defensively. Work in drills/http_drills.py.
Set up a practice target (5 min)
Save this as drills/flaky_server.py. Do not study it yet; it is a deliberately unreliable server you will understand fully in Block 4.
import asyncio
import itertools
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
counter = itertools.count(1)
@app.get("/ok")
async def ok() -> dict[str, str]:
return {"status": "ok"}
@app.get("/slow")
async def slow(seconds: float = 3) -> dict[str, float]:
await asyncio.sleep(seconds)
return {"slept": seconds}
@app.get("/flaky")
async def flaky() -> dict[str, int]:
n = next(counter)
if n % 3 != 0: # fails twice, then succeeds
raise HTTPException(status_code=503, detail="try again")
return {"attempt": n}
@app.get("/limited")
async def limited() -> JSONResponse:
return JSONResponse(
{"detail": "slow down"}, status_code=429, headers={"Retry-After": "1"}
)
Run it in a second terminal (with the venv active): cd drills && uvicorn flaky_server:app --port 8001.
Mini-lesson (10 min)
| Status | Meaning | Retry? |
|---|---|---|
| 200 / 201 / 204 | OK / created / no content | n/a |
| 400 / 422 | Bad request / validation failed | No, fix the request |
| 401 / 403 | Not authenticated / not allowed | No |
| 404 | Not found | No |
| 429 | Rate limited, often with a Retry-After header |
Yes, after waiting |
| 500 / 502 / 503 / 504 | Server error / bad gateway / unavailable / gateway timeout | Yes, with backoff |
- Timeouts: every outbound call needs one. Without it, a hung server hangs your program forever. In httpx,
timeout=5.0applies to connect, read, write and pool separately. - Retries: retry only transient failures (the 429 and 5xx rows plus network errors), and only for requests that are safe to repeat (GET, PUT, DELETE, or a POST with an idempotency key).
- Backoff: wait longer after each failure, for example
base * 2**attempt, plus a little random jitter so many clients do not retry in lockstep. - JSON:
response.json()parses the body into dicts, lists, strings, numbers, booleans andNone. Always expect it to fail on bad input.
import httpx
with httpx.Client(timeout=5.0) as client:
r = client.get("http://localhost:8001/ok")
print(r.status_code, r.headers["content-type"], r.json())
Exercise 3.1 (Core, 10 min): read status codes
Call /ok, /nope and /flaky (four times in a row) and print the status code and body of each. Then call r.raise_for_status() on the failures and catch httpx.HTTPStatusError, printing e.response.status_code. Write down which codes you would retry and which you would not.
Exercise 3.2 (Core, 10 min): timeouts
Call /slow?seconds=3 with timeout=1.0 and catch httpx.TimeoutException. Then retry with timeout=5.0 and confirm it succeeds. Try httpx.Timeout(connect=1.0, read=5.0, write=5.0, pool=5.0) and say what each number controls.
Exercise 3.3 (Core, 20 min): retry with backoff
Write get_json_with_retry(url: str, *, max_attempts: int = 3, base_delay: float = 0.2) -> dict with these rules:
- Retry on 429, 500, 502, 503 and 504, and on
httpx.TransportError(this includes timeouts). - Do not retry any other 4xx; raise
httpx.HTTPStatusErrorimmediately. - Wait
base_delay * 2**attemptplus up to 0.1 seconds of random jitter between attempts. If the response has aRetry-Afterheader, wait that many seconds instead. - After the final attempt, raise the last error.
- Print one log line per attempt, for example
attempt 2/3 -> 503.
Test it on /flaky (succeeds on the third attempt), /limited (watch it honour Retry-After), /nope (fails after one attempt) and /slow?seconds=3 with a 1-second timeout.
Exercise 3.4 (Core, 10 min): JSON in and out
- Run
json.loads('{"name": "Ada", "tags": ["a"], "score": null, "ok": true}')and print the type of every value. NotenullbecomesNoneandtruebecomesTrue. - Print it back with
json.dumps(data, indent=2, sort_keys=True). - Parse
'{"name": 'insidetry/except json.JSONDecodeErrorand print a friendly message. - Try
json.dumps(Task("x"))using your Block 1 dataclass and read the error. Fix it withdataclasses.asdict. Then tryjson.dumps({"when": datetime.now()})and note that dates need converting to strings first.
Exercise 3.5 (Stretch, 10 min)
Make an async version with httpx.AsyncClient and await asyncio.sleep for backoff, then use asyncio.gather to fetch /flaky five times concurrently. Notice how the shared counter changes which attempts fail.
Self-check: save as drills/check_block3.py (server must be running)
import httpx
from http_drills import get_json_with_retry
BASE = "http://localhost:8001"
assert get_json_with_retry(f"{BASE}/ok") == {"status": "ok"}
assert get_json_with_retry(f"{BASE}/flaky")["attempt"] % 3 == 0
try:
get_json_with_retry(f"{BASE}/nope")
except httpx.HTTPStatusError as e:
assert e.response.status_code == 404 # must fail fast, no retries
else:
raise AssertionError("expected a 404 error")
print("block 3 ok")
Explain out loud, without notes
- Why retry a 503 but never a 404 or 422?
- Why add exponential backoff and jitter instead of retrying immediately?
- What happens to a service if you call it with no timeout and it hangs?
- What is the difference between a connect timeout and a read timeout?
Commit: feat: resilient http client.
Block 4: FastAPI with validation, errors and tests (100 min)
Goal: build a small Notes API where bad input is rejected automatically, errors are deliberate, and a test suite proves it. Work in app/main.py and tests/test_notes.py. The notes live in memory for now; Block 5 moves them into a database.
Mini-lesson (15 min)
- FastAPI turns a decorated Python function into an HTTP endpoint. The type hints drive everything: they parse path and query parameters, validate bodies and generate interactive docs at
/docs. - A Pydantic model describes a JSON shape. If the request body does not match, FastAPI returns 422 with a list of what was wrong, and your function never runs.
response_modelcontrols what leaves the API, so internal fields never leak.status_code=201sets the success code.- Errors you choose are raised with
HTTPException(status_code=..., detail=...). For errors specific to your domain, define an exception class and register a handler with@app.exception_handler. - A plain
defendpoint runs in a thread pool. Anasync defendpoint runs on the event loop, so it must onlyawaitand never block (Block 2, Exercise 2.2). TestClientcalls your app directly in-process, so tests need no running server.
import itertools
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Notes API")
_ids = itertools.count(1)
class NoteIn(BaseModel):
title: str = Field(min_length=1, max_length=100)
body: str = ""
class NoteOut(NoteIn):
id: int
NOTES: dict[int, NoteOut] = {}
@app.post("/notes", response_model=NoteOut, status_code=201)
def create_note(note: NoteIn) -> NoteOut:
new = NoteOut(id=next(_ids), **note.model_dump())
NOTES[new.id] = new
return new
@app.get("/notes/{note_id}", response_model=NoteOut)
def get_note(note_id: int) -> NoteOut:
if note_id not in NOTES:
raise HTTPException(status_code=404, detail="note not found")
return NOTES[note_id]
Create pytest.ini in the repo root so tests can import app:
[pytest]
pythonpath = .
asyncio_mode = auto
Run the server from the repo root with uvicorn app.main:app --reload and open http://127.0.0.1:8000/docs.
Exercise 4.1 (Core, 15 min): see what you get for free
In the /docs page, create a note, then fetch it. Then trigger each of these and read the response body: an empty title (422), /notes/abc (422), /notes/99 (404). Write down which of the three errors FastAPI produced by itself and which one your own code raised.
Exercise 4.2 (Core, 25 min): complete the CRUD
Add these endpoints, returning 404 for a missing note where relevant:
GET /noteswithlimit: int = Query(10, ge=1, le=100)and an optionalq: str | None = Nonethat keeps only notes whose title containsq, ignoring case.PUT /notes/{note_id}replaces title and body of an existing note.DELETE /notes/{note_id}returns 204 with an empty body.
Then try /notes?limit=0 and /notes?limit=500 and confirm both give 422 without any extra code from you.
Exercise 4.3 (Core, 20 min): custom validation and errors
- Reject titles that are only whitespace with a
@field_validator("title")onNoteIn. Confirm it returns 422. - Define
class DuplicateTitle(Exception)and raise it from create and update when another note already has the same title, ignoring case. - Register
@app.exception_handler(DuplicateTitle)returningJSONResponse(status_code=409, content={"error": "duplicate_title", "detail": ...}).
Exercise 4.4 (Core, 30 min): write the tests
Create tests/test_notes.py from this skeleton and add at least five more tests:
import pytest
from fastapi.testclient import TestClient
from app.main import NOTES, app
client = TestClient(app)
@pytest.fixture(autouse=True)
def clean_state() -> None:
NOTES.clear()
def test_create_note() -> None:
r = client.post("/notes", json={"title": "First", "body": "hello"})
assert r.status_code == 201
assert r.json()["title"] == "First"
Cover at least: fetch an existing note (200), fetch a missing note (404), empty title (422), whitespace title (422), duplicate title (409 and the error field), delete then fetch (204 then 404), and limit=0 (422). Read each note's id from the create response instead of assuming it is 1. Run pytest -q from the repo root.
If pytest prints a deprecation warning about httpx and TestClient, it comes from the library versions you installed. It is harmless while the tests pass.
Exercise 4.5 (Stretch, 15 min): a dependency
Add def require_key(x_api_key: str = Header(...)) -> None that raises 401 unless the header equals "secret", and protect DELETE with dependencies=[Depends(require_key)]. Add tests for the missing-key and correct-key cases.
Self-check
pytest -qreports at least 7 passed and 0 failed./docslists all five routes with their status codes.- This works against the running server:
curl -s -X POST localhost:8000/notes -H "content-type: application/json" -d '{"title":"hi"}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/notes -H "content-type: application/json" -d '{"title":""}'
The first prints a note with an id; the second prints 422.
Explain out loud, without notes
- Which part of your code makes a bad request return 422, and which makes a missing note return 404?
- What would leak if you removed
response_modelfrom an endpoint that returned a database row? - When should an endpoint be
defand whenasync def? - Why does
TestClientnot needuvicornrunning?
Commit: feat: notes api with tests.
Block 5: SQL and SQLAlchemy (90 min)
Goal: write joins by hand, see an index change a query plan, then persist the Notes API in a real database through SQLAlchemy. Part A is raw SQL in SQLite (45 min); Part B is SQLAlchemy 2.0 (45 min).
Part A mini-lesson (5 min)
- A primary key uniquely identifies a row. A foreign key points at another table's primary key, and the database refuses rows that break the link.
- An INNER JOIN keeps only rows that match on both sides. A LEFT JOIN keeps every row from the left table and fills the right side with
NULLwhen there is no match. GROUP BYcollapses rows into groups;HAVINGfilters those groups after counting, whereWHEREfilters rows before.- An index is a sorted lookup structure. It turns "scan every row" into "jump to the matching rows", at the cost of extra storage and slower writes.
Save as drills/schema.sql, then load it with cd drills && sqlite3 practice.db < schema.sql. The sqlite3 command ships with macOS and most Linux; on Windows use DB Browser for SQLite, or python -m sqlite3 on Python 3.12 or newer.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (name, email) VALUES
('Ada', '[email protected]'),
('Linus', '[email protected]'),
('Grace', '[email protected]');
INSERT INTO notes (user_id, title) VALUES
(1, 'Engine'), (1, 'Analysis'),
(2, 'Kernel'), (2, 'Git'), (2, 'Opinions');
Grace has no notes, which is what makes the LEFT JOIN exercise interesting.
Exercise 5.1 (Core, 20 min): joins
Write one query for each, run it, and predict the row count before you run it:
- Every note with its author's name (INNER JOIN). Expect 5 rows.
- Every user with the number of notes they wrote, including users with zero (LEFT JOIN plus
GROUP BY). Grace must appear with 0. - Only users with more than one note (
HAVING COUNT(*) > 1). Expect Ada and Linus. - Users with no notes at all, using
LEFT JOIN ... WHERE n.id IS NULL. Expect Grace. - Change query 2 to an INNER JOIN and describe in one sentence what disappears and why.
Exercise 5.2 (Core, 15 min): see an index work
First add 200,000 rows:
WITH RECURSIVE seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 200000
)
INSERT INTO notes (user_id, title)
SELECT (n % 3) + 1, 'note ' || n FROM seq;
Then:
- Run
EXPLAIN QUERY PLAN SELECT * FROM notes WHERE user_id = 2;and note the wordSCAN. - Run
CREATE INDEX ix_notes_user_id ON notes(user_id);and repeat step 1. You should now seeSEARCH ... USING INDEX. - Run
.timer onand timeSELECT COUNT(*) FROM notes WHERE title = 'note 150000';before and afterCREATE INDEX ix_notes_title ON notes(title);. - Write two sentences: why does
users.emailalready have an index without you creating one, and what do indexes cost you on every INSERT?
Part B mini-lesson (10 min)
- An Engine holds the database connection pool. A Session is one unit of work: it tracks the objects you load or add, and
commit()writes them in one transaction.rollback()throws the changes away. - Declare tables as Python classes with
Mapped[...]annotations. Arelationshiplets you move between related rows as ordinary attributes. - Query with
select(...), then run it withsession.scalars(stmt).
from sqlalchemy import ForeignKey, String, create_engine, select
from sqlalchemy.orm import (
DeclarativeBase, Mapped, Session, mapped_column, relationship,
)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(200), unique=True)
notes: Mapped[list["Note"]] = relationship(back_populates="user")
class Note(Base):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
title: Mapped[str] = mapped_column(String(100))
body: Mapped[str] = mapped_column(default="")
user: Mapped[User] = relationship(back_populates="notes")
engine = create_engine("sqlite:///orm_demo.db", echo=True)
Base.metadata.create_all(engine)
with Session(engine) as session:
ada = User(name="Ada", email="[email protected]")
ada.notes.append(Note(title="Engine"))
session.add(ada)
session.commit()
stmt = select(Note).join(Note.user).where(User.name == "Ada")
for note in session.scalars(stmt):
print(note.id, note.title)
Exercise 5.3 (Core, 15 min): the ORM in a scratch file
Run the snippet as drills/orm_demo.py with echo=True and read the SQL it prints. Add a second user with two notes. Write a select with a join and a where on the note title, and compare the printed SQL with the join you wrote by hand in 5.1. Delete orm_demo.db and rerun to confirm create_all rebuilds it.
Exercise 5.4 (Core, 25 min): put the Notes API on a database
Create app/db.py:
import os
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_session() -> Iterator[Session]:
with SessionLocal() as session:
yield session
Then:
- Add
app/models.pywith aNoteRow(Base)table holdingid,titleandbody. - Give
NoteOutthe settingmodel_config = ConfigDict(from_attributes=True)so it can be built from aNoteRow. - Rewrite every endpoint to take
session: Session = Depends(get_session)and usesession.add,session.get,session.scalars(select(...)),session.deleteandsession.commit. Detect duplicate titles withfunc.lower(NoteRow.title) == title.lower(). - Create the tables at startup with a lifespan handler that calls
Base.metadata.create_all(engine). - Point your tests at a throwaway in-memory database by overriding the dependency:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db import Base, get_session
from app.main import app
test_engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
TestSession = sessionmaker(bind=test_engine, expire_on_commit=False)
@pytest.fixture(autouse=True)
def fresh_db():
Base.metadata.create_all(test_engine)
def override():
with TestSession() as s:
yield s
app.dependency_overrides[get_session] = override
yield
app.dependency_overrides.clear()
Base.metadata.drop_all(test_engine)
Replace the old clean_state fixture with this one. Your Block 4 tests should pass unchanged apart from the import of NOTES, which you remove.
Exercise 5.5 (Core, 10 min): the N+1 query problem
In the scratch file with echo=True, loop over all users and print len(user.notes). Count how many SELECT statements ran: one for the users plus one per user. Now load with select(User).options(selectinload(User.notes)) and count again. Write one sentence on why this matters for an endpoint that lists 100 users with their notes.
Exercise 5.6 (Stretch, 15 min)
Add a UserRow table and an owner_id foreign key to notes, then GET /users/{user_id}/notes implemented with a join. Add a test, and add an index on owner_id.
Self-check
pytest -qpasses against the in-memory database.- Create a note, stop the server, start it again, and
GET /notesstill returns it. That proves it is stored on disk inapp.db. - Starting the server with
DATABASE_URL=sqlite:///./other.db uvicorn app.main:appcreates a separate empty database. You will use this switch for Postgres in Block 6.
Explain out loud, without notes
- What rows does an INNER JOIN drop that a LEFT JOIN keeps?
- What is an index, why does it speed up reads, and when does it hurt?
- What is a Session, and what happens to your changes if you never call
commit()? - What is the N+1 problem, and how does
selectinloadfix it?
Commit: feat: sqlalchemy persistence.
Block 6: Git, Docker and CI (90 min)
Goal: collaborate through Git, start the whole app (API plus Postgres) with one command, and have GitHub run your tests on every push. Part A is Git (20 min), Part B is Docker and Compose (45 min), Part C is CI (25 min).
Part A: Git workflow
Mini-lesson (5 min)
- A commit is a snapshot with a message. A branch is a movable label pointing at a commit, so a feature can grow without touching
main. - The everyday loop is
git switch -c feature/x, edit,git add,git commit,git push, then a pull request that CI checks before merge. - A merge conflict happens when two branches changed the same lines. Git marks both versions in the file and waits for you to choose.
Exercise 6.1 (Core, 15 min)
- Create an empty GitHub repository, then connect and push:
git remote add origin [email protected]:<you>/ai-prereqs.git
git branch -M main
git push -u origin main
- Run
git switch -c feature/health, addGET /healthreturning{"status": "ok"}plus a test, commit withfeat: add health endpoint, switch back tomainand rungit merge feature/health. - Create a deliberate conflict: on branch
a, change thetitle=of the FastAPI app to"Notes A"; on branchb, change the same line to"Notes B". Merge both intomainand resolve the conflict by editing the markers by hand, thengit addandgit commit. - Run
git log --oneline --graph --alland find both merges. Practisegit diff,git restore <file>(discard an edit) andgit stash/git stash pop(park work).
Part B: Docker and Compose
Mini-lesson (10 min)
- An image is a packaged, read-only filesystem plus a start command. A container is a running instance of an image; delete it and everything written inside it is gone unless stored in a volume.
- A Dockerfile builds an image in cached layers. Copy
requirements.txtand install dependencies before copying your code, so editing code does not reinstall packages. - Docker Compose describes several containers in one YAML file. Services reach each other by service name, so the API connects to the database at host
db.
First add the Postgres driver: pip install "psycopg[binary]" && pip freeze > requirements.txt.
Dockerfile:
FROM python:3.12-slim
WORKDIR /code
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
.dockerignore:
.venv/
.git/
__pycache__/
*.db
.pytest_cache/
docker-compose.yml:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: notes
POSTGRES_DB: notes
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 3s
timeout: 3s
retries: 10
api:
build: .
environment:
DATABASE_URL: postgresql+psycopg://notes:notes@db:5432/notes
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
Exercise 6.2 (Core, 15 min): build and run a single container
- Run
docker build -t notes-api .thendocker run --rm -p 8000:8000 notes-apiand openhttp://localhost:8000/docs. - Create a note, stop the container with Ctrl+C, run it again, and confirm the note is gone. Say why (the SQLite file lived inside the container).
- Edit one line in
app/main.py, rebuild, and find theCACHEDmarker on thepip installstep. Then editrequirements.txtand watch that layer rebuild.
Exercise 6.3 (Core, 20 min): Compose with Postgres
- Run
docker compose up --build. Create a note through/docs. - Run
docker compose down, thendocker compose upagain. The note should still exist, because thepgdatavolume survived. - Inspect the database directly:
docker compose exec db psql -U notes -d notes -c "select * from notes;"(use your own table name if different). Look at the API's output withdocker compose logs api. - Run
docker compose down -v, bring it back up, and confirm the data is gone. Say what-vremoved.
Exercise 6.4 (Stretch, 10 min)
Add a USER instruction so the container does not run as root (create one with RUN useradd -m app), and add a Compose healthcheck on the api service that calls /health using Python's urllib.
Part C: Continuous integration
Mini-lesson (5 min)
CI means a clean machine runs your tests every time code changes, so "works on my machine" stops being an excuse. GitHub Actions reads workflow files from .github/workflows/. Each job runs on a fresh runner, and a failing step turns the pull request red.
.github/workflows/ci.yml:
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: mypy app
- run: pytest -q
docker:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- run: docker build -t notes-api .
The action versions above are valid majors; check each action's Marketplace page for the newest one when you set up a real project. If mypy app fails on your code, fix the type errors rather than deleting the line.
Exercise 6.5 (Core, 20 min): make CI go green, red, green
- Commit the workflow to
mainand push. Open the Actions tab and wait for a green check. - Create branch
break-it, change one assertion in a test so it fails, push, and open a pull request. Watch the check turn red and read the failing log. - Fix the test on the same branch, push, and watch the PR turn green before you merge it.
Exercise 6.6 (Stretch, 10 min)
Add a third job that proves the whole stack boots from a clean checkout:
compose:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- run: docker compose up -d --build --wait
- run: curl --fail --retry 10 --retry-connrefused --retry-delay 2 http://localhost:8000/docs
- run: docker compose down -v
In the repository settings, add a branch protection rule on main that requires the test check to pass before merging.
Self-check
git log --oneline --graph --allshows at least one feature branch and a resolved merge.- In a fresh folder,
git clone <your repo> && cd ai-prereqs && docker compose up --buildgives a working API with Postgres and no other setup. - GitHub shows a green check on
mainand a red-then-green history on yourbreak-itpull request.
Explain out loud, without notes
- What is the difference between an image and a container, and where does a container's data go when it is deleted?
- Why does the Dockerfile copy
requirements.txtbeforeapp? - Why can the API reach the database at
db:5432, whenlocalhostwould fail inside the container? - What does CI catch that running tests on your own laptop does not?
Commit: ci: docker compose and github actions.
Block 7: Capstone, a link-checker API (60 min)
Goal: combine all six topics in one small service. You submit a batch of URLs; the service checks them concurrently with timeouts and retries, stores the results in a database, and lets you query them. Work on a branch called feature/link-checker and merge through a pull request so CI has to pass.
Behaviour to build
| Endpoint | Behaviour |
|---|---|
POST /checks |
Body {"urls": ["https://example.com", ...]}. Accept 1 to 20 valid http(s) URLs, otherwise 422. Check them all concurrently, save the batch, return 201 with {"id": 1, "results": [...]}. |
GET /checks/{id} |
The batch with its results, or 404 {"detail": "check not found"}. |
GET /checks/{id}/failures |
Only the results where ok is false, loaded with a join and filter in SQL, or 404 for an unknown batch. |
Each result has url, status_code (nullable), ok (true for 2xx), elapsed_ms and error (nullable text).
Rules that tie the blocks together
- Python: the checker returns a
@dataclassUrlResult, every function is type-annotated, andmypy apppasses. - Async: all URLs in a batch run through
asyncio.gather, limited to 5 at a time by aSemaphore. - HTTP: each request has a 3-second timeout and up to 3 attempts on 5xx, 429 and transport errors, with backoff (reuse Block 3, made async with a configurable
base_delay). One bad URL must never fail the whole batch; it becomes a result withok=falseand anerrormessage. - FastAPI: use
Field(min_length=1, max_length=20)on the list andHttpUrlfor items, a 404 for unknown ids, andDependsto supply both the DB session and the sharedhttpx.AsyncClient. - SQL: tables
checks(id, created_at)andresults(id, check_id FK, url, status_code, ok, elapsed_ms, error)with an index onresults.check_id. Confirm withEXPLAINor the SQL echo that the failures query uses the join and the index path. - Docker and CI:
docker compose up --buildserves the API on Postgres, and the CI workflow runsmypyandpytest.
Suggested time plan
- 5 min: sketch the two tables and the
UrlResultdataclass on paper. - 25 min: models, the async
check_urlandcheck_manyfunctions, then the three endpoints. - 20 min: tests (below).
- 10 min: compose run, push the branch, open the PR, watch CI.
Testing without the internet
Inject the HTTP client as a dependency so tests can swap in a fake transport. httpx.MockTransport answers requests from a function you write:
import asyncio
import httpx
async def handler(request: httpx.Request) -> httpx.Response:
await asyncio.sleep(0.3) # pretend the network is slow
if request.url.host == "good.test":
return httpx.Response(200, json={"hello": "world"})
if request.url.host == "bad.test":
return httpx.Response(503)
raise httpx.ConnectTimeout("boom", request=request)
def make_test_client() -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
Override your client dependency with app.dependency_overrides, the same way you overrode the session in Block 5. Write tests for all of these:
- A mixed batch of
good.test,bad.testandslow.testURLs returns 201 and three results with the expectedokvalues. - An empty list, 21 URLs, and
"not a url"each return 422. GET /checks/999returns 404./failuresreturns only the failed results for the right batch, even when a second batch exists.- Ten
good.testURLs finish in under 2 seconds. Ten sequential 0.3-second calls would take 3 seconds, so this proves the checks run concurrently. - A URL that returns 503 is attempted 3 times. Count calls inside the handler, and pass a tiny
base_delayso the test stays fast.
Definition of done
-
pytest -qpasses andmypy appis clean. - A fresh
docker compose up --buildservesPOST /checkswith a real URL such ashttps://example.com. - The pull request shows a green CI check before you merge it.
- A short
README.mdgives the three commands to run, test and start the app. - You can walk someone through one request from
curlto the database row and back, naming where validation, concurrency, retry and the join each happen.
Stretch (if you finish early)
- Add
GET /checks/{id}/export.csvthat streams rows with a generator andStreamingResponse, so a million-row export would not need to sit in memory. - Add a
GET /checks?limit=&offset=list endpoint and a composite index that supports it. - Return a
Retry-After-aware error when a target URL answers 429.
Commit and merge: feat: capstone link checker.
Block 8: End-of-day checkpoint (20 min)
You are ready to move on when every row below scores at least 1 and no row scores 0. Score yourself honestly: 0 means you cannot do it, 1 means you can do it with the docs open, 2 means you can do it from memory. Edit the last column as you go.
| Topic | You can... | Score (0-2) |
|---|---|---|
| Python | Write a validated dataclass, a class with a property, and a generator, all with type hints that pass mypy. | |
| Async Python | Explain async def and await, and run 10 calls concurrently with asyncio.gather and a Semaphore. |
|
| HTTP and JSON | Name which status codes to retry, set timeouts, write retry with backoff, and parse JSON without crashing on bad input. | |
| FastAPI | Build endpoints with validation (422), deliberate errors (404, 409) and Depends, and test them with TestClient. |
|
| SQL and SQLAlchemy | Write INNER and LEFT joins, show an index in a query plan, and use a Session with select, commit and selectinload. |
|
| Git | Branch, commit, resolve a merge conflict and open a pull request. | |
| Docker and Compose | Write a Dockerfile, explain layers, and start API plus Postgres with docker compose up. |
|
| CI | Read a GitHub Actions workflow and explain why a pull request is red or green. |
If any row scores 0
Spend 60 to 90 minutes before Day 2 repeating that block's Core exercises from a blank file, without looking at your earlier solution. The roadmap's own advice is the same: revisit the matching part of your Python roadmap before moving on, because later topics assume these skills without re-teaching them.
Five-minute wrap-up
- Push everything and make sure the CI badge or latest run is green.
- Write three lines in your
README.mdunder "Notes to self": the thing that confused you most, the thing that clicked, and the one exercise you will redo. - Run
docker compose down -vso you start tomorrow from a clean slate.
Quick reference: the six blocks in one line each
- Python: dataclasses hold data, classes add behaviour, generators stream, type hints document.
- Async:
awaityields control;gatherruns many waits at once; never block the loop. - HTTP: retry transient failures with backoff; never retry client errors; always set a timeout.
- FastAPI: type hints give validation and docs; 422 is automatic, everything else you raise on purpose.
- SQL: joins combine tables, indexes speed reads, sessions batch writes into a transaction.
- Git, Docker, CI: branches isolate work, Compose reproduces the stack, CI proves it on a clean machine.
Notes & links
Saved automatically.