Advanced Python for Agents
The Python that makes LLM apps production-grade: Pydantic for schema-safe output, exceptions for resilient calls, context managers for streaming, generators, decorators (the tool-runner), a primer on async, and handling API keys safely. Each maps to a specific technique in the course.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
Learning objectives
- Define Pydantic models — how the course guarantees valid JSON output.
- Handle exceptions specifically — how the client stays resilient.
- Use
with(context managers) — how streaming works. - Recognize generators — how tokens stream one at a time.
- Read decorators (
@) — the SDK tool-runner and dataclasses. - Know when async matters, and load API keys safely from the environment.
1 · Pydantic — schema-safe data essential for agents
Pydantic is the library that turns a class into a validated schema. You describe the shape you want; Pydantic guarantees the data matches (or raises a clear error). It's how this course gets reliable JSON out of an LLM without regex.
pythonfrom pydantic import BaseModel, Field
from typing import Literal, Optional
class Diagnosis(BaseModel):
likely_cause: str
evidence: list[str]
fix_risk: Literal["read_only", "reversible", "irreversible"]
confidence: float = Field(ge=0.0, le=1.0) # must be 0-1
runbook_ref: Optional[str] = None # optional
# validation happens on construction
d = Diagnosis(likely_cause="db auth", evidence=["log line"],
fix_risk="reversible", confidence=0.9)
d.confidence # 0.9 (typed attribute access)
# bad data raises a clear ValidationError:
# Diagnosis(..., confidence=5.0) -> error: must be <= 1.0
# Diagnosis(..., fix_risk="nuke") -> error: not a valid Literal
This shows Pydantic, a library that turns a class into a strict data shape. You describe what fields you expect and their types; Pydantic checks the data matches the moment you build the object, and raises a clear error if it doesn't. This is how the course gets trustworthy JSON out of an LLM.
class Diagnosis(BaseModel):— inheriting fromBaseModelis what gives the class its validation powers. The lines under it are the fields and their types:likely_cause: strmeans "this must be text",evidence: list[str]means "a list of text items".Literal["read_only", "reversible", "irreversible"]restrictsfix_riskto exactly those three words — anything else is rejected. Think of it as a fixed menu.confidence: float = Field(ge=0.0, le=1.0)— a decimal number that must be greater-or-equal to 0 and less-or-equal to 1.runbook_ref: Optional[str] = Nonemeans that field may be text or left out entirely (its default isNone, Python's "no value").- The line
d = Diagnosis(likely_cause=..., confidence=0.9)is where validation actually runs. Because the data is valid, you get an objectdand can read fields with a dot:d.confidencegives back0.9.
What the output means: On good data you get a typed object (not a string to parse). The commented-out lines show what bad data does: confidence=5.0 or fix_risk="nuke" would each raise a ValidationError that names the exact problem.
Try this: Mentally set confidence=1.5 — Pydantic would refuse it because of le=1.0. That refusal is the whole point: bad data is stopped before your code ever sees it.
output_format, the SDK makes the model return JSON that must match — and hands you back a typed object, not a string to parse. The Literal becomes an enum the model literally cannot violate. No regex, no surprise values, no 2am parsing bug.client.messages.parse(..., output_format=Ticket) in Ch 2 Lab 2.3; the Sentiment classifier (Lab 2.4); the Judgment eval grader (Ch 5); and the capstone's Diagnosis and ToolCallRecord (Lab 8a) — the exact model shown above.2 · Exceptions — resilient code essential
When something fails (network, rate limit, bad input), Python raises an exception. try/except catches it so your program adapts instead of crashing. Catch specific exceptions, most-specific first.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
pythonimport anthropic
try:
resp = client.messages.create(...)
except anthropic.RateLimitError: # most specific first
print("slow down, retry later")
except anthropic.APIStatusError as e: # broader
if e.status_code >= 500:
print("server issue")
except anthropic.APIConnectionError: # network
print("check your connection")
# raise your own when appropriate
if resp.stop_reason == "refusal":
raise RefusalError(resp)
When something goes wrong at runtime (network drops, rate limit hit, bad input), Python raises an exception — an error object that stops normal flow. try/except lets you catch it and react calmly instead of crashing. The rule: catch the specific errors you can handle, and list the most specific ones first.
- Everything under
try:is the risky code. Here it's the API callclient.messages.create(...). If it works, theexceptblocks are skipped. - Each
exceptnames one error type.anthropic.RateLimitError(you sent requests too fast) comes first because it's the most specific. Order matters: Python uses the first matching block, so a broad type listed early would swallow the narrow ones. except anthropic.APIStatusError as e:catches a broader family of server responses;as egives you the error object so you can inspect it — heree.status_code >= 500checks whether it was a server-side failure.- At the bottom,
raise RefusalError(resp)shows you can also throw your own exception when the model declines — signalling a problem upward for a caller to handle.
What the output means: Nothing prints unless a matching error occurs; then exactly one print runs (e.g. "slow down, retry later" for a rate limit). The program keeps going instead of dying.
Try this: Ask yourself what happens if you put except Exception at the top — it would catch everything and none of the specific blocks below would ever run. That's the mistake the "Don't catch everything" warning is about.
except Exception hides bugs and treats a fatal 400 the same as a retryable 429. Catch the specific exceptions you can actually handle — the course's error tables list exactly which ones.ask() wrapper in Ch 1 Lab 1.5; the classifier's except (anthropic.APIError, ValueError) fail-safe in Ch 2; retry logic in Ch 6.3 · Context managers (with) intermediate
with sets something up and guarantees it's cleaned up afterward — even if an error occurs. You met it with files (P2); it's also how streaming works.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
python# files: closed automatically at the end of the block
with open("runbook.md") as f:
text = f.read()
# f is closed here, guaranteed
# streaming: the connection opens, streams, and closes cleanly
with client.messages.stream(model="claude-opus-4-8",
max_tokens=500, messages=msgs) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()
A with block is a context manager: it sets something up, lets you use it, and guarantees cleanup at the end — even if an error happens inside. You already saw this with files; the same shape powers streaming from the model.
with open("runbook.md") as f:opens the file and names itf. The indentedtext = f.read()reads its contents. When the block ends, the file is closed automatically — you never have to remember to close it.- The second block does the same for a live network stream:
with client.messages.stream(...) as stream:opens the connection and cleans it up when done, so a crash mid-stream can't leak an open connection. - Inside,
for text in stream.text_stream:loops over pieces of the reply as they arrive and prints each immediately (end=""avoids line breaks,flush=Trueshows it right away). final = stream.get_final_message()grabs the complete assembled message after the stream finishes.
What the output means: You'd see the model's answer appear gradually, chunk by chunk, rather than all at once when it's fully done.
Try this: Compare to closing a file by hand with f.close() — if an error struck before that line, the file would stay open. with removes that whole class of bug.
with open(...) reads fixtures & runbooks throughout the capstone. with client.messages.stream(...) is the streaming pattern in Ch 1 Lab 1.4 and the Ch 1 client wrapper.4 · Generators & streaming intermediate
A generator produces values one at a time, on demand, instead of building a whole list first. That's exactly what streaming tokens is — you get each chunk as the model produces it.
Illustrative fragment — defines demo values / files are needed before this runs standalone.
python# 'yield' makes a generator — it pauses and resumes
def count_to(n):
for i in range(n):
yield i # hands back one value, then waits
for x in count_to(3): # 0, 1, 2 — produced lazily
print(x)
# the SDK's text_stream is a generator of text chunks:
for chunk in stream.text_stream: # each token as it arrives
print(chunk, end="")
A generator is a function that produces values one at a time, on demand, instead of building a whole list up front. The keyword yield is what makes it a generator: it hands back a single value and pauses, resuming where it left off next time you ask.
def count_to(n):looks like a normal function, but because its body usesyield i, calling it doesn't run the code — it returns a generator you can loop over.- Each time the loop asks for the next value, execution runs to
yield i, hands backi, then freezes until the next request. So it never holds all the numbers in memory at once. for x in count_to(3):drives the generator, pulling 0, then 1, then 2 — printed as they're produced ("lazily").- The last two lines show the real payoff:
stream.text_streamis itself a generator, so looping over it gives you each token of the model's reply the instant it arrives.
What the output means: Prints 0, 1, 2 on separate lines. The key idea isn't the output — it's that values are made just-in-time, which is exactly how token streaming works.
Try this: Imagine count_to(1000000). A generator handles it with almost no memory because it only ever holds one number at a time; building a full list of a million items would not.
next(b.text for b in resp.content if ...) idiom (everywhere) consumes a generator expression to grab the first match without building a list.5 · Decorators advanced
A decorator (@something above a function/class) wraps it to add behavior. You don't need to write decorators for this course, but you need to recognize them — the SDK's tool-runner and dataclasses use them.
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'
beta_tool = _Any()
from dataclasses import dataclasspython# @dataclass decorates a class (P3) — writes __init__ for you
@dataclass
class Tool: ...
# the SDK's tool-runner: @beta_tool turns a function INTO a tool
@beta_tool
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"sunny in {location}"
# the decorator reads the signature + docstring to build the tool schema
A decorator is the @name line written directly above a function or class. It wraps that function/class to add behavior, without you editing the function itself. You mainly need to recognize them here — the SDK uses them heavily.
@dataclassaboveclass Tool: ...tells Python to auto-write boilerplate (like the__init__that stores your fields) so you don't have to type it by hand.@beta_toolaboveget_weatheris the interesting one: it takes an ordinary function and turns it into a tool the model can call.- The magic is in the last comment — the decorator reads the function's signature and its docstring (
"""Get weather for a location.""") to automatically build the description ("schema") the model needs. You write a normal function; the decorator does the wiring.
Try this: Read @beta_tool as "take the function below and make it a tool." The @ is not decoration in the visual sense — it's applying a wrapper. Recognizing that is the whole goal of this section.
@dataclass on the capstone's Tool. The SDK's @beta_tool decorator is the "tool runner" alternative to the manual loop, referenced in Ch 4 (the course teaches the manual loop for control, but the decorator path exists).6 · async — a primer advanced
Async lets one program handle many I/O-bound tasks (like API calls) concurrently, instead of waiting for each. This course uses the synchronous client for clarity, but you'll meet async when scaling a service.
pythonimport asyncio
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
async def ask(q): # 'async def' defines a coroutine
resp = await client.messages.create( # 'await' yields while waiting
model="claude-opus-4-8", max_tokens=100,
messages=[{"role":"user","content":q}])
return resp
# run many at once, concurrently
async def main():
results = await asyncio.gather(ask("a"), ask("b"), ask("c"))
Async lets one program juggle many slow I/O tasks (like API calls) at the same time, instead of standing idle waiting for each to finish. This course uses the simpler synchronous client, but you'll meet async when scaling a service — so learn to recognize its two keywords.
client = AsyncAnthropic()is the async version of the client. Its calls must be "awaited" rather than called normally.async def ask(q):defines a coroutine — a special function that can pause and let other work run while it waits. You can onlyawaitinside anasync def.resp = await client.messages.create(...)—awaitmeans "start this, and while it's waiting on the network, let other coroutines run." That's how many calls overlap.asyncio.gather(ask("a"), ask("b"), ask("c"))launches three calls together and waits for all of them — running concurrently instead of one after another.
What the output means: Functionally the answers are the same as the sync client; the win is time: three calls that each take a second finish in about one second total, not three.
Try this: Spot the two keywords — async def and await. Wherever you see them, the code is overlapping waits. For the labs you don't need to write this; just recognize it.
7 · Environment variables & secrets essential
Never hardcode an API key. Load it from the environment (a git-ignored .env file), which is exactly the isolation the course setup relies on.
pythonimport os
from dotenv import load_dotenv
load_dotenv() # read .env into the environment
key = os.environ.get("ANTHROPIC_API_KEY") # fetch it
# the SDK reads ANTHROPIC_API_KEY automatically:
from anthropic import Anthropic
client = Anthropic() # no key in code — good
# fail fast with a friendly message if it's missing (like llmkit.py):
if not key:
raise SystemExit("No API key — cp .env.example .env and paste your key")
Never paste an API key directly into your code — it can leak into git or logs. Instead you keep it in a hidden .env file (which git ignores) and load it into the program's environment at startup. This block is the standard, safe setup.
load_dotenv()reads the key/value lines from your.envfile and puts them into the environment, as if you'd set them in your terminal.os.environ.get("ANTHROPIC_API_KEY")fetches that value by name. Using.get(...)returnsNoneif it's missing rather than crashing.client = Anthropic()— notice there's no key written in the code. The SDK looks upANTHROPIC_API_KEYfrom the environment on its own.if not key:is a fail-fast check: if the key wasn't found,raise SystemExit(...)stops the program with a friendly message telling you how to fix it, instead of a confusing error deeper in.
What the output means: If your key is set, the client is ready to use and nothing prints. If it's missing, the program exits immediately with the helpful message.
Try this: Look at how the key never appears as text in the file. That single habit — keys in .env, never in .py or prompts — is the most important security rule in the lesson.
.env (git-ignored), never in .py files, never in prompts (prompts get logged). This is both a security practice and what keeps your personal key isolated from FICO's Claude Code setup.load_dotenv() + Anthropic() is the setup in Ch 1 Lab 1.2. The shared llmkit.py does exactly the "fail fast if no key" check. In the capstone, the same idea extends to scoping cloud IAM roles so credentials can't be misused.8 · Pydantic in depth — nested models, validators, serialization advanced
§1 covered the basics. Real schemas nest, validate, and serialize. This is the full toolkit for the structured output that makes agents reliable.
pythonfrom pydantic import BaseModel, Field, field_validator
from typing import Literal
# models nest — a model can contain a list of other models
class Evidence(BaseModel):
source: str
line: str
class Diagnosis(BaseModel):
likely_cause: str
evidence: list[Evidence] # nested list of models
fix_risk: Literal["read_only", "reversible", "irreversible"]
confidence: float = Field(ge=0.0, le=1.0)
# a custom validator — enforce a rule the type system can't
@field_validator("evidence")
@classmethod
def must_have_evidence(cls, v):
if not v:
raise ValueError("a diagnosis must cite at least one piece of evidence")
return v
# build from nested dicts (e.g. parsed from the model's JSON)
d = Diagnosis(likely_cause="db auth",
evidence=[{"source":"logs", "line":"auth failed"}],
fix_risk="reversible", confidence=0.9)
d.evidence[0].source # 'logs' — typed all the way down
d.model_dump() # -> a plain dict (for JSON/storage)
d.model_dump_json() # -> a JSON string
Diagnosis.model_json_schema() # -> the JSON Schema (what the SDK sends the model)
This extends §1's Pydantic basics with the two things real schemas need: nesting (a model that contains other models) and custom validators (rules the type system alone can't express).
class Evidence(BaseModel):is a small model. Then inDiagnosis, the fieldevidence: list[Evidence]says "a list ofEvidenceobjects" — a model nested inside another.- The
@field_validator("evidence")block is a custom rule. After the basic type checks pass, this function runs on theevidencevalue;if not v:means "if the list is empty", and itraises an error demanding at least one item.return vpasses the (approved) value through. - Building
d = Diagnosis(...)from plain dictionaries shows Pydantic converting nested dicts like{"source":"logs", "line":"auth failed"}into realEvidenceobjects for you. d.evidence[0].sourcereads a field of the first nested object — typed access all the way down. The last three lines convert the object back out:model_dump()→ a dict,model_dump_json()→ a JSON string,model_json_schema()→ the schema the SDK sends the model.
What the output means: You get a fully-typed Diagnosis whose evidence is a list of real Evidence objects; the dump methods turn it into plain data for storage or sending.
Try this: A validator lets you enforce domain rules like "a plan must not target prod." It's a guardrail at the data layer — bad output is rejected before your code acts on it.
Diagnosis is a nested model like this. model_dump() serializes records for the audit log. model_json_schema() is what output_format= sends under the hood in Ch 2. Validators are how you'd harden the agent's proposed plans.9 · Custom exceptions intermediate
Define your own exception types so callers can catch your specific failures distinctly — like a refusal, or a policy block.
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 log(*a, **k): # demo stub
return _Any()
class _resp_t:
stop_reason = 'demo'
def stop_reason(self, *a, **k): return 'demo'
def __getattr__(self, k): return 'demo'
resp = _resp_t()
def run_agent(*a, **k): # demo stub
return _Any()
task = _Any()python# subclass Exception (this is inheritance from P3)
class RefusalError(Exception):
def __init__(self, response):
self.response = response
super().__init__("model refused the request")
class PolicyBlocked(Exception):
"""Raised when the safety gate blocks an action."""
# raise it where the condition occurs
if resp.stop_reason == "refusal":
raise RefusalError(resp)
# callers catch YOUR type specifically
try:
answer = run_agent(task)
except RefusalError as e:
log("refused", e.response.stop_details)
except PolicyBlocked:
log("action blocked by policy")
You can define your own exception types so callers can catch your specific failures distinctly — telling "the model refused" apart from "a policy blocked it" apart from "the network failed." Each can then be handled differently.
class RefusalError(Exception):creates a new error type by inheriting from the built-inException. That inheritance is all it takes to make a usable custom error.- Its
__init__stores the model'sresponseon the error object (self.response = response) so a handler can inspect it later, and callssuper().__init__(...)to set the human-readable message. class PolicyBlocked(Exception):shows you don't even need a body — a docstring alone is enough to define a distinct type.- You
raise RefusalError(resp)where the condition happens, and callers useexcept RefusalError as e:to catch that exact case — readinge.responseto log details. A separateexcept PolicyBlocked:handles the other case its own way.
What the output means: No output on its own — this is machinery. The value is that different failures become separately catchable, so your program can respond appropriately to each.
Try this: Notice RefusalError and PolicyBlocked are just classes that extend Exception. Custom exceptions are one of the simplest, highest-value patterns for readable error handling.
RefusalError; the capstone treats a policy block as a distinct outcome. Custom exceptions keep "the model declined" separate from "the network failed" separate from "we blocked it" — each handled differently.10 · Retry with exponential backoff advanced · production
APIs fail transiently (429 rate limits, 5xx). The production pattern: retry with an increasing, jittered delay. The SDK does this for you, but understanding it lets you tune it — and it's a beautiful use of everything so far.
pythonimport time, random
import anthropic
def call_with_retry(fn, *args, max_retries=5, base=1.0, cap=60.0, **kwargs):
last = None
for attempt in range(max_retries):
try:
return fn(*args, **kwargs) # *args/**kwargs from P3
except anthropic.RateLimitError as e: # retryable
last = e
except anthropic.APIStatusError as e:
if e.status_code < 500: # 4xx = don't retry
raise
last = e
# exponential backoff + jitter, capped
delay = min(base * (2 ** attempt) + random.uniform(0, 1), cap)
print(f"retry {attempt+1}/{max_retries} in {delay:.1f}s")
time.sleep(delay)
raise last # give up after max_retries
APIs sometimes fail temporarily — a rate limit (429) or a server hiccup (5xx). The professional fix is to retry, but wait longer between each attempt ("exponential backoff") plus a little randomness ("jitter") so many clients don't all retry in lockstep. This one function ties together most of the course's Python.
call_with_retry(fn, *args, max_retries=5, ...)takes the function to call plus its arguments.*args/**kwargsmean "accept whatever arguments and pass them straight through" tofn.- The
for attempt in range(max_retries):loop tries up to 5 times. Inside,return fn(*args, **kwargs)attempts the call — and if it succeeds,returnexits immediately with the result. - A
RateLimitErroris caught and remembered (last = e) so we can retry. But in theAPIStatusErrorblock,if e.status_code < 500: raise— a 4xx like "bad request" is your fault and won't fix itself, so don't waste retries; re-raise now. delay = min(base * (2 ** attempt) + random.uniform(0, 1), cap)doubles the wait each round (2 ** attempt→ 1, 2, 4, 8…), adds up to 1 second of jitter, andmin(..., cap)keeps it from growing past 60s.time.sleep(delay)waits, then the loop tries again. If all attempts fail,raise lastgives up with the last error.
What the output means: On a transient failure you'd see lines like retry 1/5 in 1.3s, with the delay roughly doubling each time, until it either succeeds or exhausts all retries and re-raises.
Try this: Trace the delays for attempts 0–3 with base=1.0: about 1s, 2s, 4s, 8s (plus jitter). The SDK does this for you automatically — now you know exactly what its max_retries setting controls.
*args/**kwargs (P3), specific exceptions (§2/§9), the min()/power math (P1), a loop with a cap (P1), and f-string formatting (P1). This one function is a capstone of the earlier parts — and it's real production code.max_retries does exactly this — now you know what it's doing and when to override it.11 · Writing your own context manager & decorator advanced
§3 and §5 showed how to use with and @decorators. Here's how to write them — useful for timing calls, scoping resources, or wrapping tools with logging.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
pythonimport time
from contextlib import contextmanager
from functools import wraps
# a context manager that times a block — code before yield = setup,
# after yield = teardown (runs even on error)
@contextmanager
def timer(label):
t0 = time.monotonic()
try:
yield
finally:
print(f"{label}: {(time.monotonic()-t0)*1000:.0f}ms")
with timer("llm call"):
resp = client.messages.create(...) # timed automatically
# a decorator that logs every call to a function
def logged(fn):
@wraps(fn) # preserves fn's name/docstring
def wrapper(*args, **kwargs): # accept any args (P3)
print(f"-> {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
print(f"<- {fn.__name__} = {result!r}")
return result
return wrapper
@logged
def scale(name, replicas):
return f"scaled {name}"
scale("web", 3) # logs the call and the result
§3 and §5 showed how to use with and @decorators. Here you write your own — a timer context manager and a logging decorator. Both are tiny and extremely useful.
@contextmanagerabovedef timer(label):lets you build awith-block from a function. Everything beforeyieldis setup (record the start time); theyieldis where yourwith-block's body runs; everything after runs as cleanup.- The
try/finallymatters:finallyruns no matter what — so the timingprintfires even if the code inside thewithraises an error. That's the guarantee context managers give. - Using it,
with timer("llm call"):automatically prints how many milliseconds the wrapped API call took. - The second half writes a decorator:
def logged(fn):takes a function and returns a newwrapperthat prints the call, runs the real function (result = fn(*args, **kwargs)), prints the result, then returns it.@wraps(fn)keeps the original function's name and docstring intact. Writing@loggedabovescaleswaps in that wrapper.
What the output means: with timer("llm call"): prints something like llm call: 812ms. Calling scale("web", 3) prints a -> line before and a <- line after, showing the arguments and the return value.
Try this: Notice a context manager uses yield (like a generator) to split setup from teardown, while a decorator returns a replacement function. Both are ways to wrap behavior around your code.
traced_call does — capturing latency and tokens around every API call. Wrapping tools with a @logged-style decorator is a clean way to feed the capstone's audit log.12 · async concurrency — gather & Semaphore advanced · scaling
§6 introduced async. Here's the part that actually saves time at scale: running many API calls concurrently, with a limit so you don't blow your rate limit. This is how you'd run a 200-case eval suite in a fraction of the wall-clock time.
Requires: pip install anthropic
pythonimport asyncio
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
# limit concurrency so you don't exceed rate limits
sem = asyncio.Semaphore(5) # at most 5 in flight at once
async def grade_one(case):
async with sem: # acquire a slot (context manager!)
resp = await client.messages.create(
model="claude-opus-4-8", max_tokens=200,
messages=[{"role":"user","content": case}])
return resp
async def grade_all(cases):
# launch them all; gather waits for every one, preserving order
return await asyncio.gather(*[grade_one(c) for c in cases])
results = asyncio.run(grade_all(golden_set)) # 200 evals, ~5 at a time
§6 introduced async; this is the part that saves real time: running many API calls at once but capping how many run simultaneously so you don't trip the rate limit. This is how you'd grade a 200-case eval suite in a fraction of the wall-clock time.
sem = asyncio.Semaphore(5)creates a counter that allows at most 5 tasks through at a time. Think of it as 5 tickets: a task must hold a ticket to run, and returns it when done.async with sem:is that ticket in action — a task waits here until a slot is free, then holds it for the duration of its call. (It's a context manager, so the slot is always released, even on error.)grade_one(case)makes one awaited API call while holding a slot.grade_all(cases)builds onegrade_one(c)per case and hands them all toasyncio.gather(...), which runs them concurrently and returns results in order.asyncio.run(grade_all(golden_set))is the entry point that actually starts the async machinery and runs everything to completion.
What the output means: You get a list of results for every case, but they complete far faster than one-at-a-time — with never more than 5 requests hitting the API simultaneously.
Try this: Remove the Semaphore in your head: all 200 calls would fire at once and you'd hit a 429 rate-limit error. The async with sem line is what keeps concurrency safe.
Semaphore is essential — without it you'd fire all 200 at once and hit a 429. Note how it uses async with — a context manager, tying §3 and §6 together.13 · Logging (not print) production
In production you use the logging module, not print — it gives you levels, timestamps, and structured output you can ship to a dashboard. This is the backbone of observability.
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'
pod_name = _Any()pythonimport logging, json
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("agent")
log.info("starting diagnosis")
log.warning("pod %s restarting", pod_name) # lazy % formatting
log.error("tool failed", exc_info=True) # include the traceback
# structured logging — one JSON object per event (queryable later)
log.info(json.dumps({"event":"tool_call", "tool":"kubectl_get",
"allowed": True, "ms": 42}))
In real programs you use the logging module instead of print. Logging gives you severity levels, timestamps, and structured output you can filter and ship to a dashboard — the foundation of observability.
logging.basicConfig(level=logging.INFO, format=...)configures logging once: show everything at INFO level and above, and prefix each line with a timestamp and level.log = logging.getLogger("agent")gets a named logger so you can tell this component's messages apart from others'.- The three calls show the levels:
log.info(...)for normal events,log.warning(...)for something notable,log.error(..., exc_info=True)which also attaches the full error traceback. Notelog.warning("pod %s restarting", pod_name)— the%splaceholder is filled in only if the message is actually logged ("lazy" formatting, slightly faster than building the string yourself). - The final call logs a structured event:
json.dumps({...})writes one JSON object per event, so later you can query logs like data ("show everytool_callthat wasn't allowed").
What the output means: Each line comes out prefixed with a timestamp and level, e.g. 2026-09-06 10:00:00 INFO starting diagnosis. The JSON line is a single machine-readable record.
Try this: Compare to print: logging lets you dial the level up or down without deleting lines, and the JSON form is what makes production logs searchable.
logging + json.dumps per request is exactly the Ch 6 observability pattern (trace_id, tokens, stop_reason, latency). The capstone's audit log is this idea specialized to infra actions.Where to go next with Python expert
You now have the Python this course uses, end to end. Beyond it, the natural next steps for AI/DevOps automation:
- Testing —
pytestin depth (the capstone uses it for safety tests). - Async at scale —
asyncio, concurrency patterns, rate-limit-aware queues. - Packaging —
pyproject.toml, publishing a reusable package. - Cloud SDKs —
boto3(AWS), the Kubernetes Python client — for the "go real" step of the capstone. - Typing depth —
mypy, generics, protocols — for larger codebases.
🎯 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.
A staple senior-Python ask. Parameterized decorator = 3 nested layers; use functools.wraps to preserve the function identity.
pythonimport functools, time
def retry(times=3, delay=1.0):
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
for attempt in range(times):
try:
return fn(*a, **kw)
except Exception:
if attempt == times - 1: raise
time.sleep(delay * 2 ** attempt) # backoff
return wrapper
return deco
A classic senior-Python interview question: write a @retry decorator you can configure (how many times, how long to wait). A configurable decorator needs three nested layers — that structure is exactly what interviewers are checking for.
- The outer
retry(times=3, delay=1.0)takes the settings and returns the real decorator. This layer exists only so you can write@retry(times=5)with arguments. - The middle
deco(fn)is the actual decorator: it receives the function being decorated and returns a replacement. - The inner
wrapper(*a, **kw)is what runs when you call the decorated function. Its loop triesfn(*a, **kw)up totimes; on anyException,if attempt == times - 1: raisere-raises on the last attempt (give up), otherwise it sleeps and retries. time.sleep(delay * 2 ** attempt)is the backoff — the wait doubles each attempt (1s, 2s, 4s…).@functools.wraps(fn)keeps the wrapped function's real name and docstring so it isn't disguised aswrapper.
What the output means: Used as @retry(times=5) above a flaky function, calls that fail get retried automatically with growing pauses, and only the final failure propagates.
Try this: Count the defs: three, one inside the next. The rule of thumb — "a decorator that takes arguments has one extra layer than one that doesn't" — is the insight the question tests.
"Process a 10GB file" → yield line by line, constant memory. Generators are the answer they want.
pythondef error_lines(path):
with open(path) as f:
for line in f: # lazy iterator, O(1) memory
if "ERROR" in line:
yield line.rstrip()
"Process a 10GB file" is a favorite interview trap: if you read the whole file into memory it won't fit. The answer is a generator that yields one line at a time, so memory stays tiny no matter how big the file is.
with open(path) as f:opens the file safely (auto-closed at the end — the context manager from §3).for line in f:is the key trick: iterating a file object reads it one line at a time instead of loading it all. That's the "O(1) memory" comment — memory used stays constant regardless of file size.if "ERROR" in line:keeps only the lines you care about, andyield line.rstrip()hands each matching line back to the caller (rstrip()trims the trailing newline).- Because it's a generator, the caller can loop over
error_lines(path)and process results as they stream out — the 10GB file is never fully in memory at once.
What the output means: You get an iterator that produces just the ERROR lines, one by one — you could count them or write them elsewhere while barely using any memory.
Try this: Contrast with lines = open(path).read().splitlines(), which loads everything first. The generator version is the same idea as token streaming: produce values lazily, hold almost nothing.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The moment an LLM returns a tool call, you have untrusted JSON crossing into your code. Pydantic turns that raw dict into a typed object that either validates or fails loudly — the difference between catching a malformed call at the door and debugging a mystery KeyError three layers deep.
Your task: Define a Pydantic model for an LLM tool call with a name: str and an args: dict field, parse a well-formed call, then feed it a value of the wrong type and show the model rejects it.
Requirements:
- Subclass
BaseModelwith exactly the two annotated fields - Construct one valid instance and read
.name/.argsback - Pass a non-dict where a
dictis required and catch theValidationErrorit raises - Report that the failure was caught rather than letting it crash the program
- Keep the demo runnable even though the import is illustrative
💡 Hint: The whole point is that construction itself is the validation step — wrap the bad call in try/except ValidationError and inspect the error rather than checking types by hand.
Show solution
Pydantic gives schema-safe data (section 1). Runnable if pydantic is installed; the logic is the point:
from pydantic import BaseModel, ValidationError
class ToolCall(BaseModel):
name: str
args: dict
ok = ToolCall(name="scale", args={"n": 3})
print(ok.name, ok.args)
try:
ToolCall(name="scale", args="not-a-dict")
except ValidationError as e:
print("rejected", e.error_count(), "error(s)")
Context: In an agent, "the tool blew up" and "we ran out of budget" are different problems with different recovery paths, but a caller sometimes just wants to know "did the agent fail at all?". A small exception hierarchy lets one except catch broadly and another catch narrowly, from the same tree.
Your task: Define a base AgentError and two subclasses ToolError and BudgetError, then raise a ToolError and catch it through the base type to prove the inheritance works.
Requirements:
AgentErroris the common base; both specifics inherit from it- Raise a
ToolErrorwith a descriptive message - Catch it with
except AgentErrorand confirm the subclass is caught - Print the concrete type name (
type(e).__name__) so the hierarchy is visible - Stay pure-stdlib — no third-party imports
💡 Hint: Empty subclasses (just pass or a docstring) are enough; the value is entirely in the is-a relationship that lets the base clause match a subclass instance.
Show solution
Custom exceptions let callers catch broadly or narrowly. Runnable (pure stdlib):
class AgentError(Exception):
"""Base for all agent failures."""
class ToolError(AgentError):
pass
class BudgetError(AgentError):
pass
try:
raise ToolError("scale failed: no such deployment")
except AgentError as e: # base catches the subclass
print(f"caught {type(e).__name__}: {e}")
Context: Token streaming is how a chat UI feels responsive, and how you cap generation cost — you consume as much as you need and stop. A generator yields one item at a time so memory stays flat no matter how long the stream, and a lazy consumer can walk away early.
Your task: Write a generator that yields tokens one at a time from a list of text chunks, then a consumer that takes only the first N tokens using itertools.islice — without ever building the full token list.
Requirements:
- The generator
yields individual tokens, not whole chunks or a list - Splitting each chunk into words happens lazily as the stream is pulled
- The consumer stops after N tokens via
islice, leaving the rest unproduced - The final result is a short list of the first N tokens
- No full materialization of all tokens anywhere in the flow
💡 Hint: islice(gen, N) pulls exactly N values and then stops iterating, so the generator never runs past the tokens you actually asked for.
Show solution
Lazy iteration keeps memory flat. Runnable (stdlib):
from itertools import islice
def token_stream(chunks):
for chunk in chunks:
for tok in chunk.split():
yield tok # produce one token at a time
chunks = ["the quick brown", "fox jumps over", "the lazy dog"]
first5 = list(islice(token_stream(chunks), 5))
print(first5) # ['the', 'quick', 'brown', 'fox', 'jumps']
Context: Two idioms carry a lot of agent plumbing: a decorator to wrap any function with cross-cutting behaviour (timing, logging, retries), and a context manager to bracket a block with reliable setup/teardown. Getting both right — preserving identity, cleaning up on error — is what separates toy code from instrumentation you trust.
Your task: Build a @timed decorator that prints how long the wrapped call took, and a step(name) context manager built with contextlib.contextmanager that prints on entry and exit, then use both together.
Requirements:
- Preserve the wrapped function's identity with
functools.wraps - Measure elapsed time and print it even if the call raises (use
finally) - Report a monotonic-ish elapsed time (e.g.
time.perf_counter), not wall-clocktime.time stepuses a singleyieldto split enter from exit- Demonstrate a real call wrapped in the timer inside a
with step(...)block
💡 Hint: The @contextmanager generator does its setup, yields once, then does teardown; put the decorator's timing read in a finally so it fires on both success and failure.
Show solution
Decorator via functools.wraps, context manager via @contextmanager. Runnable:
import time, functools
from contextlib import contextmanager
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
print(f"{fn.__name__} took {time.perf_counter()-t0:.4f}s")
return wrapper
@contextmanager
def step(name):
print(f"-> {name}")
yield
print(f"<- {name} done")
@timed
def work(n):
return sum(range(n))
with step("compute"):
print("sum:", work(100_000))
Context: Every network call to a model provider will eventually get a 429 or a blip, and hammering it on failure makes things worse. Exponential backoff with jitter spreads retries out so a fleet of clients doesn't retry in lockstep — the standard resilience pattern for any rate-limited API.
Your task: Implement a generic call_with_retry(fn, ...) that retries a callable on a chosen exception with a min(base*2**attempt + jitter, cap) delay and re-raises after max_retries, then exercise it against a flaky function that fails twice before succeeding — no real API.
Requirements:
- Retry only on the designated transient exception; let anything else propagate
- Delay grows as
base * 2**attempt, plus random jitter, clamped tocap - After
max_retriesexhausted, re-raise the last captured exception - The flaky test function fails a fixed number of times then returns a value
- Confirm the eventual success and the number of attempts it took
💡 Hint: Track the last exception in a variable inside the loop so you can re-raise it after the loop ends; you can compute the delay for realism even if you skip the actual sleep to keep the demo instant.
Show solution
The section-10 pattern, exercised offline with a function that fails twice then succeeds. Runnable:
import random
class Transient(Exception): pass
def call_with_retry(fn, *args, max_retries=5, base=0.001, cap=0.05, **kwargs):
last = None
for attempt in range(max_retries):
try:
return fn(*args, **kwargs)
except Transient as e:
last = e
delay = min(base * (2 ** attempt) + random.uniform(0, base), cap)
# time.sleep(delay) # omitted so the demo returns instantly
raise last
calls = {"n": 0}
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise Transient("429")
return "ok"
print(call_with_retry(flaky)) # ok (after 2 transient failures)
print("attempts:", calls["n"])
Context: A real request path is never just "call the model". It reads config and secrets from the environment, validates them before spending a cent, routes the call through a resilience wrapper, and logs each attempt so on-call can see what happened. This is the shape almost every production LLM client converges on.
Your task: Wire environment-driven config, a validated fake API key, custom exceptions, and the retry wrapper into one guarded request path: read the key from an env var (with a default when missing), validate its format, route the call through retry, and log every attempt.
Requirements:
- Read the key from an environment variable, falling back to a safe default if unset
- Validate the key's shape and raise a dedicated
ConfigErroron a bad one - Never log the full secret — log only a masked suffix (e.g. last 4 chars)
- Route the actual call through the retry wrapper from the previous rung
- Emit a log line per attempt via the
loggingmodule, not bare prints - The whole path runs offline against a fake request function
💡 Hint: Compose the pieces you already built — load_key() validates, call_with_retry handles transience, and a module logger records each try; keep the request itself a stub that echoes the payload.
Show solution
Production request path shape — env-driven config, validation, resilient call. Runnable (stdlib):
import os, logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("agent")
class ConfigError(Exception): pass
def load_key():
key = os.environ.get("FAKE_API_KEY", "sk-demo-000")
if not key.startswith("sk-"):
raise ConfigError("key must start with sk-")
return key
def request(payload, key):
log.info("calling with key ...%s", key[-4:])
return {"ok": True, "echo": payload}
key = load_key()
print(request({"model": "opus"}, key))
✓ Checkpoint — you know the course's Python when you can…
- Write a Pydantic model with
Literal/Optional/Fieldconstraints and explain why it makes LLM output safe. - Catch specific exceptions most-specific-first and say why not to catch everything.
- Use
withfor files and streaming and explain what it guarantees. - Recognize a generator and how
text_streamstreams tokens. - Read a decorator (
@dataclass,@beta_tool). - Say when async matters, and load an API key safely from
.env.
Knowledge check check yourself
According to the lesson, why must except blocks be ordered most-specific-first (e.g. RateLimitError before APIStatusError), and what is the danger of a bare except Exception?
Show answer
except block, so a broad type listed early would swallow the narrow ones and prevent their handlers from ever running. A bare except Exception hides bugs and treats a fatal 400 the same as a retryable 429, so you should catch only the specific exceptions you can actually handle.In the retry-with-exponential-backoff example, the APIStatusError handler does if e.status_code < 500: raise. Why re-raise immediately on a 4xx instead of retrying?