AI EngineeringZero to ProductionHome·About·Contact
Appendix · Python for AI Agents · Part 5

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.

⏱️ ~2 hours🎯 Advanced → Expert🧪 incl. testing agentsoptional but powerful
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • an Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
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) and itertools in 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; contextvars for 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

Beyond the hints in P3. Precise types catch bugs before runtime and make an agent codebase navigable.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
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
▶ How this works

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.

  1. TypedDict describes a dictionary whose keys are known ahead of time. Message says every message dict must have a role (only one of the three listed words) and a content string. The checker flags a misspelled key like "rol".
  2. Generic + TypeVar makes a reusable container that works for any inner type. T is a placeholder; EvalResult[str] means "an EvalResult whose output is a string" — same class, filled in with a concrete type.
  3. 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. So answer() works with any retriever that has the right method shape.
  4. 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.

🔗 Course relevanceTypedDict 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

Try it

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
▶ How this works

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.

  1. @cache is a decorator (the @ line above a function wraps it with extra behavior). It memoizes: remembers past results so calling embed_once("hi") twice computes only once and returns the stored answer the second time.
  2. partial makes a new function with some arguments pre-filled. ask_opus is client.messages.create with model and max_tokens already set, so you only pass messages each time — less repetition.
  3. reduce boils a whole list down to one value by combining items pairwise. Here it sums [0.2, 0.5, 0.9] starting from 0.0, giving 1.6.
  4. singledispatch lets one function name do different things depending on the type of its first argument. The base render handles anything; the @render.register version runs only when a dict is 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 carefully@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.
🔗 Course relevancepartial 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

Try it

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)))
▶ How this works

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).

  1. 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+.)
  2. 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.
  3. chain glues several lists into one sequence without building a big combined list in memory — it just walks the first, then the next.
  4. groupby groups neighbouring equal items. It only groups items that are already next to each other, so you sort by 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.

🔗 Course relevancebatched 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

P4 covered models + validators. Here's the rest of what makes Pydantic the backbone of reliable agents.

Try it
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)
▶ How this works

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.

  1. A discriminated union means "the value is exactly one of these models, and one field tells us which." Answer and Escalate both have a kind field with a fixed value; Field(discriminator="kind") tells Pydantic to read kind and pick the matching model.
  2. model_validate({...}) takes a plain dict and returns a real, typed object. Because kind was "escalate", d.action is an Escalate instance — so isinstance(d.action, Escalate) is True.
  3. Annotated constrained types bundle a type with rules. Confidence is a float that must sit between 0 and 1 (ge=0, le=1); reuse it anywhere instead of re-writing the check.
  4. model_dump turns the object back into a dict. exclude_none=True drops 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.

pydantic-settings for config & secretsfrom 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.
🔗 Course relevanceDiscriminated unions are the ideal shape for "the agent chose answer OR escalate" (support project) or "one of N tool calls". Reusable 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

P4 showed gather. Modern async (3.11+) uses TaskGroup, which handles errors and cancellation correctly — the right way to fan out agent calls.

Try it
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]
▶ How this works

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.

  1. 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.
  2. 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.
  3. TaskGroup (Python 3.11+) starts many tasks with tg.create_task(...) and, at the end of its async with, waits for all of them. Key behavior: if any task fails, it cancels the rest and re-raises — "all or nothing."
  4. 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.

TaskGroup vs gathergather 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.
🔗 Course relevanceThis is how you run the Ch 5 / project eval suites concurrently (dozens of cases in seconds), and how the research agent gathers sources in parallel. The Semaphore + timeout combo is essential to respect rate limits and avoid hangs (Ch 6 scaling).

6 · Protocols, dunder methods & making things swappable expert

Try it
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")
▶ How this works

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.

  1. In Transcript, defining __len__ makes len(transcript) work, __iter__ makes for m in transcript work, __getitem__ enables transcript[-1], and __bool__ decides what if transcript: means. Your object now feels built-in.
  2. __call__ makes an instance callable like a function. After t = Tool(fn, risk), writing t(**args) runs fn — a "function object" that also carries data (its risk level).
  3. __enter__/__exit__ let an object be used in a with block. Timer records the start time on enter and prints the elapsed seconds on exit — so with 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.

🔗 Course relevanceA 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

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.

Try it
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}")
▶ How this works

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.

  1. ContextVar("trace_id", default="-") creates the request-scoped variable with a fallback used before anything is set.
  2. handle_request calls .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.
  3. 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.

Why not a plain global?A module global is shared across all concurrent requests — under async or threads, request A would see request B's tenant. 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.
🔗 Course relevanceThreads the 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

The most valuable section here. LLM calls are slow, cost money, and are non-deterministic — terrible for a test suite. The fix: mock the client so your tests exercise your logic (the loop, the gate, the parsing) with a fake, instant, free response. This is how you test agents in CI.

Try it
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
    ...
▶ How this works

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.

  1. fake_response(text) builds a lightweight object shaped like the SDK's real reply. SimpleNamespace just makes an object with the attributes you name (.content, .stop_reason, …) so your code can read them the same way.
  2. eng.client = MagicMock() swaps the real client for a fake. ...create.return_value = fake_response("Paris") says "whenever the code calls create, hand back this fake message."
  3. eng.run(...) then exercises your real logic against the fake — no API key, no network, instant and identical every time.
  4. 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.

Mock the tool loop tooTo test an agentic loop, make 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.
What mocks can & can't tell youMocking tests your plumbing (loop, gate, parsing, error handling) — fast, deterministic, run on every commit. It does not test output quality (does the model actually diagnose correctly?) — that needs real evals (Ch 5), run less often with a key. You need both: mocked unit tests for logic, evals for quality. This is the split every project in the course uses.
🔗 Course relevanceThis is the technique behind every project's "tests run with no API key." The DevOps safety test, the support tenant test, the analyst read-only test — all assert on your logic. Mocking lets you also test the model-facing paths (loop, gate) deterministically in CI, complementing the key-requiring evals.

Worked example · a typed, cached, testable retriever putting it together

Protocol + generics + @cache + a dunder + a mockable design — a small component that shows the expert patterns cohering.

Worked example
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
▶ How this works

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.

  1. @runtime_checkable class Retriever(Protocol) defines the contract: "anything with a retrieve(query, k) method is a Retriever." runtime_checkable lets you verify it at run time with isinstance.
  2. KeywordRetriever holds some docs. __len__ makes len(retriever) report the doc count. @cache on retrieve remembers answers so an identical query is instant the second time.
  3. 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 @cache can only store hashable (unchangeable) results — a tuple is; a list isn't.
  4. answer_with(store, q) takes any Retriever, so you could pass the Ch 3 VectorStore instead with no change here. The __main__ block builds one, asserts it structurally counts as a Retriever, 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

🎯 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.

Test LLM code without calling the API (mocking)

"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
▶ How this works

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.

  1. summarize(client, text) takes the client as a parameter instead of creating its own. That single design choice is what makes it testable — you can supply any client.
  2. In the test, fake = MagicMock() is a stand-in that auto-creates any attribute you touch. The next line pre-programs its reply: create returns an object whose content[0].text is " hi ".
  3. 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.

Bounded parallelism with a semaphore

"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])
▶ How this works

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.

  1. asyncio.Semaphore(limit) creates limit permits (default 10).
  2. Each worker does async with sem: — it waits for a free permit, runs await fn(x), then releases the permit. So no more than limit workers are ever inside at once.
  3. 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.

Exercise 1 · A Protocol as a structural interfaceBeginner

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_checkable so isinstance works at runtime
  • isinstance(instance, Retriever) returns True purely 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
Exercise 2 · Memoize with functools.cacheIntermediate

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, not 2 — 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
Exercise 3 · Stream-friendly itertoolsAdvanced

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,)
Exercise 4 · A dunder to make an object swappableExpert

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 a k) and returns the top-k matches
  • Do a simple keyword overlap ranking so the results are intelligible
  • Show len(retriever) and retriever("...") 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
Exercise 5 · Test LLM code by mocking the modelProfessional

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 MagicMock and set its respond.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)
Exercise 6 · A typed, cached, testable retrieverIndustry scenario

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_checkable and typed to return a tuple
  • retrieve is cached, so it must return a hashable result (a tuple, not a list)
  • answer_with is 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, and TypedDict.
  • Reach for the right functools/itertools tool 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 vs gather deliberately.
  • Thread request state with contextvars instead 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.
🎓 The Python appendix is completeP1→P5 take you from your first f-string to expert-level, testable agent code. The single most impactful habit from this page: mock the model to unit-test your logic, and use evals for quality — it's what lets you ship agents with confidence.

Knowledge check check yourself

✓ Knowledge check

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
Mocking tests your plumbing — the loop, the policy gate, parsing, and error handling — deterministically, for free, on every commit. It cannot test output quality (whether the model actually diagnoses correctly); that requires real evals (Ch 5), run less often with an API key. You need both.
✓ Knowledge check

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
A discriminated union expresses that the value is exactly one of several models, ideal for 'the agent chose answer OR escalate' or one of N tool calls. Pydantic reads the shared discriminator field (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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in