What’s one thing you learned? What’s still confusing?
Production Python: Logging, Config & CLI
Production-grade logging, config management, CLI tools (argparse/click/typer), packaging.
Python Mini-Projects, Part 1
Three hands-on projects: calculator with history, persistent contact book, and a word frequency analyzer — the foundation of NLP preprocessing.
Python Mini-Projects, Part 2
Two larger projects: a pandas-driven student-performance dashboard and an OOP-driven CLI quiz game with decorators, random shuffling, and a JSON-persisted leaderboard.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
The reason this works: real Python files have a predictable spine. Imports at top, config constants next, then domain types, then dependency wiring, then endpoint or entrypoint code. Once you have read one well-structured file, you know where to look in every other one.
uvicorn main:app --reload.Here is the entire file -- around 180 lines. Read it top-to-bottom once before continuing. Sixty seconds. Do not stop on anything confusing. Just note "imports, dataclass, schemas, endpoints" and keep scrolling.
Done with pass 1? You should be able to answer: how many endpoints? (Five.) What is the storage? (In-memory dict.) Where is the validation? (Pydantic models.) If you cannot answer those, scroll back up. That is the entire point of pass 1.
Now let's walk through each section.
from __future__ import annotations
import logging
import os
import secrets
import string
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, HttpUrl, Fieldfrom __future__ import annotations on line 1 turns every type hint into a string at import time. That means dict[str, LinkRecord] works on Python 3.8 even though dict-as-generic was added in 3.9, and it eliminates circular-import pain in larger codebases. Most production files have it. The secrets module (not random) is the cryptographically-safe RNG -- whenever you generate a token, slug, or password, reach for secrets, not random. The split between dataclasses, datetime, typing shows the author thinks in terms of "what role does each module play": dataclass for value objects, datetime for timestamps, typing for annotations.Why does this file import secrets.choice instead of random.choice for slug generation?
logger = logging.getLogger(__name__)
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
SLUG_CHARS = string.ascii_letters + string.digits
SLUG_LEN = 7
MAX_URL_LEN = 2048logging.getLogger(__name__) is the canonical idiom -- each module gets a logger named after itself, so log lines tell you exactly which file they came from when filtered. The log level comes from LOG_LEVEL env var with "INFO" as a sane default -- this is the "twelve-factor app" pattern: configuration via environment, not code. The UPPER_SNAKE_CASE constants live at module level so any future change is a single edit (and grep-able). Notice SLUG_LEN = 7: with 62 alphanumeric characters, that is 62^7 ≈ 3.5 trillion possible slugs -- enough headroom that the "could not generate unique slug" branch will basically never fire.If you wanted to run this service with debug-level logs in development without editing the file, what would you do?
@dataclass
class LinkRecord:
long_url: str
slug: str
created_at: datetime
visits: int = 0
last_accessed: datetime | None = None@dataclass auto-generates __init__, __repr__, and __eq__ from the class attributes.def __init__(self, long_url, slug, ...): self.long_url = long_url; ... for five fields is twelve lines of repetitive code. @dataclass collapses it to five. The datetime | None union (3.10+) plus = None default means "this field is optional and defaults to not-yet-set". Order of fields matters: required fields (no defaults) come first; defaulted fields last -- otherwise Python raises TypeError: non-default argument 'x' follows default argument 'y'. This is a real gotcha when you go to add a new field months later.@dataclass
class LinkStore:
"""In-memory store. In production this would be Postgres."""
_by_slug: dict[str, LinkRecord] = field(default_factory=dict)
def create(self, long_url: str, slug: str | None = None) -> LinkRecord:
..._by_slug has a leading underscore -- Python's "do not touch this from outside the class" convention. Not enforced, but anyone reading the file knows the contract. (2) field(default_factory=dict) instead of = {} -- this is the single most important dataclass rule. A bare = {} would create one dict shared across all instances of LinkStore. default_factory=dict calls dict() fresh on every instantiation. The same trap exists in JavaScript with default function parameters. (3) The docstring tells you the design decision out loud: this is in-memory by design, swap-to-Postgres later. That single line saves a future reader twenty minutes of "wait, where's the database connection?".The author wrote _by_slug with a leading underscore. Code outside the class still reaches into _store._by_slug (see the lifespan and health endpoint). Is that a bug?
_store = LinkStore()
def get_store() -> LinkStore:
return _storeLinkStore instance for the whole process and exposes it through a function that FastAPI's dependency-injection system will call.from somewhere import store? The answer is testability. In a test, you can override the dependency: app.dependency_overrides[get_store] = lambda: FakeStore(). Every endpoint then receives the fake store instead of the real one, with zero changes to the endpoint code. This is the single most valuable pattern in FastAPI and the reason it scales to large codebases. The "wrapping a global in a function" smell IS the design.class ShortenRequest(BaseModel):
long_url: HttpUrl
custom_slug: str | None = Field(default=None, min_length=3, max_length=32)POST /shorten. Pydantic validates incoming data against this class before your endpoint runs.HttpUrl is not just str -- it is a Pydantic type that rejects "hello", "javascript:alert(1)", missing schemes, and other garbage at the boundary. The Field(min_length=3, max_length=32) constraints are validated automatically and surface as 422 Unprocessable Entity with a precise error message when violated. You never write if len(custom_slug) < 3: raise ... -- Pydantic does it. This is the boundary-validation principle: validate once, at the edge, with types; never check again inside business logic.ShortenRequest (what clients send), ShortenResponse (what we return), StatsResponse (a different read view), HealthResponse (operational). Four schemas for one resource. This is intentional. Mixing them produces the "leaking internal field through the API" bug that every junior engineer ships at least once.Why does this file define `ShortenResponse` as a separate Pydantic model instead of returning `LinkRecord` directly?
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("URL shortener starting up")
yield
logger.info("URL shortener shutting down. %d links in store at exit", len(_store._by_slug))
app = FastAPI(
title="URL Shortener",
version="1.0.0",
lifespan=lifespan,
)yield runs once at server startup; the code after yield runs once at shutdown.@asynccontextmanager turns a generator function into an async context manager (the same protocol as async with). FastAPI calls lifespan(app).__aenter__() on boot, then __aexit__() on shutdown. This is where you open database pools, load ML models from disk, warm caches, connect to message brokers -- anything that should happen once per process, not once per request. The %d formatting (rather than f-string) is deliberate: it defers string formatting until the log handler actually decides to emit -- a small perf win at production log volumes, but standard practice in serious codebases.@app.post("/shorten", response_model=ShortenResponse, status_code=status.HTTP_201_CREATED)
async def shorten(
payload: ShortenRequest,
store: Annotated[LinkStore, Depends(get_store)],
) -> ShortenResponse:
try:
record = store.create(str(payload.long_url), slug=payload.custom_slug)
except ValueError as e:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(e))
return ShortenResponse(
slug=record.slug,
short_url=f"/{record.slug}",
long_url=record.long_url,
)201 Created status.@app.post("/shorten", ...) registers the route AND declares the response model AND the success status -- three pieces of metadata in one line. (2) payload: ShortenRequest tells FastAPI "parse the request body as this Pydantic model". (3) store: Annotated[LinkStore, Depends(get_store)] says "call get_store() to get the store, inject it here". The Annotated[Type, ...] syntax is the modern way (replaces the older = Depends(...)) and is what mypy and IDEs understand best. (4) str(payload.long_url) -- HttpUrl is a Pydantic type, not a str, so we cast it for storage. (5) try/except ValueError -> raise HTTPException: this is the translation layer. Domain errors (ValueError) become HTTP errors (409 Conflict). Your business logic stays HTTP-agnostic; only this thin layer knows about HTTP. (6) The return value is constructed explicitly even though it could be inferred -- explicitness wins when junior engineers read the file in six months.If a client POSTs {"long_url": "not-a-url"}, what status code comes back?
@app.get("/{slug}", response_class=RedirectResponse, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
async def follow(
slug: str,
store: Annotated[LinkStore, Depends(get_store)],
) -> RedirectResponse:
record = store.get(slug)
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")
store.record_visit(slug)
return RedirectResponse(url=record.long_url)/abc123, we look up the record and 307-redirect them to the original URL (counting the visit on the way)./{slug} -- a placeholder that captures whatever segment the user typed and binds it to the slug parameter. The response_class=RedirectResponse and status_code=307 are declared up front so FastAPI's auto-generated OpenAPI docs know this endpoint redirects (not returns JSON). Why 307 and not 302? 307 is "temporary, preserve the HTTP method" -- a POST stays a POST. 302 is older and historically inconsistent across clients. 301/308 are permanent. For a URL shortener, 307 is the right choice because the mapping might change (custom slugs can be deleted and reused). The order of operations matters: look up, raise if missing, THEN record the visit -- so unknown slugs do not pollute the visit counter.Notice `record = store.get(slug)` returns `LinkRecord | None`. What would happen if we wrote `record.long_url` without the None check?
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")return Response(status_code=404, ...)?" Two reasons. (1) raise unwinds the stack -- if an HTTPException fires three function calls deep, every caller above it short-circuits without each one having to check a return value. (2) FastAPI's middleware, logging, and Sentry/OpenTelemetry integrations all hook into the exception machinery. A return skips them. Always raise errors; only return success. This is the same principle as assert in test code versus if x: return False.status.HTTP_404_NOT_FOUND instead of the literal 404: searchable, refactor-safe, and self-documenting. You will see this style in every serious FastAPI codebase. The string "unknown slug: {slug}" is a debug-friendly detail message -- short, factual, machine-parseable. Never include user PII or stack traces in detail -- it goes straight to the client.These two endpoints repeat patterns you have already seen, so the third pass is fast.
@app.get("/stats/{slug}", response_model=StatsResponse)
async def stats(
slug: str,
store: Annotated[LinkStore, Depends(get_store)],
) -> StatsResponse:
record = store.get(slug)
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")
return StatsResponse(...)follow. Different response model -- that is it.@app.delete("/{slug}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
slug: str,
store: Annotated[LinkStore, Depends(get_store)],
) -> None:
if not store.delete(slug):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")204 No Content is the correct status for a successful delete with no body to return -- the function's -> None return type matches it. Returning None from an async def endpoint is FastAPI's signal to emit an empty body. store.delete(slug) returns a bool, so if not ...: makes the intent explicit ("if the delete reported nothing was there to remove, that is a 404").follow carefully. You skim stats and delete because they reuse 90% of the same shape. You save your attention for the parts that are actually new.@app.get("/health", response_model=HealthResponse)
async def health(store: Annotated[LinkStore, Depends(get_store)]) -> HealthResponse:
return HealthResponse(
status="ok",
links_in_store=len(store._by_slug),
)/health returns 200 + small JSON if alive, anything else if dead. Notice what is NOT in here: no database query that could time out, no expensive computation, no external HTTP calls. Health endpoints must be cheap -- they get hit every few seconds by every load balancer in front of your service. A health endpoint that takes 500ms is a health endpoint that fails under load. There is a related pattern -- /readiness -- for "am I ready to receive traffic" (which DOES check dependencies). Liveness checks "am I alive" (which does not). Some codebases use one endpoint, some use two.Take the URL shortener you just read line-by-line, run it locally, hit each endpoint with curl, then ADD a new endpoint GET /links that returns all links with pagination.
Server starts on localhost:8000.
POST /shorten {"long_url":"https://example.com"} -> 201 + {"slug":"...","short_url":"/...","long_url":"https://example.com/"}
GET /{slug} -> 307 redirect
GET /stats/{slug} -> 200 + visit count
GET /links?limit=10&offset=0 -> 200 + {"items":[...],"total":N}# Add to main.py — fill in the TODOs
class LinksResponse(BaseModel):
items: list[StatsResponse]
total: int
# TODO: add a list_all method to LinkStore
# def list_all(self, limit: int, offset: int) -> list[LinkRecord]:
# ...
@app.get("/links", response_model=LinksResponse)
async def list_links(
limit: int = 10,
offset: int = 0,
# TODO: inject the store with Annotated[..., Depends(get_store)]
):
# TODO: build StatsResponse for each record and return LinksResponse
pass
@dataclass, field(default_factory=...), Annotated[Type, Depends(...)], HTTPException, lifespan @asynccontextmanager, Pydantic request/response model separation, env-var config, structured logging, leading-underscore privacy. You have now seen each one in contextYou are dropped into a new 50-file FastAPI codebase on day one. Your ticket is 'add a /export endpoint that streams CSV'. Which is the right reading strategy?
Annotated[] and mypy-compatible patterns you saw in every endpoint@dataclass, __post_init__, and field(default_factory=...) that makes the domain section in this lesson click deeperraise ... except ... raise HTTPException translation layer pattern"""
url_shortener/main.py
A minimal URL-shortener service with in-memory storage.
Run with: uvicorn main:app --reload
"""
from __future__ import annotations
import logging
import os
import secrets
import string
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, HttpUrl, Field
# Config
logger = logging.getLogger(__name__)
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
SLUG_CHARS = string.ascii_letters + string.digits
SLUG_LEN = 7
MAX_URL_LEN = 2048
# Domain
@dataclass
class LinkRecord:
long_url: str
slug: str
created_at: datetime
visits: int = 0
last_accessed: datetime | None = None
@dataclass
class LinkStore:
"""In-memory store. In production this would be Postgres."""
_by_slug: dict[str, LinkRecord] = field(default_factory=dict)
def create(self, long_url: str, slug: str | None = None) -> LinkRecord:
if slug and slug in self._by_slug:
raise ValueError(f"slug already taken: {slug}")
if slug is None:
slug = self._generate_unique_slug()
record = LinkRecord(
long_url=long_url,
slug=slug,
created_at=datetime.now(timezone.utc),
)
self._by_slug[slug] = record
return record
def get(self, slug: str) -> LinkRecord | None:
return self._by_slug.get(slug)
def delete(self, slug: str) -> bool:
return self._by_slug.pop(slug, None) is not None
def record_visit(self, slug: str) -> None:
rec = self._by_slug.get(slug)
if rec is not None:
rec.visits += 1
rec.last_accessed = datetime.now(timezone.utc)
def _generate_unique_slug(self) -> str:
for _ in range(10):
candidate = "".join(secrets.choice(SLUG_CHARS) for _ in range(SLUG_LEN))
if candidate not in self._by_slug:
return candidate
raise RuntimeError("could not generate unique slug after 10 attempts")
# Singleton store the dependency returns. Swappable for tests.
_store = LinkStore()
def get_store() -> LinkStore:
return _store
# Pydantic schemas
class ShortenRequest(BaseModel):
long_url: HttpUrl
custom_slug: str | None = Field(default=None, min_length=3, max_length=32)
class ShortenResponse(BaseModel):
slug: str
short_url: str
long_url: str
class StatsResponse(BaseModel):
slug: str
long_url: str
visits: int
created_at: datetime
last_accessed: datetime | None
class HealthResponse(BaseModel):
status: str
links_in_store: int
# Lifecycle
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("URL shortener starting up")
yield
logger.info("URL shortener shutting down. %d links in store at exit", len(_store._by_slug))
app = FastAPI(
title="URL Shortener",
version="1.0.0",
lifespan=lifespan,
)
# Endpoints
@app.post("/shorten", response_model=ShortenResponse, status_code=status.HTTP_201_CREATED)
async def shorten(
payload: ShortenRequest,
store: Annotated[LinkStore, Depends(get_store)],
) -> ShortenResponse:
try:
record = store.create(str(payload.long_url), slug=payload.custom_slug)
except ValueError as e:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(e))
return ShortenResponse(
slug=record.slug,
short_url=f"/{record.slug}",
long_url=record.long_url,
)
@app.get("/{slug}", response_class=RedirectResponse, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
async def follow(
slug: str,
store: Annotated[LinkStore, Depends(get_store)],
) -> RedirectResponse:
record = store.get(slug)
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")
store.record_visit(slug)
return RedirectResponse(url=record.long_url)
@app.get("/stats/{slug}", response_model=StatsResponse)
async def stats(
slug: str,
store: Annotated[LinkStore, Depends(get_store)],
) -> StatsResponse:
record = store.get(slug)
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")
return StatsResponse(
slug=record.slug,
long_url=record.long_url,
visits=record.visits,
created_at=record.created_at,
last_accessed=record.last_accessed,
)
@app.delete("/{slug}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
slug: str,
store: Annotated[LinkStore, Depends(get_store)],
) -> None:
if not store.delete(slug):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown slug: {slug}")
@app.get("/health", response_model=HealthResponse)
async def health(store: Annotated[LinkStore, Depends(get_store)]) -> HealthResponse:
return HealthResponse(
status="ok",
links_in_store=len(store._by_slug),
)