Expert Python
P1–P4 cover the Python the course uses. This part goes further — the patterns you reach for when you build serious agent systems: precise typing, functools/itertools power tools, Pydantic mastery, structured concurrency, protocols, request-scoped context, and — most valuable of all — testing LLM code by mocking the model so your tests are fast, free, and deterministic.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Type agent code precisely: generics,
Protocol,TypedDict,overload,Self. - Use
functools(cache, partial, reduce, singledispatch) anditertoolsin real pipelines. - Master Pydantic: discriminated unions, custom types, settings, serialization control.
- Write correct concurrency with
TaskGroup, timeouts, and cancellation. - Use
Protocol/dunders to make swappable components;contextvarsfor request-scoped state. - Test agents without calling the API — mock the client, assert on tool calls, snapshot outputs.
Who this is for optional
You do not need P5 to complete the course or the projects — P1–P4 are enough to read and write every lab. Reach for P5 when you're building something real and want it maintainable, fast, and well-tested: a production support bot, the DevOps agent for a real team, or a project you'll extend for months. The testing section (§8) is the highest-value part for anyone shipping agents — read that even if you skip the rest.
1 · Advanced typing — precise contracts expert
Here's a bug you've almost certainly shipped. Somewhere deep in an agent, a function expected a dict shaped like {"role": "user", "content": "..."}. One day a caller passed {"role": "usr", ...} — a one-letter typo — and nothing complained. The code ran, the API rejected the malformed message twelve stack-frames later, and the traceback pointed at the SDK, not at your typo. You spent an hour finding a mistake a machine could have caught in a millisecond. That millisecond is what precise typing buys you.
The instinct most people have is that Python "doesn't have types," so hints are decoration. That's the mistake. Python doesn't enforce hints at runtime, but a static checker (mypy or Pyright) reads them and fails your build the moment a shape is wrong — before a single line runs, before CI, before a user. Think of type hints as a contract you write down: this function promises to receive X and return Y. The checker is the auditor who reads every call site and tells you where the contract is broken. In a small script you can hold the shapes in your head; in an agent codebase with retrievers, tool results, message lists, and streamed content blocks all flowing through dozens of functions, you can't — and that's exactly where the silent-typo bugs breed.
Beyond the basic hints from P3, four tools do the heavy lifting for agent code, and each answers a real question. How do I describe a dict with a fixed set of keys (a message, a tool_result)? TypedDict. How do I write a container that works for any inner type (an eval result holding a str today, a dataclass tomorrow)? Generics with TypeVar. How do I accept "any object that has a retrieve method" without forcing everyone to inherit a base class? Protocol — structural typing, the Pythonic answer to "duck typing, but checked." How do I type a method that returns its own class for chaining? Self.
The common mistake here is over-typing: plastering Any everywhere (which silently switches the checker off) or, at the other extreme, building baroque generic hierarchies nobody can read. The goal is not maximal types — it's precise contracts at the seams, the boundaries where one component hands data to another. Type the message that crosses from your code into the SDK; type the retriever your agent depends on; type the shape a tool returns. Those are the joints where a wrong shape does the most damage, and where a checker earns its keep. Beyond the hints in P3, precise types catch bugs before runtime and make an agent codebase navigable.
Read each row left-to-right: a concrete typing question on the left, an arrow, and the single tool that answers it on the right.
- The left blue boxes are the four questions you actually hit in agent code — a fixed-key dict, a reusable container, an object with a method, a self-returning method.
- The arrows pair each question with exactly one tool; they don't cross, because these tools solve different problems and aren't substitutes for each other.
- The green box (
Protocol) is the one to notice — it's structural typing ("has the method" is the whole test), the answer whenever you want swappable components without a shared base class.
In short: match the question to the tool — TypedDict for shaped dicts, Generic for any-inner-type containers, Protocol for duck-typing-but-checked, Self for chaining.
pythonfrom typing import TypeVar, Generic, Protocol, TypedDict, overload, Self, Literal
# TypedDict: a dict with a known shape (great for message/tool_result dicts)
class Message(TypedDict):
role: Literal["user", "assistant", "system"]
content: str
msgs: list[Message] = [{"role": "user", "content": "hi"}] # type-checked keys
# Generics: a typed, reusable container (e.g. an eval result for any output type)
T = TypeVar("T")
class EvalResult(Generic[T]):
def __init__(self, output: T, passed: bool):
self.output, self.passed = output, passed
r: EvalResult[str] = EvalResult("answer", True)
# Protocol: structural typing — "anything with a .retrieve(str) method"
class Retriever(Protocol):
def retrieve(self, query: str) -> list[str]: ...
def answer(q: str, store: Retriever): ... # accepts ANY object with retrieve()
# Self: a method that returns its own class (fluent builders)
class PromptBuilder:
def add(self, text: str) -> Self:
...; return self
A type hint is a note you write next to a value saying what kind of thing it should be (a str, an int, a list, …). Python does not enforce hints while running, but a checker tool (mypy/Pyright) reads them and warns you before you run — catching typos and wrong shapes early. This block shows four power-tools for typing agent code.
- TypedDict describes a dictionary whose keys are known ahead of time.
Messagesays every message dict must have arole(only one of the three listed words) and acontentstring. The checker flags a misspelled key like"rol". - Generic + TypeVar makes a reusable container that works for any inner type.
Tis a placeholder;EvalResult[str]means "an EvalResult whoseoutputis a string" — same class, filled in with a concrete type. - Protocol is "duck typing, checked." It says "I accept any object that has a
retrieve(query)method" — the object does not need to inherit from anything. Soanswer()works with any retriever that has the right method shape. - Self is the return type for a method that hands back its own object, so calls can be chained (
builder.add(...).add(...)) — a "fluent" style. The...(ellipsis) is a real placeholder meaning "body filled in elsewhere."
Try this: Install a checker (pip install mypy) and run mypy yourfile.py. Change a message's "role" to "boss" and watch mypy complain — that error is the type system earning its keep before the code ever runs.
TypedDict types the messages/tool_result dicts used everywhere (Ch 1–4). Protocol lets you swap the keyword retriever for the Ch 3 VectorStore without changing callers — the projects' "swap-in" points (support KB, analyst schema) are exactly this. Run mypy or Pyright to actually enforce these.2 · functools — caching, partials, dispatch expert
Picture the RAG index you built in Chapter 3, running in production. Users ask the same handful of questions all day — "how do I reset my password," "what's the refund policy" — and every single time, your code dutifully re-embeds the identical query string, paying the same milliseconds and, if you're calling an embedding API, the same fractions of a cent. Multiply by ten thousand requests and you've burned real money and latency computing an answer you already had. functools is the standard-library toolbox that makes that waste disappear with one decorator — and it does three other things you'd otherwise hand-roll badly.
The unifying idea is that functools works on functions themselves — it treats a function as a value you can wrap, specialize, or dispatch on. That's a genuinely different mental model from "a function is just a thing I call," and it's worth pausing on, because once it clicks you stop writing so much glue code. The clearest analogy is a coffee machine with presets: partial is you programming the "always a large oat-milk latte" button so you press one key instead of dialing in three settings every morning; @cache is the machine remembering it already ground beans for this exact order and handing you the stored cup; singledispatch is one "make drink" button that behaves differently depending on whether you feed it beans, pods, or instant.
The four tools map to four everyday agent needs. @cache / @lru_cache memoize a pure function — one that always returns the same output for the same input and touches nothing else — so a repeated call is a dictionary lookup instead of a recomputation (embedding a fixed string is the textbook case). partial pre-fills arguments to make a specialized function, so client.messages.create(model=..., max_tokens=...) becomes a tidy ask() you call with just the messages. reduce folds a whole sequence down to one value. singledispatch lets one function name behave differently per argument type — perfect for rendering the different content-block shapes (text, tool_use, thinking) you meet in every response.
The mistake that bites people — and it bites hard — is caching something that isn't pure. @cache keys on the arguments and never evicts, so caching a per-user or time-sensitive result means user B silently receives user A's answer, or a stale value lives forever. The rule: cache deterministic, input-only functions; bound the size with @lru_cache(maxsize=N) when the key space is large; and never, ever cache across users when the result is user-specific. Get that right and @cache is the single cheapest speed-up on this page.
Read each row left-to-right: the functools tool, then what it does to a function, then the concrete agent payoff.
- The purple boxes on the left are the four tools; they all share one idea — they operate on a function rather than just calling it.
- The middle blue boxes say what each one does: remember results, pre-fill arguments, fold a sequence, or pick a per-type body.
- The green boxes are the real wins for agent code — not re-embedding a repeated query, a tidy pre-configured
ask(), one combined score, and clean rendering of the different content-block types.
In short: functools lets you wrap (@cache), specialize (partial), fold (reduce), or dispatch (singledispatch) a function — four small levers that delete a lot of glue code.
Requires: pip install anthropic
pythonfrom functools import lru_cache, cache, partial, reduce, singledispatch
# @cache: memoize a pure function — e.g. don't re-embed the same text
@cache
def embed_once(text: str) -> tuple:
return expensive_embed(text) # computed once per unique text
# partial: pre-fill arguments to make a specialized function
from anthropic import Anthropic
client = Anthropic()
ask_opus = partial(client.messages.create, model="claude-opus-4-8", max_tokens=1024)
ask_opus(messages=[...]) # model/max_tokens already set
# reduce: fold a sequence (e.g. combine per-doc scores)
total = reduce(lambda acc, x: acc + x, [0.2, 0.5, 0.9], 0.0)
# singledispatch: one function, different behavior per argument type
@singledispatch
def render(block): return str(block)
@render.register
def _(block: dict): return block.get("text", "") # handle dict blocks specially
functools is a standard-library toolbox for working with functions. These four helpers each replace a chunk of hand-written code. A key word here is pure function: one that always returns the same output for the same input and changes nothing else — safe to remember.
- @cache is a decorator (the
@line above a function wraps it with extra behavior). It memoizes: remembers past results so callingembed_once("hi")twice computes only once and returns the stored answer the second time. - partial makes a new function with some arguments pre-filled.
ask_opusisclient.messages.createwithmodelandmax_tokensalready set (on OpenAI it would bepartial(client.responses.create, model="gpt-5.5", max_output_tokens=1024)), so you only pass the input each time — less repetition. - reduce boils a whole list down to one value by combining items pairwise. Here it sums
[0.2, 0.5, 0.9]starting from0.0, giving1.6. - singledispatch lets one function name do different things depending on the type of its first argument. The base
renderhandles anything; the@render.registerversion runs only when adictis passed.
Try this: Add print(embed_once.cache_info()) after calling embed_once twice with the same text — you'll see hits=1, proof the second call was served from memory.
@cache keys on arguments and never evicts — great for deterministic pure functions (embedding a fixed string), dangerous for anything time- or user-dependent. Use @lru_cache(maxsize=N) to bound it. Never cache across users if the result is user-specific.partial is a clean alternative to the Ch 1 complete() wrapper. @cache on embeddings speeds up the Ch 3 RAG index. singledispatch elegantly handles the different content-block types (text/tool_use/thinking) you loop over in every response.3 · itertools — streaming-friendly iteration expert
A story that ends in an out-of-memory crash. You need to embed a million document chunks. The obvious code — chunks = list(load_all_documents()) then loop — works perfectly on your test folder of fifty files and dies on the real corpus, because it tried to hold a million chunks in RAM at once before doing any work. The fix isn't a bigger machine; it's to stop materializing the whole thing. itertools is the standard library's kit for exactly that: building lazy iterators that produce one item at a time, on demand, so memory stays flat no matter how large — or how infinite — the source is.
The mental shift is from "a list is a box holding all my items" to "an iterator is a tap I open when I want the next item." A list eagerly computes and stores everything; an iterator is a recipe that yields the next value only when asked. This is precisely the right shape for two things you do constantly with models: consuming a streamed response token-by-token as it arrives (you don't have the whole thing yet, and you don't want to wait for it), and walking a huge collection you can't fit in memory. Think of a conveyor belt versus a warehouse: itertools gives you the belt.
Four of its tools cover most agent pipelines. batched(iterable, n) (Python 3.12+) hands you the items in fixed-size groups — the natural way to feed documents to a Batch API twenty at a time without loading them all. islice takes just the first N items from an iterator, even an endless streaming one, so you can peek at or cap a live response. chain glues several sequences into one walk-through without building a combined list. groupby collapses runs of equal items into groups — invaluable for tallying logs by service.
The classic trap is groupby, and nearly everyone hits it once. It groups only consecutive equal items — it is not SQL's GROUP BY. If your rows aren't already adjacent, you get several tiny fragmented groups for the same key instead of one. The fix is a one-liner most people forget: sort by the same key first, so all the matching rows sit together, then group. A second, subtler trap: an iterator is exhausted after one pass. Once you've walked it, it's empty — if you need the data twice, keep a list or use itertools.tee. Respect those two rules and lazy iteration turns memory-bound code into streaming code that scales.
list(load_all()) holds every chunk in RAM at once and can blow up on a real corpus; an itertools iterator produces one item at a time, so memory stays flat no matter how large the source.
Compare the two panels side by side — same million chunks, the red (left) way versus the green (right) way.
- The red panel is the eager approach: every tall bar is a chunk, and they all sit in memory simultaneously because
list(...)built the whole thing before any work started — which is what tips a real corpus into an out-of-memory crash. - The green panel is the lazy iterator: only the small "now" box exists at any instant. The solid arrow is the item you just consumed; the dashed arrow points at the ones not yet produced — they don't exist in RAM until you ask.
- The payoff is the flat row of tiny boxes on the right versus the wall of tall bars on the left: flat memory is what lets the same code handle a million chunks or an endless stream.
In short: eager loading trades RAM for simplicity and dies at scale; a lazy itertools iterator streams one item at a time and stays flat.
Illustrative fragment — defines demo values / files are needed before this runs standalone.
pythonfrom itertools import islice, chain, groupby, batched
# batched (3.12+): chunk an iterable into fixed-size groups
# e.g. send documents to a batch API in groups of 20
for group in batched(documents, 20):
submit_batch(group)
# islice: take the first N from a (possibly infinite/streaming) iterator
first5 = list(islice(stream.text_stream, 5)) # first 5 chunks only
# chain: flatten several lists lazily (no intermediate list)
all_findings = list(chain(review1.findings, review2.findings))
# groupby: group CONSECUTIVE items (sort first if you want all groups)
logs.sort(key=lambda l: l["service"])
for service, group in groupby(logs, key=lambda l: l["service"]):
print(service, len(list(group)))
itertools builds lazy iterators — sequences produced one item at a time, on demand, instead of all at once in memory. That is ideal for streaming model output or huge lists. An iterable is anything you can loop over (a list, a file, a live stream).
- batched(documents, 20) hands you the items in groups of 20 — perfect for sending documents to a batch API a chunk at a time. (Needs Python 3.12+.)
- islice takes just the first N items from an iterator, even an endless streaming one. Here
islice(stream.text_stream, 5)grabs only the first 5 chunks and stops. - chain glues several lists into one sequence without building a big combined list in memory — it just walks the first, then the next.
- groupby groups neighbouring equal items. It only groups items that are already next to each other, so you
sortby the same key first to gather every service together.
What the output means: After sorting and grouping, each printed line is a service name and the count of its log lines, e.g. auth 12.
Try this: Comment out the logs.sort(...) line and re-run: groupby will split the same service into several small groups whenever its rows are not already adjacent — that shows why the sort matters.
batched is exactly how you'd feed the Batch API (Ch 6 cost) or the doc-intel project's many invoices. islice caps a streaming response. chain merges findings across the coding-agent's review passes.4 · Pydantic mastery — unions, custom types, settings expert
The single most fragile line in most agent codebases is the one right after the model responds. The model hands you back text, you json.loads it, and then you write data["action"]["reason"] — trusting that a probabilistic system produced exactly the shape you imagined. Sometimes it returns {"action": {"kind": "escalate"}} with no reason. Sometimes kind is "Escalate" with a capital E. Sometimes it's a whole different action you didn't handle. Your code doesn't crash there — it crashes three functions downstream with a KeyError that tells you nothing about the real cause. Pydantic exists to move that failure to the front door and make it loud, specific, and typed.
The core idea is a boundary guard. Loose, untrusted data — JSON from a model, a request body, a config file — comes in; Pydantic validates it against a schema and either hands you a real, typed object or raises a precise error naming the field that's wrong. Think of a bouncer with a guest list: nobody gets past the door in the wrong outfit, so everyone inside the party is guaranteed to be dressed correctly. Every function past that boundary can then trust its inputs completely, which is a huge simplification — the checks live in one place, not scattered as defensive ifs through the whole codebase. P4 covered the basics — models and field validators; this section covers the pieces that specifically make agents reliable.
The star of the show for agent code is the discriminated union. An agent's output is often "exactly one of several things": it either answered, or it escalated, or it called one of N tools. A discriminated union expresses precisely that — a set of models that share a "tag" field (here, kind) — and Field(discriminator="kind") tells Pydantic to read that tag and build the matching subtype. The payoff is that d.action comes back as a real Escalate instance you can branch on with isinstance and that your type checker understands — not an anonymous dict you index into and hope. Alongside it: annotated constrained types (a Confidence that must lie in [0, 1], defined once and reused) and serialization control (model_dump(mode="json") to get audit-log-safe output).
The mistake to avoid is treating Pydantic as mere documentation and then bypassing it — parsing the JSON yourself, or catching the ValidationError and pressing on with half-built data. The whole value is that a validation failure is a signal: the model gave you something you didn't expect, which is exactly the moment to retry, ask again, or escalate — not to limp forward on a malformed object. Let it raise, handle it at the boundary, and everything inside stays clean. Here's the rest of what makes Pydantic the backbone of reliable agents.
kind tag and builds the matching model — a real Answer or Escalate you can isinstance-branch on — or raises a precise ValidationError at the boundary instead of failing three functions downstream.
Follow the blue arrows left to right: raw JSON enters on the left, Pydantic inspects it in the middle, and it exits on the right as one specific typed object.
- The blue box on the left is the loose, untrusted dict the model handed you — it carries a
kindtag but no guarantees. - The purple box is
Field(discriminator="kind")reading that one tag. Based on its value the solid arrows fork to exactly one of the green boxes —Answerwhenkind=="answer",Escalatewhenkind=="escalate"— each now a real typed instance. - The red arrow down to the red box is the payoff: if the data fits no variant (a bad or missing field), Pydantic raises
ValidationErrorright here at the door, not deep in code that assumed the shape was fine.
In short: one tag field routes untrusted JSON to the correct typed model, or fails loudly at the boundary — never silently onward.
pythonfrom pydantic import BaseModel, Field
from typing import Literal, Annotated, Union
# Discriminated unions: the agent returns ONE OF several action types.
# Pydantic picks the right model by the 'kind' field — perfect for tool calls.
class Answer(BaseModel):
kind: Literal["answer"]
text: str
class Escalate(BaseModel):
kind: Literal["escalate"]
reason: str
Action = Annotated[Union[Answer, Escalate], Field(discriminator="kind")]
class Decision(BaseModel):
action: Action # validated into the right subtype
d = Decision.model_validate({"action": {"kind": "escalate", "reason": "low conf"}})
isinstance(d.action, Escalate) # True — typed, not a raw dict
# Constrained/annotated types — reusable field constraints
Confidence = Annotated[float, Field(ge=0, le=1)]
Severity = Literal["low", "medium", "high"]
# Serialization control
d.model_dump(exclude_none=True) # drop null fields
d.model_dump(mode="json") # JSON-safe types (datetime -> str)
Pydantic turns loose data (like JSON from a model) into checked, typed objects — if a field is missing or the wrong type, it raises a clear error instead of failing later. This block shows the advanced pieces you need when an agent must return one of several shapes.
- A discriminated union means "the value is exactly one of these models, and one field tells us which."
AnswerandEscalateboth have akindfield with a fixed value;Field(discriminator="kind")tells Pydantic to readkindand pick the matching model. model_validate({...})takes a plain dict and returns a real, typed object. Becausekindwas"escalate",d.actionis anEscalateinstance — soisinstance(d.action, Escalate)isTrue.- Annotated constrained types bundle a type with rules.
Confidenceis a float that must sit between 0 and 1 (ge=0, le=1); reuse it anywhere instead of re-writing the check. - model_dump turns the object back into a dict.
exclude_none=Truedrops empty fields;mode="json"converts values (like dates) into JSON-safe text.
Try this: Change the dict to {"kind": "escalate"} (drop reason) and re-run. Pydantic raises a validation error naming the missing field — that guard is exactly why you use it on model output.
from pydantic_settings import BaseSettings gives you a typed, validated config object loaded from env vars / .env — a clean upgrade over scattered os.environ.get calls. One Settings model with your model id, thresholds, and keys, validated at startup.Confidence/Severity types dedupe the field definitions repeated across the projects' schemas. model_dump(mode="json") is how you serialize records for the audit log / API responses.5 · Structured concurrency — TaskGroup, timeouts, cancellation expert
You have fifty evaluation cases to grade, each a round-trip to the model that takes two seconds. Run them one after another and your test suite takes a hundred seconds — long enough that people stop running it. Run them all at once and you've fired fifty simultaneous requests, tripped the API's rate limit, and half of them fail. The right answer is neither serial nor a free-for-all: it's controlled concurrency — many calls in flight, but a firm cap, per-call timeouts so nothing hangs forever, and clean behavior when one fails. That's what this section is about, and modern Python (3.11+) finally makes it correct-by-default.
First, the intuition for why concurrency helps here at all. These calls are I/O-bound — your program spends almost all its time waiting for the network, not computing. async/await lets a single thread start a request, and while it's waiting on the reply, await hands control to another task to start its request. Fifty tasks all wait at the same time, so the batch finishes in roughly the time of the slowest single call rather than the sum of all of them. The analogy is a chef with fifty pots: you don't stand watching one pot boil (serial) — you get every pot going and tend whichever is ready, because the bottleneck is boiling time, not your hands.
Two tools tame the chaos. A Semaphore(K) is a bowl of K permits: async with sem: takes one and returns it at the end, so no matter how many tasks you launch, at most K are ever running — that's your rate-limit dial. asyncio.timeout(s) wraps a call so a hung request is cancelled after s seconds instead of freezing the whole run. And TaskGroup (3.11+) is the structured way to launch a batch and wait for all of it: if any task raises, it cancels the siblings and re-raises, so you never leak a half-finished task into the background.
The decision that trips people is TaskGroup versus gather, and they encode opposite intentions. TaskGroup is fail-fast: one error aborts the whole group — right when "all must succeed or the result is meaningless." gather(..., return_exceptions=True) is resilient: it runs everything to completion and hands you the failures alongside the successes — right for an eval run where one bad case shouldn't kill the other forty-nine. The classic bug is reaching for whichever you learned first without asking which semantics you actually want. And the mistake beginners make regardless is launching an unbounded fan-out with no semaphore and no timeout — it works in a five-item test and melts the moment real volume arrives. P4 showed gather; this is the disciplined, production-grade way to fan out agent calls.
Semaphore(K) runs the calls in bounded waves — fast and within the cap. A per-call timeout stops any one call hanging the run.
Read the three rows top to bottom — each is the same six calls run a different way, and horizontal distance is elapsed time (further right = later).
- Serial (top): the boxes sit end-to-end, so the batch takes the sum of all six calls. Correct, but the "slow" tail on the right is why people stop running the suite.
- Unbounded (middle, red): all six start at the same instant — great in a tiny test, but firing every request at once trips the API's rate limit (a
429) and half fail. - Semaphore(2) (bottom, green): calls run two at a time in waves — the green pairs step rightward — finishing far faster than serial while never exceeding the cap. The dashed axis underneath is wall-clock time; the green row clearly ends well left of the serial row.
In short: the Semaphore number is your concurrency dial — big enough to be fast, small enough to stay under the rate limit.
pythonimport asyncio
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
sem = asyncio.Semaphore(5) # cap concurrency (rate limits)
async def grade(case):
async with sem:
# per-call timeout — don't let one hang forever
async with asyncio.timeout(60):
return await client.messages.create(model="claude-opus-4-8",
max_tokens=200, messages=[{"role":"user","content":case}])
async def grade_all(cases):
results = []
async with asyncio.TaskGroup() as tg: # 3.11+
tasks = [tg.create_task(grade(c)) for c in cases]
# TaskGroup waits for all; if ANY raises, it cancels the rest & re-raises
return [t.result() for t in tasks]
Concurrency means doing many slow things (like API calls) at once instead of one after another, so a batch finishes in the time of the slowest call. async/await is Python's way to do this on one thread: await pauses a task while it waits and lets others run. This shows the modern, correct way to fan out agent calls.
- Semaphore(5) is a permit counter.
async with sem:takes one of 5 permits and returns it at the end — so at most 5 calls run at once. That respects the API's rate limit even if you launch hundreds of tasks. async with asyncio.timeout(60):gives each call 60 seconds; if it hangs longer it is cancelled and raises — so one stuck call can't freeze the whole run.- TaskGroup (Python 3.11+) starts many tasks with
tg.create_task(...)and, at the end of itsasync with, waits for all of them. Key behavior: if any task fails, it cancels the rest and re-raises — "all or nothing." - After the group finishes,
[t.result() for t in tasks]collects every task's return value into a list.
What the output means: Nothing prints — these are just the definitions. Calling grade_all(cases) would run up to 5 grading calls at a time and return the list of responses.
Try this: Lower the semaphore to Semaphore(1) and you've serialized everything (one at a time); raise it and calls overlap. That one number is your concurrency dial.
gather keeps going when one task fails (returns exceptions if you ask). TaskGroup is fail-fast: one error cancels the siblings and raises an ExceptionGroup. For an eval run where one bad case shouldn't kill the batch, gather(..., return_exceptions=True) is better; for "all must succeed," use TaskGroup. Know which you want.Semaphore + timeout combo is essential to respect rate limits and avoid hangs (Ch 6 scaling).6 · Protocols, dunder methods & making things swappable expert
Ever wondered why len(my_list) works but you write len(), not my_list.length()? Python's built-in functions and syntax — len(x), for m in x, x[-1], if x:, with x: — don't do the work themselves. They quietly call special methods on your object: __len__, __iter__, __getitem__, __bool__, __enter__/__exit__. These are the dunder methods ("double-underscore"), and they're the hooks that let your own classes plug into the language as if they were built-in types. Define them, and a conversation-history object stops being a clumsy wrapper you call history.get_messages() on and becomes something you can len(), loop over, and index like a native list.
The reason this matters for agents is swappability, and it pairs directly with the Protocol idea from §1. A well-built agent doesn't hard-code "the keyword retriever" — it depends on anything shaped like a retriever, so you can drop in the Chapter 3 vector store later without touching the caller. Dunders are what make those swappable components feel seamless: the retriever supports len(), the tool is callable like a function via __call__, the timer works in a with block. The interface is the contract; the dunders are the polish that make honoring the contract invisible to callers. The mental model is an electrical socket: as long as your device has the standard plug shape (the dunders), it works in the wall (Python's syntax) — the wall doesn't care what's inside.
Three dunders earn their place immediately. The collection dunders (__len__, __iter__, __getitem__, __bool__) make a custom object behave like a sequence — a Transcript you can measure, iterate, and slice. __call__ makes an instance callable, so a Tool object that also carries data (its risk level, its schema) can still be invoked like a plain function — a "function object." And the context-manager dunders __enter__/__exit__ let you build your own with-blocks: a Timer that starts on entry and prints elapsed time on exit, guaranteed to run even if the body raises.
The mistake to sidestep is dunder enthusiasm — implementing a dozen special methods because you can, turning a simple class into a puzzle where + and [] do surprising things. Dunders should make an object behave the way its real-world role suggests: a transcript is list-like, so give it list dunders; a tool is action-like, so give it __call__. Add the ones that match the mental model your callers already have, and stop. Used with that restraint, dunders plus Protocols are the whole toolkit for making the retriever, the store, and the tool set cleanly interchangeable across the projects.
Transcript you can len(), loop, and index (blue), a Tool you can call, a Timer you can with (green).
Read each row left-to-right: the ordinary syntax on the left, the arrow, and the hidden method Python actually invokes on the right.
- The left column is code you already write —
len(x),for m in x,x[-1],if x:. None of these "know" about your class; they just call a method. - The arrows show that each one is really a call to the matching dunder on the right — define it and the syntax starts working on your object.
- The green rows are the two that unlock swappability:
__call__makes aToolinstance invokable like a function, and__enter__/__exit__make aTimerusable in awithblock.
In short: a dunder is the socket behind the syntax — implement the ones that match your object's real-world role and callers use it like a built-in.
pythonimport time
# dunder methods make your objects behave like built-ins
class Transcript:
def __init__(self): self._msgs = []
def __len__(self): return len(self._msgs) # len(transcript)
def __iter__(self): return iter(self._msgs) # for m in transcript
def __getitem__(self, i): return self._msgs[i] # transcript[-1]
def __bool__(self): return bool(self._msgs) # if transcript:
# __call__ makes an instance callable — a configurable "function object"
class Tool:
def __init__(self, fn, risk): self.fn, self.risk = fn, risk
def __call__(self, **kw): return self.fn(**kw) # tool(**args)
# context-manager dunders — build your own 'with' object
class Timer:
def __enter__(self): self.t = time.monotonic(); return self
def __exit__(self, *exc): print(f"{time.monotonic()-self.t:.2f}s")
Dunder methods ("double-underscore," e.g. __len__) are special hooks Python calls for you when you use built-in syntax on your object. Define them and your own class starts acting like a list, a function, or a with-block.
- In
Transcript, defining__len__makeslen(transcript)work,__iter__makesfor m in transcriptwork,__getitem__enablestranscript[-1], and__bool__decides whatif transcript:means. Your object now feels built-in. __call__makes an instance callable like a function. Aftert = Tool(fn, risk), writingt(**args)runsfn— a "function object" that also carries data (its risk level).__enter__/__exit__let an object be used in awithblock.Timerrecords the start time on enter and prints the elapsed seconds on exit — sowith Timer():times whatever runs inside.
Try this: Wrap a slow line in with Timer(): and run it — the elapsed time prints automatically when the block ends, because Python calls __exit__ for you.
Transcript with __len__/__iter__ is a nicer conversation-history object than a raw list (context management, Ch 4/6). The capstone's Tool dataclass could use __call__. Protocols (§1) + these dunders are how you make the retriever, the store, and the tool set swappable across the projects.7 · contextvars — request-scoped state expert
Here's the parameter that poisons agent codebases. You add tracing, so every log line needs a trace_id. The only way to get it into log() — buried six calls deep inside a tool — is to pass it down through every function in between: run_agent(msg, trace_id) → call_tool(args, trace_id) → fetch(url, trace_id). None of those functions use the id; they just ferry it along. Then you add a tenant, and every signature grows again. This "tramp data" — arguments threaded through functions that don't care about them — is a real smell, and contextvars is the clean cure: a value you set once at the top of a request and read anywhere below, without it appearing in a single signature between.
The obvious shortcut is a module-level global — and it's a landmine the moment you have concurrency. Picture two requests handled at once: request A sets the global tenant to "acme," then awaits a slow model call; while it waits, request B runs and overwrites the global to "globex"; A resumes and now logs — and, worse, filters tenant-scoped data — as "globex." That's a cross-tenant data leak born from one shared variable. The fix is that a ContextVar is not shared: each concurrent task (and each thread) gets its own isolated copy, automatically. The analogy is a hospital wristband — the patient's ID travels with them into every room and every test, and nobody confuses two patients because each wears their own band, not a shared clipboard on the wall.
Mechanically it's tiny: ContextVar("trace_id", default="-") declares the variable with a fallback; .set(value) stamps the current request near the entry point; .get() reads it from however deep in the call stack you are. The async runtime is what makes the isolation work — when a task awaits and another runs, the runtime swaps in that task's context, so each .get() sees its own request's value. You get the ergonomics of a global with none of the sharing.
The one caveat worth internalizing: contextvars is for genuinely request-scoped, cross-cutting state — observability ids, the authenticated tenant, a request-start clock — not a back door to smuggle ordinary business data around because passing it feels tedious. If a function's logic depends on a value, that value belongs in its signature where it's visible and testable; if the value is ambient plumbing that every layer merely tags along with, that's the contextvar sweet spot. And note the security angle for tenancy: because you set the tenant from the authenticated request — never from anything the model produced — the tenant-scoped tools filter correctly no matter what the model tries to claim. Passing a trace_id / tenant / user through every function is tedious; contextvars gives you request-scoped globals that are safe under async concurrency — the clean way to thread observability + tenancy through an agent.
ContextVar hands each concurrent task its own isolated copy, so every .get() returns that request's own value.
Two requests, A and B, run at the same time. Compare the red (left) panel — one shared global — with the green (right) panel — a ContextVar.
- On the left, both requests write to one box. The two red arrows converge on the same variable, so whoever wrote last wins: the box reads
"globex", and request A — which setacme— now reads the wrong tenant. That convergence is the bug. - On the right, each request has its own context box (blue for A, purple for B). There are no crossing arrows because nothing is shared — A's
.get()readsacme, B's readsglobex. - The payoff is the green panel's isolation: the same
trace_id.get()/tenant.get()deep in the call stack returns the right request's value automatically, which is why it's safe under async concurrency.
In short: a shared global leaks one request's state into another under concurrency; a ContextVar's per-task copy keeps them cleanly separate.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def run_agent(*a, **k): # demo stub
return _Any()pythonimport contextvars, uuid
trace_id = contextvars.ContextVar("trace_id", default="-")
tenant = contextvars.ContextVar("tenant")
def handle_request(user_msg, tenant_id):
trace_id.set(str(uuid.uuid4())) # set once per request
tenant.set(tenant_id)
run_agent(user_msg) # deep calls can read it without passing it
def log(msg):
# any function, however deep, sees the current request's values
print(f"[{trace_id.get()}][{tenant.get()}] {msg}")
Threading a value like a trace_id or tenant through every function call is tedious. contextvars gives you a variable that is set once and readable anywhere in the same request — and, crucially, kept separate per concurrent task, so one request never sees another's value.
ContextVar("trace_id", default="-")creates the request-scoped variable with a fallback used before anything is set.handle_requestcalls.set(...)once at the top to stamp this request with a fresh unique id (uuid4()) and its tenant. Deeper code doesn't need these passed in.log()— however deep in the call stack — reads the current values with.get(), so every log line is automatically tagged with the right request's id and tenant.
Try this: Picture two requests running at once: because each task has its own context, request A's log() prints A's id and request B's prints B's — no mix-up. Try replacing the ContextVar with a plain global and you'd get exactly that bug.
ContextVar is isolated per task/context, so it's safe. This is the correct tool for the tenant-scoping the support project and the multi-tenant DevOps SaaS need.trace_id through the Ch 6 observability without adding a parameter to every function, and carries the authenticated tenant so the tenant-scoped tools (support project, DevOps multi-tenant §11) always filter correctly — without trusting a value passed by the model.8 · Testing LLM code — mock the model essential for shipping
If you read one section on this page, read this one. Here's the wall every engineer hits the first time they try to test an agent: you write def test_agent(): assert agent.run("hi") == ... and immediately everything is wrong. The test costs money (a real API call). It's slow (a network round-trip in a suite that should run in milliseconds). And it's flaky — the model phrases things differently each run, so your assert passes today and fails tomorrow on wording alone. A test that costs money, crawls, and fails randomly is a test people delete. So agents go untested, and untested agents break in production.
The fix flips the problem on its head. You are not trying to test the model — the model isn't yours, and its quality is a separate question. You're trying to test your code around it: the loop that keeps calling until the model stops, the safety gate that blocks a dangerous tool, the parser that pulls text out of the response, the branch that handles a refusal. All of that is deterministic logic that has nothing to do with which words the model chose. So you mock the client: swap the real API object for a fake stand-in that instantly returns a canned response you control. Now the model-shaped hole in your code is filled by a puppet, and every test runs in a millisecond, for free, identically every time — safe for CI on every commit.
A mock is just a fake object you drop in during a test; Python's unittest.mock.MagicMock gives you one that auto-creates any attribute you touch and lets you pre-program return values. The technique that makes it work cleanly is dependency injection — designing the function to receive its client as an argument (summarize(client, text)) rather than reaching for a global one, so a test can hand it a mock. The real power move is side_effect = [resp1, resp2]: make the fake return a tool_use response first and an end_turn response second, and now you can drive a full tool-using loop through its paces — assert it called the right tool with the right arguments, then stopped — all without a single network call.
The mistake here is a mirror-image pair, and honest engineers name both. Under-mocking is letting real calls sneak into your unit tests — back to slow, costly, flaky. Over-trusting the mock is the subtler one: a green suite of mocked tests proves your plumbing is correct, but it says nothing about whether the model actually diagnoses the bug or answers the question well. Mocks can't test output quality — only real evals (Chapter 5), run less often and with a key, can. The professional stance is to hold both truths: mock the model to test your logic deterministically on every commit, and run evals to test quality on a cadence. This is how you test agents in CI.
MagicMock with a scripted side_effect = [tool_use, end_turn] lets you drive a full tool-using loop — asserting it called the right tool then stopped — with no network, no key, and identical results every run.
Read left to right: your real code on the left calls out through the middle to the fake client, which feeds back a scripted pair of responses on the right.
- The left box is your code — the loop, the safety gate, the parser. These stay real and are exactly what the test exercises; the model isn't yours to test.
- The amber box is the
MagicMockthat replaces the real client: the samecreate()call the blue arrow makes now hits a puppet, so there's no network, no key, no cost. - The two right boxes are the scripted replies from
side_effect = [resp1, resp2]: first atool_use(drive the loop into calling a tool), then anend_turn(make it stop). That sequence is what lets one test walk a whole tool-using loop, ending in the green assertion bar: right tool, right args, then stopped — in a millisecond.
In short: keep your logic real, puppet the model with a scripted sequence, and you can test the entire loop deterministically in CI.
test_agent_logic.pyfrom unittest.mock import MagicMock, patch
import types
def fake_response(text, stop="end_turn"):
"""Build an object shaped like the SDK's Message."""
block = types.SimpleNamespace(type="text", text=text)
return types.SimpleNamespace(content=[block], stop_reason=stop,
usage=types.SimpleNamespace(input_tokens=10,
output_tokens=5, cache_read_input_tokens=0))
def test_agent_returns_text_without_calling_the_api():
import agent.engine as eng
# replace the real client with a mock that returns our fake response
eng.client = MagicMock()
eng.client.messages.create.return_value = fake_response("Paris")
out = eng.run("capital of France?") # runs YOUR loop, no network
assert "Paris" in out
eng.client.messages.create.assert_called_once() # it made exactly one call
def test_agent_stops_on_refusal():
import agent.engine as eng
eng.client = MagicMock()
eng.client.messages.create.return_value = fake_response("", stop="refusal")
# assert your code handles the refusal branch correctly
...
A mock is a fake stand-in object you drop in during a test. Real LLM calls are slow, cost money, and change their wording — awful for automated tests. So you replace the client with a mock that instantly returns a canned response, and test your code (the loop, parsing, error handling) with no network at all.
fake_response(text)builds a lightweight object shaped like the SDK's real reply.SimpleNamespacejust makes an object with the attributes you name (.content,.stop_reason, …) so your code can read them the same way.eng.client = MagicMock()swaps the real client for a fake....create.return_value = fake_response("Paris")says "whenever the code callscreate, hand back this fake message."eng.run(...)then exercises your real logic against the fake — no API key, no network, instant and identical every time.assert_called_once()checks your code called the model exactly once — a test of behavior, not just output. The second test feeds a"refusal"reply to prove your refusal branch is handled.
Try this: To test a full tool-using loop, set create.side_effect = [tool_use_resp, end_turn_resp] so the mock returns a tool call first and a final answer second — now you can assert the agent ran the tool and then stopped, all without the model.
create return a sequence: first a fake tool_use response, then an end_turn. Use mock.side_effect = [resp1, resp2]. Now you can assert the agent called the right tool with the right args, executed it, and looped — all without the model. This is exactly how the projects' safety tests stay API-key-free: they test the gate and parsing directly, and mock the model where a response is needed.Worked example · a typed, cached, testable retriever putting it together
Isolated tricks are easy to admire and hard to use — the real skill is making them cohere in one small, shippable component. Everything on this page has been a single tool held up to the light: a Protocol here, a cache there, a dunder in section six. That's how you learn them, but it's not how you meet them in real code, where a good component quietly wears several at once. This worked example is that convergence point: a retriever that is swappable (a Protocol, so the Chapter 3 vector store drops in with zero caller changes), fast (a @cache, so a repeated query is a lookup not a recompute), native-feeling (a __len__ dunder, so len(retriever) just works), and testable (a clean seam a mock can slot into).
Watch for the one detail that ties two ideas together and would silently bite you: @cache can only store hashable results, and a list is not hashable — a tuple is. So retrieve returns tuple(ranked[:k]), not the list you'd instinctively write. That's the kind of interaction you only feel when the patterns share a body: caching forces immutability, immutability changes your return type, and the type annotation follows. Read it as a template — the shape of a retriever you'd actually ship inside an agent — and notice how each expert pattern is pulling a specific, nameable weight.
@cache), native-feeling (__len__), and testable at once — and the amber note is the interaction that bites: caching forces retrieve to return a hashable tuple, not a list.
Read top-down: the one component at the top fans out to the four patterns it wears, and the amber strip shows how two of them interact.
- The purple box is the single
KeywordRetrieveryou ship — everything below is a pattern living inside it. - The four green boxes are what each pattern buys: a
Protocolmakes it swappable for the Ch 3 vector store,@cachemakes a repeat query a lookup, a__len__dunder makeslen(retriever)work, and a clean seam makes it mockable in a test. - The amber strip is the load-bearing detail: because
@cacheonly stores hashable values,retrievemust return a tuple, not a list — caching changes your return type.
In short: real components wear several expert patterns at once, and the patterns interact — here caching forces immutability, which changes the return type and its annotation.
typed_retriever.pyfrom typing import Protocol, runtime_checkable
from functools import cache
@runtime_checkable
class Retriever(Protocol):
def retrieve(self, query: str, k: int = 3) -> list[str]: ...
class KeywordRetriever:
def __init__(self, docs: list[str]):
self._docs = docs
def __len__(self) -> int:
return len(self._docs) # len(retriever)
@cache # memoize identical queries
def retrieve(self, query: str, k: int = 3) -> tuple[str, ...]:
q = set(query.lower().split())
ranked = sorted(self._docs,
key=lambda d: -sum(w in d.lower() for w in q))
return tuple(ranked[:k]) # tuple so it's hashable/cacheable
def answer_with(store: Retriever, q: str) -> str:
# works with ANY Retriever — swap in the Ch 3 VectorStore, no change here
hits = store.retrieve(q)
return f"top hit: {hits[0] if hits else '(none)'}"
if __name__ == "__main__":
r = KeywordRetriever(["reset your password", "refund policy", "enable 2fa"])
assert isinstance(r, Retriever) # structural check passes
print(len(r), "docs")
print(answer_with(r, "how to reset password"))
print(answer_with(r, "how to reset password")) # 2nd call hits the cache
3 docs
top hit: reset your password
top hit: reset your password
The worked example combines the page's patterns in one tiny file: a Protocol (structural interface), a plain class that satisfies it, @cache, and a dunder — a component you could swap and test easily.
@runtime_checkable class Retriever(Protocol)defines the contract: "anything with aretrieve(query, k)method is a Retriever."runtime_checkablelets you verify it at run time withisinstance.KeywordRetrieverholds some docs.__len__makeslen(retriever)report the doc count.@cacheonretrieveremembers answers so an identical query is instant the second time.- The ranking line scores each doc by how many query words it contains and sorts best-first (the leading
-sorts high-to-low). It returns a tuple, not a list, because@cachecan only store hashable (unchangeable) results — a tuple is; a list isn't. answer_with(store, q)takes any Retriever, so you could pass the Ch 3VectorStoreinstead with no change here. The__main__block builds one, asserts it structurally counts as aRetriever, and queries it twice.
What the output means: It prints 3 docs, then the top hit reset your password twice — the second identical query is served from the @cache, not recomputed.
Try this: Add a 4th doc and a different query, and add print(KeywordRetriever.retrieve.cache_info()) at the end to see cache hits vs misses.
Where to go beyond this expert
- Static analysis — run
mypy/pyrightin CI to enforce the types from §1. - Property-based testing —
hypothesisto fuzz your parsers/validators. - Profiling —
cProfile/py-spywhen an agent pipeline is slow. - Packaging —
pyproject.toml,uv/poetryto ship your agent as a library. - Observability libraries — OpenTelemetry for the tracing sketched in Ch 6.
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.
"How do you unit-test this?" → inject a fake client so logic is tested deterministically, no network, no key.
pythonfrom unittest.mock import MagicMock
def summarize(client, text):
resp = client.messages.create(model="claude-opus-4-8",
max_tokens=100, messages=[{"role":"user","content":text}])
return resp.content[0].text.strip()
def test_summarize():
fake = MagicMock()
fake.messages.create.return_value.content = [MagicMock(text=" hi ")]
assert summarize(fake, "x") == "hi" # no real API call
A classic interview question: "how do you unit-test code that calls an LLM?" The expected answer is dependency injection + mocking — pass the client in as an argument so a test can hand you a fake one.
summarize(client, text)takes theclientas a parameter instead of creating its own. That single design choice is what makes it testable — you can supply any client.- In the test,
fake = MagicMock()is a stand-in that auto-creates any attribute you touch. The next line pre-programs its reply:createreturns an object whosecontent[0].textis" hi ". assert summarize(fake, "x") == "hi"checks your.strip()trimmed the spaces — testing your logic, with no network and no API key.
Try this: In an interview, say this out loud: "I inject the client so I can pass a mock; the test asserts on my parsing, not the model's wording." That framing is what they're listening for.
"Run N tasks, at most K at once." Structured concurrency the modern way.
pythonimport asyncio
async def bounded_map(fn, items, limit=10):
sem = asyncio.Semaphore(limit)
async def worker(x):
async with sem:
return await fn(x)
return await asyncio.gather(*[worker(i) for i in items])
Another common question: "run many async tasks, but never more than K at the same time." The answer is a semaphore — a counter of permits that caps how many workers run concurrently.
asyncio.Semaphore(limit)createslimitpermits (default 10).- Each
workerdoesasync with sem:— it waits for a free permit, runsawait fn(x), then releases the permit. So no more thanlimitworkers are ever inside at once. asyncio.gather(*[worker(i) for i in items])launches a worker per item and waits for all results, returned in the original order.
Try this: Set limit=2 and give fn a print plus await asyncio.sleep(1). You'll see items processed two at a time — visible proof the semaphore is bounding the parallelism.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Agent code thrives on swappable parts — a keyword retriever today, a vector store tomorrow — and you want the type checker to accept any object that has the right method, without forcing a shared base class. A Protocol gives duck typing a name the tooling can check.
Your task: Define a Retriever Protocol with a retrieve(query, k) method, then write an unrelated class that simply has that method and show it satisfies the protocol structurally — including a runtime_checkable isinstance check.
Requirements:
- The protocol declares the method signature only, with
...as the body - The implementing class does not inherit from the protocol
- Decorate the protocol
@runtime_checkablesoisinstanceworks at runtime isinstance(instance, Retriever)returnsTruepurely on method shape- Type the protocol method (e.g. returning a
list<str>)
💡 Hint: Structural typing means "has the method" is the whole test — no class Keyword(Retriever) needed, just a matching retrieve.
Show solution
Protocols give duck-typing a type. Runnable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Retriever(Protocol):
def retrieve(self, query: str, k: int = 3) -> list[str]: ...
class Keyword:
def retrieve(self, query, k=3):
return [query][:k]
r = Keyword()
print(isinstance(r, Retriever)) # True -- matches structurally
Context: Some functions in a retrieval or scoring pipeline are pure and get called with the same arguments over and over — recomputing them is wasted latency. functools.cache memoizes the result keyed on the arguments, turning the second identical call into a dictionary lookup.
Your task: Cache an expensive pure function with @cache, then call it twice with identical arguments and prove the second call is served from the cache by counting how many times the body actually ran.
Requirements:
- Decorate a pure function with
@cache - Increment a counter inside the body so real invocations are observable
- Call the function twice with the same argument
- Show the counter is
1, not2— the second call hit the cache - The function's arguments must be hashable for caching to work
💡 Hint: The count of real invocations is the proof; a plain mutable counter (a one-key dict) incremented in the body makes the cache hit visible.
Show solution
@cache memoizes on the arguments. Runnable:
from functools import cache
hits = {"n": 0}
@cache
def score(query: str) -> int:
hits["n"] += 1
return len(query.split())
print(score("reset my password")) # computes
print(score("reset my password")) # cached
print("real calls:", hits["n"]) # 1
Context: Embedding APIs charge and rate-limit per request, so you send documents in fixed-size batches rather than one at a time or all at once. Batching a lazy stream — without first pulling everything into memory — is the itertools.batched recipe, and it works on any iterable including an infinite one.
Your task: Use itertools to write a batched(iterable, n) generator that groups any iterable into fixed-size tuples of length n (last one short) without materializing the whole input, mirroring 3.12's itertools.batched.
Requirements:
- Pull from the iterable lazily via
iter()+islice - Each yielded batch is a tuple of at most n items
- The final batch may be shorter than n and is still yielded
- Stop cleanly when the source is exhausted (an empty slice ends it)
- Never build a list of the entire input
💡 Hint: The walrus-driven loop — while (batch := tuple(islice(it, n))) — slices n at a time from a single iterator and stops when the slice comes back empty.
Show solution
Uses islice to lazily batch any iterable (mirrors 3.12's itertools.batched). Runnable:
from itertools import islice
def batched(iterable, n):
it = iter(iterable)
while (batch := tuple(islice(it, n))):
yield batch
docs = range(1, 8)
for b in batched(docs, 3):
print(b) # (1,2,3) (4,5,6) (7,)
Context: Dunder methods let your own types plug into Python's built-in protocols so callers use them like native objects — len(store) and store(query) instead of remembering bespoke method names. That polish is what makes an injected component feel like part of the language.
Your task: Implement __len__ and __call__ on a retriever class so it works with the built-in len() and can be invoked directly like a function, then demonstrate both.
Requirements:
__len__returns the number of documents held__call__takes the query (and ak) and returns the top-k matches- Do a simple keyword overlap ranking so the results are intelligible
- Show
len(retriever)andretriever("...")both working - Slice to the top k so the call returns a bounded list
💡 Hint: Once __call__ exists the instance is the callable — rank by counting how many query words appear in each doc, sort descending, and return the head.
Show solution
Dunder methods make your type behave like a built-in. Runnable:
class KeywordRetriever:
def __init__(self, docs):
self._docs = docs
def __len__(self):
return len(self._docs)
def __call__(self, query, k=3):
q = set(query.lower().split())
ranked = sorted(self._docs,
key=lambda d: -sum(w in d.lower() for w in q))
return ranked[:k]
r = KeywordRetriever(["reset your password", "refund policy", "enable 2fa"])
print(len(r), "docs")
print(r("how to reset password")) # called like a function
Context: You cannot afford a live model call in a unit test — it is slow, costs money, and is nondeterministic. The standard move is to inject a fake client whose method returns a canned value, so the test exercises your logic around the model, not the model itself.
Your task: Unit-test a function that calls a model client without any real API by injecting a unittest.mock fake whose respond method returns a canned answer, then assert both on the transformed output and on how the client was called.
Requirements:
- Build the fake with
MagicMockand set itsrespond.return_value - The function under test transforms the reply (e.g. strips and upper-cases it)
- Assert the returned value equals the expected transformed string
- Assert the client was called once with the exact question (
assert_called_once_with) - No network, no real client — only the mock
💡 Hint: Design the function to take the client as a parameter so the test can hand it a mock; the two assertions — on the result and on the call — together pin down the behaviour.
Show solution
The 'mock the model' pattern, runnable on stdlib unittest:
from unittest.mock import MagicMock
def answer(client, question):
reply = client.respond(question)
return reply.strip().upper()
fake = MagicMock()
fake.respond.return_value = " reset it in settings "
out = answer(fake, "how do I reset?")
assert out == "RESET IT IN SETTINGS"
fake.respond.assert_called_once_with("how do I reset?")
print("passed:", out)
Context: The payoff of this page is one component that is swappable, fast, and testable at once: a protocol so it can be substituted, a cache so repeat queries are cheap, and a clean seam so a test can assert on it. This is the retriever shape you would actually ship inside an agent.
Your task: Assemble the capstone: a Retriever Protocol, a class that satisfies it with a @cache-d retrieve returning a hashable tuple, and an answer_with(store, q) that accepts any Retriever — then assert both the structural check and a cache hit.
Requirements:
- The protocol is
runtime_checkableand typed to return atuple retrieveis cached, so it must return a hashable result (a tuple, not a list)answer_withis typed against the protocol, not the concrete class- Assert
isinstance(store, Retriever)passes structurally - Call the same query twice and confirm the second is served from cache
- Handle the empty-result case without indexing into nothing
💡 Hint: Returning a tuple is what makes @cache legal here; write answer_with against the protocol type so it stays agnostic to which retriever it got.
Show solution
The P5 capstone component — swappable, cached, and testable. Runnable:
from typing import Protocol, runtime_checkable
from functools import cache
@runtime_checkable
class Retriever(Protocol):
def retrieve(self, query: str, k: int = 3) -> tuple: ...
class KeywordRetriever:
def __init__(self, docs): self._docs = docs
def __len__(self): return len(self._docs)
@cache
def retrieve(self, query, k=3):
q = set(query.lower().split())
ranked = sorted(self._docs, key=lambda d: -sum(w in d.lower() for w in q))
return tuple(ranked[:k])
def answer_with(store: Retriever, q: str) -> str:
hits = store.retrieve(q)
return f"top hit: {hits[0] if hits else '(none)'}"
r = KeywordRetriever(["reset your password", "refund policy", "enable 2fa"])
assert isinstance(r, Retriever)
print(answer_with(r, "how to reset password"))
print(answer_with(r, "how to reset password")) # 2nd call cached
✓ Checkpoint — you've reached expert Python when you can…
- Type an agent component with
Protocol, generics, andTypedDict. - Reach for the right
functools/itertoolstool instead of hand-rolling loops. - Model "one of N actions" with a Pydantic discriminated union.
- Fan out API calls with
TaskGroup+Semaphore+timeout, and pick it vsgatherdeliberately. - Thread request state with
contextvarsinstead of a leaky global. - Test an agent by mocking the client — assert on tool calls and the refusal branch — and explain why you still need evals.
Knowledge check check yourself
The lesson says mocking the model is the highest-value testing technique, but also warns about its limits. What can mocked unit tests verify, what can they NOT verify, and what fills that gap?
Show answer
Why does the lesson use a Pydantic discriminated union (e.g. Annotated[Union[Answer, Escalate], Field(discriminator="kind")]) for an agent that returns one of several action types, and how does Pydantic decide which model to build?
Show answer
kind) and validates the data into the matching subtype, so you get a real typed instance (e.g. an Escalate) rather than a raw dict.