Python’s reputation as a beginner language hides how much modern features matter in real code: dataclasses, type hints, match, generators, decorators, context managers and asyncio. This crash course covers them all by building one real project, a weather web app that looks up cities, shows live forecasts, saves favourites and compares five cities at once using the free Open-Meteo API. The stack matches the video: Python 3.15.1, FastAPI 0.162, Pydantic 2.14 and pytest 9.1. This written version walks through the video’s 15 chapters, with the reasoning and gotchas behind each one.
⚡ TL;DR – Always work inside a virtual environment:
python -m venv .venv. –@dataclass(frozen=True)gives you immutable, comparable value objects for free. – Useraise ... from errto keep the original cause when you wrap exceptions. – Generators stream data lazily; decorators wrap behaviour like caching or timing. – FastAPI plusasyncio.gatherturns five sequential API calls into one parallel wait.
Before you start
- Python 3.12+ (the video uses 3.15.1):
python3 --version. - A terminal and editor (VS Code with the Python extension works well).
- No API key: Open-Meteo is free for non-commercial use.
Step 1: Variables, types, control flow and functions
city = "Chennai"
temps = [31.5, 29.0, 33.2]
def describe(t: float) -> str:
if t >= 30:
return "hot"
elif t >= 20:
return "warm"
return "cool"
for t in temps:
print(f"{city}: {t:.1f}°C is {describe(t)}")
Python is dynamically typed: names point to objects, and objects have types. f-strings with format specs (:.1f) are the cleanest way to build output.
Step 2: Collections, slicing and comprehensions
days = [("Mon", 31), ("Tue", 29), ("Wed", 34)] # list of tuples
highs = {day: t for day, t in days} # dict comprehension
hot = [d for d, t in days if t > 30] # list comprehension
seen = {"London", "Paris", "London"} # set -> 2 items
print(highs["Wed"], hot, days[-1], days[:2])
Use tuples for fixed records, lists for ordered collections, dicts for lookups and sets for membership. Comprehensions are faster and clearer than appending in a loop, but switch back to a loop once one stops fitting on a line or two.
Step 3: Virtual environments and pip
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "fastapi[standard]" pytest
pip freeze > requirements.txt
A venv isolates each project’s packages, so upgrading FastAPI for one project can’t break another. Never pip install into the system Python.
Step 4: Classes and dataclasses
from dataclasses import dataclass, field
@dataclass(frozen=True)
class City:
name: str
country: str
latitude: float
longitude: float
def __post_init__(self):
if not -90 <= self.latitude <= 90:
raise ValueError("bad latitude")
@property
def label(self) -> str:
return f"{self.name}, {self.country}"
@dataclass
class Favorites:
cities: list[str] = field(default_factory=list)
@dataclass writes __init__, __repr__ and __eq__ for you. frozen=True makes instances immutable and hashable, so they can go in sets or be dict keys. Use default_factory=list, never = []: a mutable default would be shared by every instance.
Step 5: Type hints
def average(xs: list[float]) -> float | None:
return sum(xs) / len(xs) if xs else None
Hints don’t change runtime behaviour, but editors and mypy use them to catch bugs, and FastAPI and Pydantic use them to validate requests.
Step 6: Call an API with urllib
import json, urllib.parse, urllib.request
def geocode(name: str) -> City:
q = urllib.parse.urlencode({"name": name, "count": 1})
url = f"https://geocoding-api.open-meteo.com/v1/search?{q}"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.load(r)
if not data.get("results"):
raise CityNotFound(name)
c = data["results"][0]
return City(c["name"], c.get("country", ""), c["latitude"], c["longitude"])
The standard library is enough for simple calls. Always pass timeout: without one a stalled server hangs your program forever.
Step 7: Exceptions
class WeatherError(Exception): ...
class CityNotFound(WeatherError):
def __init__(self, city: str):
super().__init__(f"No city called {city!r}")
try:
city = geocode("Atlantis")
except CityNotFound as e:
print(e)
except OSError as e:
raise WeatherError("network problem") from e
Catch specific exceptions, not bare except:. raise ... from e keeps the original traceback attached, which is invaluable when debugging.
Step 8: Files with pathlib and context managers
from pathlib import Path
from contextlib import contextmanager
import time
FAV = Path("favorites.json")
def save(favs: list[str]) -> None:
FAV.write_text(json.dumps(favs, indent=2), encoding="utf-8")
def load() -> list[str]:
return json.loads(FAV.read_text(encoding="utf-8")) if FAV.exists() else []
@contextmanager
def timer(label: str):
start = time.perf_counter()
try:
yield
finally:
print(f"{label}: {time.perf_counter() - start:.2f}s")
with timer("lookup"):
geocode("London")
with guarantees cleanup even when an exception is raised. @contextmanager turns a generator into your own context manager: code before yield is setup, code in finally is teardown.
Step 9: Generators and Counter
from collections import Counter
def rainy_days(daily: dict):
for date, p in zip(daily["time"], daily["precipitation_probability_max"]):
if p >= 50:
yield date
weekdays = Counter(date[:7] for date in rainy_days(daily))
A generator produces values one at a time instead of building a whole list in memory. Counter tallies anything iterable.
Step 10: Decorators
import functools
def retry(times: int = 3):
def wrap(fn):
@functools.wraps(fn)
def inner(*args, **kwargs):
for attempt in range(times):
try:
return fn(*args, **kwargs)
except OSError:
if attempt == times - 1:
raise
time.sleep(2 ** attempt)
return inner
return wrap
@functools.lru_cache(maxsize=256)
@retry()
def cached_geocode(name: str) -> City: return geocode(name)
A decorator is a function that takes a function and returns a new one. functools.wraps keeps the original name and docstring. lru_cache works here because City is frozen and hashable.
Step 11: match statements
def describe_code(code: int) -> str:
match code:
case 0: return "clear"
case 1 | 2 | 3: return "partly cloudy"
case c if 61 <= c <= 67: return "rain"
case _: return "other"
match does structural pattern matching: it can match literals, alternatives, guards and even the shape of dicts and dataclasses, not just values.
Step 12: FastAPI
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class Day(BaseModel):
date: str
max: float
min: float
@app.get("/api/forecast/{name}")
async def forecast(name: str) -> list[Day]:
try:
city = await asyncio.to_thread(cached_geocode, name)
except CityNotFound as e:
raise HTTPException(404, str(e))
daily = await fetch_daily(city)
return [Day(date=t, max=a, min=b) for t, a, b in
zip(daily["time"], daily["temperature_2m_max"], daily["temperature_2m_min"])]
fastapi dev main.py # http://127.0.0.1:8000/docs for interactive API docs
The type hints drive validation and the auto-generated /docs page. asyncio.to_thread keeps blocking urllib code from freezing the event loop.
Step 13: asyncio for parallel lookups
@app.get("/api/compare")
async def compare(cities: str = "Chennai,London,Tokyo,Paris,Toronto"):
names = cities.split(",")[:5]
results = await asyncio.gather(*(forecast(n) for n in names), return_exceptions=True)
return {n: (r if not isinstance(r, Exception) else {"error": str(r)})
for n, r in zip(names, results)}
gather runs the five lookups concurrently, so the total time is roughly the slowest one. return_exceptions=True stops one bad city from failing the whole response.
Step 14: Testing with pytest
# test_app.py
from fastapi.testclient import TestClient
from main import app, describe
def test_describe():
assert describe(31) == "hot"
def test_unknown_city(monkeypatch):
def boom(name): raise CityNotFound(name)
monkeypatch.setattr("main.cached_geocode", boom)
r = TestClient(app).get("/api/forecast/Atlantis")
assert r.status_code == 404
pytest -q
Plain assert statements and monkeypatch to replace network calls keep tests fast and deterministic.
Troubleshooting
ModuleNotFoundError: fastapi: the venv isn’t activated, or you installed into a different Python.TypeError: unhashable typewithlru_cache: an argument or dataclass isn’t frozen.- The server freezes under load: blocking I/O inside
async def. Wrap it inasyncio.to_threador use an async HTTP client. UnicodeDecodeErroron Windows: always passencoding="utf-8"to file reads and writes.
Best practices
- One venv per project, with pinned requirements.
- Frozen dataclasses for value objects, Pydantic models at API boundaries.
- Specific exceptions, chained with
from. - Timeouts on every network call; tests that never hit the network.
Edge cases and a production-ready touch
Mutable default arguments. The classic Python bug, and why Step 4 uses default_factory:
def add(city, favs=[]): # the same list is reused on every call!
favs.append(city); return favs
add("A"); print(add("B")) # ['A', 'B']
def add(city, favs=None): # fix
favs = [] if favs is None else favs
Atomic file writes. If the process dies halfway through write_text, favorites.json is corrupted. Write to a temporary file, then rename it, which is atomic on the same filesystem:
def save(favs: list[str]) -> None:
tmp = FAV.with_suffix(".tmp")
tmp.write_text(json.dumps(favs, indent=2), encoding="utf-8")
tmp.replace(FAV)
Floating-point surprises. 0.1 + 0.2 == 0.3 is False. Compare with math.isclose, and round only for display.
Validate query input. Let FastAPI do it with Query:
from fastapi import Query
@app.get("/api/search")
async def search(q: str = Query(min_length=2, max_length=80)): ...
Invalid input now returns a 422 with a clear message, before your code runs. Finally, run the app with fastapi run main.py (not dev) in production, behind a reverse proxy.
Logging instead of print. Use the logging module, so you can control levels and output in production without editing code:
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("weather")
log.info("geocoded %s", city.label)
FAQ
FastAPI or Flask?
FastAPI if you want async, automatic validation and docs. Flask is fine for small synchronous apps.
Do type hints slow Python down?
No. They’re ignored at runtime unless a library such as Pydantic reads them.
Official documentation
Key takeaways
- Use venvs, dataclasses and type hints from day one.
- Context managers and decorators package setup, cleanup and cross-cutting behaviour.
- Generators keep memory flat.
- FastAPI plus asyncio make a concurrent JSON API in a few dozen lines, and pytest keeps it honest.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.