AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Python for AI Agents · Part 2

Data & Structures

This is the workhorse chapter. Messages are lists of dicts. Tool results are dicts. Fixtures are JSON. Chunks, embeddings, golden sets — all lists and dicts. Master these and most of the course code becomes readable at a glance.

⏱️ ~75 min🎯 Beginner→Intermediate🔗 the most-used Python here

Learning objectives

  • Use lists and dictionaries fluently — the shape of every message and tool result.
  • Read and build nested data (a list of dicts of lists) without getting lost.
  • Write list/dict comprehensions — the one-liners all over this course.
  • Load/parse JSON and read files — how fixtures, runbooks, and configs work.
  • Use just enough regex to parse logs and chunk text.

1 · Lists essential

Here's a thing that surprises people the first time they see real LLM code. You imagine an AI conversation as some exotic object — and then you open the code and it's just a list. A plain Python list, the same one you'd use for a shopping list, holding the whole chat: [first message, model's reply, your next message, …]. Once that clicks, half of this course stops looking like magic and starts looking like moving items in and out of a list. That's the whole reason this is the very first section.

Think of a list as a numbered row of lockers. The lockers sit in order, you can open any one by its number, and you can keep bolting new lockers onto the end. Two ideas do all the work. First, it's ordered: item 0 is always item 0, and the order you put things in is the order they stay in — which matters enormously for a conversation, where "who said what, and when" is the entire point. Second, it's changeable (the jargon is "mutable"): you can add to it, overwrite a slot, or remove from it after it's built, which is exactly what an agent does as it appends each new turn.

Why it matters: in this course a list is what messages, a response's content blocks, your tools, and every batch of search results actually are. When you loop over them, slice off "the last N", or .append() a new turn, you're doing the single most common operation in the whole codebase. Get comfortable here and the agent loop reads like plain English later.

Name the common mistake now, because everyone hits it: Python counts from zero, not one. The first item is pods[0], and the last of three items is pods[2], not pods[3] — reach for pods[3] on a three-item list and Python stops you with an IndexError. That "off-by-one" is the most common beginner bug in any language, and the friendly fix is pods[-1]: negative indices count from the end, so -1 is always the last item no matter how long the list grew. With that in mind, here's the everyday toolkit.

a list is an ordered row of cells — two ways to point at the same slot index → 0 1 2 checkout-api web-frontend worker-queue 3 IndexError from end → -3 -2 -1 pods[-1] = last, always Counting starts at 0, and negatives count from the end. The first item is pods[0], the last of three is pods[2] or pods[-1] — and pods[3] falls off the end into an IndexError.
🗺️ How to read this diagram

Look at the three blue cells as the list itself, then read the numbers above and below them — those are two ways to name the same slot.

  • The numbers above each cell (0, 1, 2) are the ordinary indices: Python counts from zero, so the first cell is 0, not 1.
  • The numbers below (-3, -2, -1) are negative indices that count from the end — the blue arrow highlights that -1 always lands on the last cell no matter how long the list grows.
  • The red cell on the right marked 3 is the trap: there is no slot at position 3 in a three-item list, so pods[3] raises an IndexError.

In short: the first item is index 0 and the last is index -1; asking for an index past the end is the classic off-by-one crash.

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
pythonpods = ["checkout-api", "web-frontend", "worker-queue"]

pods[0]              # 'checkout-api'  (first — zero-indexed)
pods[-1]             # 'worker-queue'  (last)
pods[:2]             # ['checkout-api', 'web-frontend']  (a "slice")
len(pods)            # 3
pods.append("db")     # add to the end
"web-frontend" in pods   # True

for p in pods:         # loop over items
    print(p)
▶ How this works

A list is an ordered, changeable collection of items written in square brackets [...]. Here pods holds three service names, and the block shows the everyday things you do with a list: read items, count them, add to it, test membership, and loop over it.

  1. pods[0] reads the first item — Python counts from 0, so index 0 is the first, 1 is the second, and so on. pods[-1] counts from the end, so -1 is the last item.
  2. pods[:2] is a slice — a smaller list of items 0 and 1 (it stops before index 2). len(pods) tells you how many items there are.
  3. pods.append("db") adds a new item onto the end of the list, changing it in place. "web-frontend" in pods checks whether an item is present and gives back True or False.
  4. for p in pods: visits each item in turn; the indented print(p) runs once per item, with p holding the current one.

What the output means: The comments show the result of each line: 'checkout-api', 'worker-queue', the two-item slice, the length 3, and so on. The loop would print each pod name on its own line.

Try this: Change pods[0] to pods[3] after the append and see it now returns 'db'. Then try an index that doesn't exist, like pods[9] — Python raises an IndexError, its way of saying "there's no item there".

🔗 Used in the coursemessages = [...] and resp.content are lists you loop over in Ch 1 and every agent loop. messages.append(...) is how the Ch 4 agent remembers turns. Slicing ([-lines:]) trims logs in the capstone mock cluster.

2 · Dictionaries essential

Imagine trying to find one word's meaning in a paper dictionary by starting at page 1 and reading every entry until you hit it. Absurd — you'd flip to roughly the right place and jump. That's the whole idea behind Python's dictionary: you don't hunt through positions, you look things up by name. Ask for msg["role"] and it hands you the value straight away, without walking past everything else. It's named after the book for exactly this reason — a real dictionary maps a word to its definition; a Python dict maps a key to a value.

If a list answers "what's at position 3?", a dict answers "what's stored under this label?" — and that's a far more natural question when your data has named parts. A chat message isn't naturally "item 0 and item 1"; it's "a role and some content". A dict lets you say that directly: {"role": "user", "content": "What's broken?"}. The picture to hold in your head is a coat-check counter: you hand over a ticket (the key) and get back exactly your coat (the value), instantly, no matter how many coats are on the rack.

Why this earns "essential": just about every structured thing in this course is a dict. A message is a dict. A tool result is a dict. An API object is a dict. Whole responses are dicts nested inside dicts. If lists are the rows of the course's data, dicts are the records — and you'll spend more time reading and building them than anything else.

The mistake that will bite you, and the habit that prevents it: looking up a key that isn't there. Writing msg["role"] when there is no "role" key doesn't quietly return nothing — it crashes your program with a KeyError. That's fine when you're certain the key exists, but real data from tools and APIs is often missing fields. The safe alternative is msg.get("role"), which returns None (or a default you name, like msg.get("ns", "staging")) instead of blowing up. A good rule of thumb: use square brackets when the key must be there and its absence is a real bug; use .get() with a sensible default when the key is optional. You'll see p.get("logs", []) all over the capstone for exactly this reason.

a dict maps a key to a value — look up by name, not position "role" "content" "missing" 'user' 'What's broken?' msg["missing"] → KeyErrormsg.get("missing") → None msg["role"] msg["content"] Ask by name, get the value back. Each key on the left maps to one value on the right; a key that isn't there crashes with KeyError under [...] but returns a safe default under .get().
🗺️ How to read this diagram

Read each row left to right: a key on the left, an arrow, and the value it points to on the right — that arrow is the lookup you do with msg[key].

  • The blue rows are keys that exist — "role" and "content" — each mapping cleanly to its value, exactly what msg["role"] and msg["content"] hand back.
  • Notice there are no numbers on the left: unlike a list, a dict is addressed by name, so order and position don't matter.
  • The red row is the trap: "missing" has no value. Under square brackets that raises a KeyError; under .get() it calmly returns None (or your default) — the dashed arrow shows there's nothing on the other end.

In short: a dict is a name→value map; use [key] when the key must exist and .get(key, default) when it might not.

Try it
pythonmsg = {"role": "user", "content": "What's broken?"}

msg["role"]                 # 'user'  (look up by key)
msg.get("missing")          # None  (safe: no error if key absent)
msg.get("ns", "staging")     # 'staging'  (default if absent)
msg["content"] = "updated"   # change a value
msg.keys()                  # dict_keys(['role', 'content'])

for key, val in msg.items():   # loop over pairs
    print(key, "=", val)
▶ How this works

A dictionary (dict) stores key → value pairs in curly braces {...}. Instead of a position, you look things up by a name (the key). Every chat message and API object in this course is a dict, so this is the pattern you'll reach for most.

  1. msg["role"] looks up the value stored under the key "role" and returns 'user'. If the key isn't there, this form crashes with a KeyError.
  2. msg.get("missing") is the safe look-up: if the key is absent it returns None instead of crashing. msg.get("ns", "staging") lets you supply a fallback value to use when the key is missing.
  3. msg["content"] = "updated" changes the value for an existing key (or adds it if new). msg.keys() lists all the keys.
  4. for key, val in msg.items(): loops over both parts of each pair at once, so inside the loop key and val hold the name and its value.

What the output means: You'd see 'user', then None, then 'staging'; the loop prints role = user and content = updated.

Try this: Swap msg["missing"] (square brackets) for the key "missing" and watch it raise KeyError — that's exactly why .get() is safer when a key might not be there.

dict[key] vs dict.get(key)msg["role"] raises KeyError if the key is missing; msg.get("role") returns None instead. Use .get() with a default when a key might not be there — like the capstone's p.get("logs", []).
🔗 Used everywhereA message is a dict: {"role":"user","content":...} (Ch 1). A tool result is a dict: {"type":"tool_result","tool_use_id":...} (Ch 4). The Ch 5 results dict and the capstone's _MATRIX[rung][risk] gate lookup (Lab 8c) are all dicts.

3 · Nested data — the real shape of API objects essential

The first time you print a real API response, your heart sinks a little. It's a wall of braces and brackets, keys inside lists inside keys, scrolling off the screen. Everyone's instinct is to try to hold the whole thing in their head at once — and everyone fails, because you can't. The good news is that you never have to. Nested data only looks scary; it's read the same calm way every time, and this section is about that one reliable trick.

Here's the mental model: nested data is a set of Russian nesting dolls, or rooms opening into rooms. Each layer is just a list or a dict — the same two things you already know from §1 and §2 — sitting inside another list or dict. A dict whose value is a list of dicts (each of which has a list inside it) sounds like a tongue-twister, but it's only lists and dicts stacked up. Nothing new to learn; the skill is navigation, not new vocabulary.

Why this is essential and not optional: this is the literal shape of the data you'll spend the course handling. kubectl get -o json, the capstone's fixtures.json, an LLM response object, a page of search hits — all of them are lists-inside-dicts-inside-lists. You cannot read state from a real system without reading nested data, so this is the bridge between "toy examples" and "actual work".

The trick, and the mistake it cures: read it one bracket at a time, left to right — never try to swallow the whole path in one gulp. The beginner error is to stare at state["pods"][0]["logs"][-1] as one intimidating blob and freeze. Instead, walk it: state["pods"] is "the list of pods"; add [0] and you have "the first pod" (a dict); add ["logs"] and you have "that pod's log list"; add [-1] and you have "its last line". Each step lands you on a plain list or dict you already understand, so at every point you can pause and ask "what do I have right now?" Build the path outward one piece at a time and the wall of brackets turns into a short, obvious walk.

peel state["pods"][0]["logs"][-1] one bracket at a time — each step is a plain list or dict state — a dict {"pods": [ ... ]} ["pods"] a list of pod dicts [ {pod0}, {pod1} ] [0] the first pod — a dict {"name":..., "logs":[...]} ["logs"] the log list ["starting", "auth failed"] [-1] 'auth failed' Nested data is a short walk, not a wall. Each bracket steps you down exactly one level — dict → list → dict → list — until the last one, [-1], lands on the value 'auth failed'.
🗺️ How to read this diagram

Read top to bottom, following the blue arrows — each arrow is one bracket from the path, and it drops you onto the plain list or dict named in the next box.

  • The top white box is state, an ordinary dict. The label on each arrow (["pods"], [0], ["logs"]) is the single bracket you add at that step.
  • Every box is something you already understand from §1 and §2 — a dict or a list — so at each step you can stop and ask "what do I have right now?"
  • The final bracket [-1] (the arrow into the green box) takes the last item of the log list, giving the payoff value 'auth failed'.

In short: build the path outward one bracket at a time and each step lands on a familiar list or dict — the scary blob becomes an obvious walk.

Try it
pythonstate = {
  "pods": [
    {"name": "checkout-api", "status": "CrashLoopBackOff",
     "logs": ["starting", "auth failed"]},
    {"name": "web", "status": "Running", "logs": []},
  ]
}

# read it step by step:
state["pods"]                 # the list of pod dicts
state["pods"][0]              # the first pod dict
state["pods"][0]["name"]      # 'checkout-api'
state["pods"][0]["logs"][-1]  # 'auth failed'
▶ How this works

Real API data is lists inside dicts inside lists. It looks intimidating, but you never read it all at once — you peel one bracket at a time, left to right. This state is a dict with one key, "pods", whose value is a list of pod dicts.

  1. state["pods"] steps into the outer dict and hands you the list of pod dicts.
  2. state["pods"][0] adds [0] to grab the first pod from that list — itself a dict.
  3. state["pods"][0]["name"] steps one more level in, into that pod's "name" key, giving 'checkout-api'.
  4. state["pods"][0]["logs"][-1] goes into the first pod's "logs" list and takes the last entry (-1) — 'auth failed'.

What the output means: Each line drills one level deeper; the comments show what you get back at each step, ending with the last log line 'auth failed'.

Try this: Read the value for the second pod's status by hand first, then check it: state["pods"][1]["status"] should give 'Running'. Building the path bracket-by-bracket is the whole skill.

🔗 Used in the courseThis is exactly the capstone's fixtures.json and the real kubectl get -o json shape. The Ch 3 chunks ({"text":..., "source":...}) and the monitoring fixtures (metrics, alerts, dashboards) are all nested like this.

4 · Tuples & sets intermediate

You already have lists and dicts — so why meet two more collections? Because sometimes a list is subtly the wrong tool, and using the right one makes a bug impossible or a slow program fast. Tuples and sets aren't exotic; they're a list with one deliberate feature added or removed. Learn what each one gives up, and you'll know instantly when to reach for it.

Start with the tuple. A tuple is simply a list that has been locked — once created, you can't change it. Think of a list as a whiteboard you keep editing, and a tuple as something printed on paper: fixed, final, safe to hand around. Why would you want to give up the ability to edit? Because some groupings shouldn't change: an (x, y) coordinate, an (chunk, score) pair returned from a search, a row from a database. Locking it says "these belong together and shouldn't be tampered with", and it lets Python use tuples as dictionary keys (lists can't be keys, because they might change under the dict's feet). You'll meet tuples most often through unpacking — chunk, score = result — which splits a two-part group into two names in one clean line.

Now the set. A set is a collection with two rules: no duplicates, and no order. Picture a bag of unique name-tags — adding a tag that's already inside changes nothing, and there's no "first" or "last". That sounds limiting until you see the payoff: because a set is built on the same hashing trick as a dict's keys, asking "is x in here?" is nearly instant even for millions of items, where the same check on a list has to scan every element one by one. So the moment your code is testing membership repeatedly — "have I seen this id?", "is this tool in my gated list?" — a set is dramatically faster than a list.

The mistake to avoid: treating a set like a list and expecting order or positions. There's no my_set[0] — sets don't index, because they don't have an order. And don't try to mutate a tuple: point[0] = 5 raises a TypeError, which is the tuple doing its job, not a failure. Match the container to the need: list for an ordered, editable sequence; tuple for a fixed group; set for fast uniqueness and membership.

same items, three containers — each gives up one thing to gain another list [ ] a b c ordered · indexed editable ✓ lst[0]=x works tuple ( ) a b c ordered · locked 🔒 editable ✗ t[0]=x → TypeError set { } a b c no order · no dupes "x in s" ≈ instant no s[0] — no index Pick the container by what it forbids. A list is editable; a tuple locks itself (edits raise TypeError); a set drops order and duplicates to make membership checks near-instant.
🗺️ How to read this diagram

Read the three panels left to right — same three items a b c in each, but each panel labels what that container gives up and gains.

  • The left panel (list) shows ordered, indexed cells you can overwrite — lst[0]=x works, because a list is editable.
  • The middle panel (tuple) is the same ordered row but locked (🔒): try to edit a slot and Python raises a TypeError. You give up editing to gain a fixed, safe-to-share group that can even be a dict key.
  • The right panel (set) drops both order and duplicates — drawn as loose circles, not a row — so there is no s[0]. In exchange, asking "x" in s is near-instant even for huge collections.

In short: list = ordered + editable, tuple = ordered + locked, set = unordered + unique + fast membership. Match the container to what the job forbids.

Try it
python# tuple: an ordered group that can't change (parentheses)
point = (0.1, 0.9)
chunk, score = ("faq.md#0", 0.87)   # "unpacking" into two names

# set: unique items, fast membership test (curly braces, no keys)
seen = {"a", "b"}
seen.add("a")          # still {'a','b'} — duplicates ignored
"a" in seen           # True (very fast)
DESTRUCTIVE = {"delete_pod", "terraform_apply"}   # a set of gated tools
▶ How this works

This block introduces two more collections. A tuple is like a list but can't be changed (fixed group, round brackets). A set holds only unique items and answers "is this in here?" very fast (curly braces, but no keys).

  1. point = (0.1, 0.9) makes a tuple — handy for a fixed pair like x/y coordinates.
  2. chunk, score = ("faq.md#0", 0.87) is unpacking: Python hands the first part to chunk and the second to score in one line. You'll see this a lot when a function returns two things.
  3. seen = {"a", "b"} makes a set. seen.add("a") does nothing visible because "a" is already there — sets silently ignore duplicates.
  4. "a" in seen is a membership test that's very fast even for huge sets. DESTRUCTIVE = {...} shows the real use: a set of tool names you want to gate.

What the output means: The comments confirm the set stays {'a','b'} after the duplicate add, and "a" in seen is True.

Try this: Try point[0] = 5 — a tuple refuses to change and raises a TypeError. That immutability is the point: use a tuple when the group shouldn't be edited.

🔗 Used in the courseTuple unpacking for chunk, score in store.search(...) reads retrieval results in Ch 3. Sets power the runbook retriever's word-overlap (Lab 8c) and the "which tools need approval" check in Ch 4.

5 · Comprehensions — the course's favorite one-liner intermediate → essential

The first time you see a comprehension, it looks like someone smashed a loop flat and hoped for the best. [p["name"] for p in pods if p["status"] != "Running"] — all on one line, no colon, no indentation. It's the single most common "wait, what does that do?" moment for people reading this course's code. So let's demystify it completely, because once it clicks you'll not only read the course fluently, you'll start writing it yourself.

A comprehension is just a for-loop that builds a list, folded onto one line. That's the entire idea. Any time you'd write "make an empty list, loop over something, append a transformed item each time", you can say the same thing in one expression. The trick to reading it is to read it out of order: find the for in the middle first ("for each pod in pods"), then the if on the right if there is one ("keeping only the ones not Running"), then the bit on the left that says what to collect ("grab its name"). Left-thing, for-each-item, optional-filter — that's the rhythm, and it matches plain English: "the name of each pod that isn't running."

Why the course leans on it so hard: almost everything here is "take a list, keep some of it, transform each survivor" — pull the names out of pod dicts, keep only the failing ones, build a tool spec from each tool, turn chunks into a lookup table. A comprehension expresses that in a line you can read at a glance, instead of a four-line loop you have to trace. There are three flavours you'll meet: the plain list comprehension, the dict comprehension ({key: value for …}, which builds a lookup instead of a sequence), and the same syntax feeding any()/all() to answer a yes/no question over a collection.

The mistake to watch for — and the honest limit: comprehensions are for building a collection, not for doing side-effects. If your loop body is really about calling print() or writing to a file rather than collecting values, keep it a normal loop; cramming that into a comprehension is a well-known code smell. And resist the urge to nest three of them deep to look clever — past a certain point a plain loop is clearer, and clarity always wins. Use a comprehension when it makes the intent more obvious, which, for the filter-and-transform work in this course, is nearly always.

a comprehension is a for-loop folded onto one line — read the parts in this order 2 · collect p["name"] 1 · for each for p in pods 3 · filter (optional) if p["status"]!="Running" reads as the plain-English sentence: the name of each pod in pods that isn't Running result → ['b'] (a new list) Read the middle first, then the right, then the left. A comprehension has three parts — collect-this, for-each-item, optional-filter — and they line up word-for-word with "the name of each pod that isn't Running."
🗺️ How to read this diagram

Don't read the top row left to right — read it in the numbered order (green first, then amber, then blue), which is the order that matches how you'd say it out loud.

  • The green box (1 · for each) is the engine: for p in pods walks every item. Start here.
  • The amber box (3 · filter) is the optional gate: if p["status"] != "Running" keeps only the items that pass — drop it and every item is kept.
  • The blue box (2 · collect) on the left is what actually goes into the result: p["name"]. The second row shows each box mapping onto the matching words of the sentence, and the white box at the bottom is the new list that comes out — ['b'].

In short: for-each-item → keep-if → collect-this. Say the sentence and the one-liner writes itself.

Try it
pythonpods = [{"name":"a","status":"Running"}, {"name":"b","status":"CrashLoopBackOff"}]

# the long way
names = []
for p in pods:
    names.append(p["name"])

# the comprehension (same result, one line)
names = [p["name"] for p in pods]                    # ['a', 'b']

# with a filter
broken = [p["name"] for p in pods if p["status"] != "Running"]   # ['b']

# dict comprehension
by_name = {p["name"]: p for p in pods}              # look up a pod by name

# any() / all() with a comprehension — common in tests
any(p["status"] == "CrashLoopBackOff" for p in pods)   # True
▶ How this works

A comprehension builds a whole list (or dict) in one line. It reads like English: "[this] for each [item] in [collection] if [condition]". The block first shows the long loop version, then the same thing as a one-liner, so you can see they're identical.

  1. The long way starts with an empty list names = [] and appends p["name"] inside a for loop — three lines to collect the names.
  2. names = [p["name"] for p in pods] does exactly the same in one line: "take p["name"] for each p in pods".
  3. Adding if p["status"] != "Running" filters: only pods that aren't Running contribute, so broken ends up as ['b'].
  4. {p["name"]: p for p in pods} is a dict comprehension — it builds a name→pod lookup. any(... for p in pods) checks whether at least one item matches and returns True/False.

What the output means: names becomes ['a', 'b'], broken is ['b'], by_name lets you look up a pod by its name, and the any(...) line is True because pod b is crash-looping.

Try this: Change any to all and re-read: all(...) is only True when every pod matches. This any/all-with-comprehension combo is all over the course's tests.

🔗 Used all over[t.spec() for t in TOOLS.values()] builds the tool list in the capstone loop. next(b.text for b in resp.content if b.type=="text") pulls the answer out of a response in Ch 1. {c["id"]: c for c in chunks} is the RRF lookup in Ch 3. The safety test uses all(... for r in Rung).

6 · JSON & files essential

Here's a problem you don't notice until you hit it. You've built a lovely dict of state in memory — but the moment your program ends, it's gone. And when an API on the other side of the internet wants to send you a nested structure, it can't ship you a live Python object down the wire; it can only send text. So you need a way to flatten your dicts and lists into plain text you can save to a file or send across a network — and to rebuild them on the other side. That format is JSON, and it's everywhere in this course precisely because it solves this everyday problem.

The beautiful thing about JSON is how little you have to learn: it maps almost 1:1 onto the lists and dicts you already know. A JSON object {...} becomes a Python dict; a JSON array [...] becomes a list; strings, numbers, true/false become str, numbers, True/False. Think of JSON as the "flat-packed furniture" version of your data — the same table, disassembled into a box for shipping, that reassembles into the identical table when unpacked. That's why fixtures, configs, and API payloads all live as JSON: it's the universal shipping format for structured data.

The one naming trap that confuses everybody, so learn it once and never trip again: the json module has two nearly-identical pairs of functions, and the difference is a single letter s for "string". json.load(f) reads from a file; json.loads(text) reads from a string. Likewise json.dump(obj, f) writes to a file, and json.dumps(obj) returns a string. Read the trailing s as "string" and the whole module suddenly makes sense: no-s talks to files, s talks to strings.

Reading files uses the with open(...) pattern, and it's worth a word on why it looks the way it does. A file is a limited resource the operating system hands you — leave it open and you leak it; forget to close it after writing and your data may never actually hit the disk. The with block guarantees the file is closed for you the instant you leave it, even if an error is thrown midway. You'll learn the machinery behind this (context managers) in P4; for now, just always read and write files inside a with open(...) and you'll never leak a file handle or lose a half-written file.

the four json functions — the trailing s means "string" a FILE a STRING read → write → json.load(f) file → objects json.loads(txt) string → objects json.dump(o, f) objects → file json.dumps(o) objects → string left column = no s (files) · right column = with s (strings) No-s talks to files; s talks to strings. load/dump read and write a file object; loads/dumps read and write a plain string — the only difference is that trailing "s".
🗺️ How to read this diagram

Read it as a 2×2 grid: the rows are the direction (read on top, write on the bottom) and the columns are what you're talking to (a file on the left, a string on the right).

  • The blue left column is the file functions — json.load reads a file into Python objects, json.dump writes objects out to a file.
  • The green right column is the string functions — json.loads parses a string, json.dumps produces a string. Notice they are identical to the left column but with a trailing s.
  • The payoff is the caption at the bottom: read the s as "string", and you never again have to guess which of the four you want.

In short: pick the row by read-vs-write, pick the column by file-vs-string — and the s suffix always means "string".

Try it
pythonimport json

# read a JSON file into Python objects
with open("fixtures.json") as f:
    state = json.load(f)          # dict/list, ready to use

# parse a JSON string (e.g. a tool's output)
data = json.loads('{"replicas": 3}')   # {'replicas': 3}

# turn Python back into a JSON string (e.g. a log line)
line = json.dumps({"trace_id": "abc", "tokens": 42})

# read a whole text file (a runbook)
with open("crashloop.md") as f:
    text = f.read()
▶ How this works

JSON is just text that mirrors Python's lists and dicts, so it's how fixtures, configs, and API payloads travel and get stored. This block shows the four moves you need: read a JSON file, parse a JSON string, turn Python back into JSON, and read a plain text file.

  1. import json loads Python's built-in JSON toolkit.
  2. with open("fixtures.json") as f: opens the file and json.load(f) reads it straight into Python dicts/lists. The with block auto-closes the file when done.
  3. json.loads('{"replicas": 3}') parses a JSON string (note the s — "load string") into a dict. json.dumps({...}) goes the other way, turning Python into a JSON string.
  4. The last with open("crashloop.md") as f: plus f.read() pulls a whole text file into one string — how runbooks get loaded for RAG.

What the output means: state becomes the file's contents as Python objects, data is {'replicas': 3}, line is a JSON string, and text holds the file's text.

Try this: Print type(data) after the json.loads line — you'll see <class 'dict'>, proving the JSON string became a real Python dict you can index.

The with open(...) patternwith guarantees the file is closed even if something errors. Always read files this way. You'll learn why (context managers) in P4 — for now, just use it.
🔗 Used in the coursejson.load(open("fixtures.json")) loads the mock cluster in Lab 8a. json.dumps(...) writes structured logs in Ch 6 observability. Reading runbook .md files powers RAG in Ch 3 and Lab 8c. Parsing model tool-input JSON is called out as a gotcha in Ch 4.

7 · Just enough regex intermediate

Regular expressions have a fearsome reputation, and it's half-deserved. You've probably seen one of those line-noise monsters — ^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@… — and quietly decided regex is not for you. Here's the reassuring truth: for this whole course you need about four tiny patterns, and they're genuinely easy. We're going to learn "just enough" on purpose, and skip the wizardry that gives regex its scary name.

So what is a regular expression? It's a search pattern for text — a way to describe "find things that look like this" instead of hunting for one exact word. Ordinary "error" in line only finds the literal word "error"; a regex can find "any run of digits", "any word", or "three digits, a dash, two digits, a dash, four digits" (a US Social Security number). Think of it as a stencil you lay over text: wherever the shape lines up, that's a match. The pattern is written in a little mini-language, but you only need a handful of its symbols.

Why it earns a place in a data chapter: real inputs arrive as messy text, not tidy dicts. Log lines, documents, and user messages are strings, and before you can put them into lists and dicts you often have to carve them up — split a document into paragraphs to chunk it for RAG, pull the numbers out of a status line, or scan for a pattern that looks like leaked personal data (PII) so you can redact it. That carving is what a little regex buys you.

The pieces to actually memorise are few: \w+ means "one or more word characters" (letters, digits, underscore) — that's how you grab words; \d+ means "one or more digits" — that's how you grab numbers; and \b is a "word boundary" — the invisible edge between a word and a space, so a pattern doesn't accidentally match inside a longer run. That's roughly 90% of what you'll ever type. One habit that prevents a whole class of bugs: always write regex patterns as raw strings with an r in front — r"\w+", not "\w+". Without the r, Python tries to interpret the backslashes itself before the regex engine ever sees them, and your pattern quietly breaks. The r says "hands off, pass these backslashes through untouched", and making it automatic will save you a confusing afternoon.

a regex is a stencil — it matches the shape of text, not one exact word pattern: r"\d+" text: 3 pods, 7 restarts → ['3','7'] the three tokens that cover ~90% of what you need \w+one+ word chars \d+one+ digits \bword boundary Match the shape, not the word. Laying \d+ over a status line lands only on the digit runs (3, 7) and skips the words — three little tokens (\w+, \d+, \b) cover almost everything.
🗺️ How to read this diagram

Read top to bottom: the pattern sits at the top, the text it scans is the middle row, and the blue arrows show which pieces of that text the pattern lands on.

  • The purple pattern box \d+ means "one or more digits". Think of it as a stencil you slide across the text looking for that shape.
  • In the middle row the green digit cells (3 and 7) are what the stencil lines up with; the white word cells (pods,, restarts) are skipped — so re.findall returns ['3','7'].
  • The bottom row is your whole starter kit: \w+ grabs words, \d+ grabs numbers, \b marks the invisible edge of a word so a pattern doesn't match mid-word.

In short: a regex describes a shape; slide it over text and it lands on every place that shape appears — \w+, \d+, and \b are almost all you need.

Try it

Illustrative fragment — defines demo values / files are needed before this runs standalone.

pythonimport re

re.findall(r"\w+", "reset my password")     # ['reset','my','password'] — words
re.findall(r"\d+", "3 pods, 7 restarts")      # ['3','7'] — numbers
re.split(r"\n\s*\n", text)                 # split on blank lines (paragraphs)
re.search(r"\b\d{3}-\d{2}-\d{4}\b", s)     # find an SSN (PII redaction)

\w+ = word characters, \d+ = digits, \b = word boundary. That's 90% of what you'll use.

▶ How this works

Regular expressions (regex) find patterns in text. You only need a tiny bit for this course. A pattern like r"\w+" is written as a raw string (the r stops Python from mangling the backslashes) and describes what to look for.

  1. re.findall(r"\w+", "reset my password") finds every run of word characters and returns them as a list — here the three words. \w+ means "one or more letters/digits/underscores".
  2. re.findall(r"\d+", ...) uses \d+ ("one or more digits") to pull the numbers ['3','7'] out of the text.
  3. re.split(r"\n\s*\n", text) splits text wherever there's a blank line (a newline, optional spaces, another newline) — that's how you break a document into paragraphs.
  4. re.search(r"\b\d{3}-\d{2}-\d{4}\b", s) looks for the first match of an SSN-shaped pattern (3-2-4 digits); \b is a word boundary so it doesn't match inside a longer number.

What the output means: findall returns lists (['reset','my','password'], ['3','7']); split returns the paragraphs; search returns a match object (or None if nothing matches).

Try this: Change \w+ to \w (drop the +) and re-run — without the + you get every single character separately. The + means "one or more".

🔗 Used in the coursere.split(r"\n\s*\n", text) chunks documents by paragraph in Ch 3 Lab 3.1 and the Lab 8c runbook retriever. re.findall(r"\w+", ...) tokenizes for keyword matching. re.search for SSNs is the PII guardrail in Ch 6 and the Ch 5 deterministic check.

8 · List methods & sorting essential

Every retrieval system, every search box, every "best results first" list you've ever used comes down to one operation: sorting. When a RAG pipeline pulls back forty candidate chunks and shows the model only the top three, that's a sort followed by a slice. When re-ranking re-orders results by a better score, that's a sort. If you learn one thing from this section, make it sorted() with a key — it's the quiet workhorse behind more of this course than any other single function.

Basic sorting is obvious enough — sorted([0.2, 0.9, 0.5]) gives you the numbers in order. The leap that unlocks real work is the key argument, so let's make sure it lands. Imagine a stack of résumés you want ordered by years of experience. You don't compare the whole résumé; you glance at one number on each and sort by that. key is exactly that glance: it's a little function that, given one item, returns the value to sort on. So sorted(chunks, key=lambda c: c["score"]) means "for each chunk, look at its score, and order by that" — the chunks come back ranked even though you sorted a list of complicated dicts. (A lambda is just a throwaway one-line function; lambda c: c["score"] reads "given c, give back c's score".)

Two companions complete the pattern. reverse=True flips ascending into descending — and for scores you almost always want the biggest first, so this shows up constantly. And slicing [:k] takes the top k after sorting. Put them together — sorted(items, key=..., reverse=True)[:k] — and you've written the "top-k" funnel that is retrieval: over-fetch a pile of candidates, rank them, keep the best few. That one line is worth committing to muscle memory.

The mistake that surprises newcomers: the difference between sorted(x) and x.sort(). sorted() returns a new list and leaves the original untouched — safe, and what you usually want. x.sort() reorders the list in place and returns None, which trips people who write y = x.sort() and are baffled when y is None. When in doubt, reach for sorted(); it never mutates data out from under you. The other classic gotcha: sorting mixed types (numbers and strings together) raises a TypeError — Python refuses to guess how a 3 compares to "three", and that strictness is a feature, not a bug.

sorted(items, key=..., reverse=True)[:k] — the retrieval funnel over-fetch many candidates 0.2 · 0.9 · 0.5 0.7 · 0.1 · 0.8 sort by score reverse=True 0.9 · 0.8 · 0.7 0.5 · 0.2 · 0.1 [:k] top-k 0.9 · 0.8 · 0.7 rest discarded rank everything, keep only the best few Sort, then slice — that's retrieval. Over-fetch a pile of candidates, rank them highest-first with sorted(..., reverse=True), then [:k] keeps the best few and drops the rest.
🗺️ How to read this diagram

Follow it left to right — it's a funnel that starts wide with many candidates and narrows to just the top few.

  • The white box on the left is the over-fetch: a jumble of candidate scores in no particular order.
  • The blue arrow into the middle box is sorted(..., key=..., reverse=True), which re-orders them highest-score-first.
  • The green box is [:k] — the top few you keep — while the dashed arrow to the red box is everything below the cutoff, silently discarded.

In short: over-fetch → rank → slice the top-k. That one line is how the course's retrieval keeps the best results and throws the rest away.

Try it
pythonscores = [0.2, 0.9, 0.5]
sorted(scores)                 # [0.2, 0.5, 0.9] — ascending (new list)
sorted(scores, reverse=True)   # [0.9, 0.5, 0.2] — descending

# sort a list of dicts by a field — the key is a function of each item
chunks = [{"id":"a","score":0.2}, {"id":"b","score":0.9}]
ranked = sorted(chunks, key=lambda c: c["score"], reverse=True)
top3 = ranked[:3]              # take the best 3 (the retrieval funnel)

# sort scores but remember original positions (argsort-style)
order = sorted(range(len(scores)), key=lambda i: -scores[i])   # [1,2,0]

# other handy methods
scores.index(0.9)              # 1  — position of a value
[1,2,2,3].count(2)         # 2  — how many times it appears
list(reversed([1,2,3]))       # [3,2,1]
▶ How this works

Ranking things — search hits, scores, chunks — is everywhere in this course, and sorted() is the tool. The star feature is key=, which lets you sort by any field of each item, not just the item itself.

  1. sorted(scores) returns a new list in ascending order (the original is untouched). Adding reverse=True flips it to descending — biggest first.
  2. sorted(chunks, key=lambda c: c["score"], reverse=True) sorts a list of dicts by their "score". The lambda is a tiny throwaway function that, given a chunk c, returns the value to sort on. ranked[:3] then takes the top 3.
  3. sorted(range(len(scores)), key=lambda i: -scores[i]) sorts the positions 0,1,2 by their score (negated, so highest first) — useful when you need to remember where each value came from.
  4. .index(0.9) finds where a value sits, .count(2) tallies how many times it appears, and reversed(...) walks a list backwards.

What the output means: The comments show each result: [0.2, 0.5, 0.9] ascending, the descending flip, the ranked dicts, and the position list [1,2,0].

Try this: Remove reverse=True from the ranked line and see the worst-scoring chunk come first instead. The key=lambda ... + [:k] pair is exactly how the course's retrieval "top-k" works.

🔗 Used in the coursesorted(scored, reverse=True) ranks runbooks in the Lab 8c retriever. sorted(range(len(scores)), key=lambda j:-scores[j])[:k] is the exact BM25 top-k in Ch 3 Lab 3.5. The [:k] slice is the over-fetch→top-k retrieval funnel.

9 · enumerate & zip essential

Watch a beginner loop when they also want the position, and you'll see the tell-tale move: they create a counter variable, i = 0, then remember to write i += 1 at the bottom of the loop — and half the time they forget, or increment it in the wrong place, and the numbering goes sideways. Python has a cleaner way, and once you meet it you'll never hand-roll a counter again. The two helpers in this section, enumerate and zip, exist to kill two of the fiddliest, most error-prone loop patterns there are.

enumerate hands you the position and the item together. Instead of "loop over the items and separately track where you are", you write for i, item in enumerate(things): and Python gives you both, correctly, every time. Picture a teacher walking down a row of students calling out "one, two, three" — the count and the student arrive as a pair, automatically. A lovely touch: enumerate(things, 1) starts the count at 1 instead of 0, which is exactly what you want when the numbers are for humans to read — like the [1] [2] [3] citations RAG builds so the model (and the reader) can point at a source.

zip walks two lists in lockstep. When you have parallel lists — names = ["cpu","mem"] and values = [17, 96] — the clumsy way is to loop over indices and write names[i] and values[i] everywhere, which is noisy and easy to mis-index. zip zips them together like a zipper's two rows of teeth meshing: for name, val in zip(names, values): pairs the first with the first, the second with the second, and hands you both. It's also the tidiest way to build a dict from two lists — dict(zip(names, values)) — which you'll do whenever keys and values arrive separately.

The one surprise to file away: zip stops at the shortest list. If names has three entries and values has two, zip gives you two pairs and silently drops the extra name — no error. That's occasionally handy but more often a bug waiting to happen, so when you zip two lists that are supposed to be the same length, it's worth a quick check that they actually are. Beyond that, these two are pure quality-of-life: they make loops read like sentences instead of index arithmetic.

zip(names, values) — pair first-with-first, stop at the shortest "cpu" "mem" "disk" 17 96 cpu = 17 mem = 96 no pair → dropped dict(zip(...)) turns the pairs into {'cpu':17, 'mem':96} Two lists meshed like a zipper. Each name pairs with the value in the same position; a leftover name with no matching value ("disk") is silently dropped — zip stops at the shorter list.
🗺️ How to read this diagram

Read across each row: the blue box on the left (a name) and the amber box on the right (a value) meet in the middle green box, which is the pair zip hands you for that step.

  • The blue arrows show the first name pairing with the first value, the second with the second — position-by-position, like a zipper's teeth meshing.
  • The green boxes (cpu = 17, mem = 96) are the pairs your loop actually receives via for name, val in zip(...).
  • The bottom row is the surprise: "disk" has no value opposite it, so the dashed arrow ends in a red box — that leftover is silently dropped because zip stops at the shorter list.

In short: zip meshes two lists position-by-position and stops at the shorter one — great for building dict(zip(names, values)), but check the lengths match when they're supposed to.

Try it
pythonchunks = ["first", "second", "third"]

# enumerate: index + item. start=1 for human-friendly numbering.
for i, text in enumerate(chunks, 1):
    print(f"[{i}] {text}")      # [1] first  [2] second  [3] third

# this is how RAG builds its numbered, citable context:
context = "\n".join(f"[{i}] {c}" for i, c in enumerate(chunks, 1))

# zip: iterate two (or more) lists together
names = ["cpu", "mem"]
values = [17, 96]
for name, val in zip(names, values):
    print(f"{name}={val}")     # cpu=17  mem=96

dict(zip(names, values))         # {'cpu': 17, 'mem': 96} — build a dict from two lists
▶ How this works

Two loop helpers you'll use constantly. enumerate hands you the position number alongside each item (great for numbering citations), and zip walks two lists in step, pairing them up.

  1. for i, text in enumerate(chunks, 1): loops over chunks but also gives i, a counter that starts at 1 (the 1 argument) instead of the usual 0 — so the numbering reads naturally for humans.
  2. "\n".join(f"[{i}] {c}" for i, c in enumerate(chunks, 1)) is how RAG builds a numbered, citable block of context: number each chunk, then glue them together with newlines.
  3. for name, val in zip(names, values): pairs the first name with the first value, the second with the second, and so on — one pass over both lists together.
  4. dict(zip(names, values)) turns two parallel lists into a dict, keys from the first and values from the second: {'cpu': 17, 'mem': 96}.

What the output means: The first loop prints [1] first, [2] second, [3] third; the zip loop prints cpu=17 and mem=96; and the final line builds that dict from the two lists.

Try this: Drop the , 1 from the first enumerate and re-run — numbering now starts at [0]. That second argument is just where the counter begins.

🔗 Used in the courseenumerate(hits, 1) numbers the citations in Ch 3 Lab 3.4 (f"[{i}] (source: {c['source']})") and the capstone context builder. enumerate also drives the RRF rank loop in Lab 3.5.

10 · defaultdict & Counter — grouping & counting intermediate

Two tasks come up so often that Python ships purpose-built tools for them, and most people re-invent both — badly — before discovering they exist. The first is counting: "how many ERRORs, how many WARNs?" The second is grouping: "collect all the messages under each service." You can do both with a plain dict, but the hand-rolled version is clumsy and has a lurking bug. This section is about the two tools from the collections module — Counter and defaultdict — that make each a clean one-liner.

Consider grouping the naive way. You want {"api": ["boom", "again"], "web": ["503"]}, so you loop and write groups[svc].append(msg) — and it crashes the very first time it meets a new service, because that key doesn't exist yet and you can't append to a value that isn't there. So you sprinkle in a guard: "if this service isn't in the dict yet, first set it to an empty list." It works, but every grouping loop you ever write carries that same tedious three-line dance. That repeated "check-if-missing-then-initialise" is precisely the friction these tools remove.

defaultdict is a dict that auto-creates a starting value for any key you touch. Tell it defaultdict(list) and a missing key springs to life as an empty list [] the moment you reach for it — so by_service[svc].append(msg) just works, first time and every time, no guard needed. Think of it as a filing cabinet that quietly opens a fresh empty folder whenever you file under a new label. Use defaultdict(int) and missing keys start at 0 (perfect for counting), defaultdict(float) starts at 0.0 (perfect for accumulating scores, exactly how the course's RRF fusion sums ranks).

Counter is a specialised dict that does the whole tally in one call. Hand it any list and Counter(levels) returns each distinct value with its count already computed; .most_common(3) then hands back the top three as ranked pairs, saving you a sort. The mistake to avoid is doing this the long way — writing your own count-and-sort loop when Counter already exists and is both faster and clearer. The rule of thumb: the instant you catch yourself writing "if the key is missing, initialise it, then add to it", stop — defaultdict or Counter is the tool you actually want.

appending under a brand-new key — plain dict vs defaultdict plain dict {} groups["api"].append("boom") KeyError: 'api' key doesn't exist yet ✗ defaultdict(list) groups["api"].append("boom") {"api": ["boom"]} missing key auto-starts as [] ✓ Auto-create beats check-then-init. The same .append crashes on a plain dict's missing key but works on a defaultdict(list), which quietly creates an empty list the moment you touch a new key.
🗺️ How to read this diagram

Compare the two panels — the same line of code sits at the top of each, and the arrow below shows what happens when the key "api" doesn't exist yet.

  • The left panel is a plain {}: appending to a key that was never set falls into the red box — a KeyError, because you can't append to a value that isn't there.
  • The right panel is a defaultdict(list): touching the missing key first springs an empty list [] into existence, so the append lands in the green box giving {"api": ["boom"]}.
  • The payoff is that the right side needs no "if key missing" guard — use defaultdict(int) for counting (starts at 0) or defaultdict(float) for accumulating scores (starts at 0.0).

In short: a plain dict crashes on an unseen key; a defaultdict auto-creates the starting value, turning the tedious check-then-init dance into one clean line.

Try it
pythonfrom collections import defaultdict, Counter

# Counter: tally occurrences (e.g. error types in logs)
levels = ["ERROR", "INFO", "ERROR", "WARN", "ERROR"]
counts = Counter(levels)         # Counter({'ERROR': 3, 'INFO': 1, 'WARN': 1})
counts["ERROR"]                  # 3
counts.most_common(2)           # [('ERROR', 3), ('INFO', 1)]

# defaultdict: group items without checking "does the key exist yet?"
logs = [{"svc":"api","msg":"boom"}, {"svc":"api","msg":"again"},
        {"svc":"web","msg":"503"}]
by_service = defaultdict(list)   # missing keys auto-start as []
for l in logs:
    by_service[l["svc"]].append(l["msg"])
# {'api': ['boom','again'], 'web': ['503']}

# the scoring pattern from RRF uses the same idea:
scores = defaultdict(float)
for rank, cid in enumerate(["a","b","a"]):
    scores[cid] += 1 / (60 + rank)   # no "if cid in scores" needed
▶ How this works

Two helpers from the collections module make grouping and counting effortless. Counter tallies how often each value appears; defaultdict is a dict that auto-creates a starting value for any new key, so you skip fiddly "does this key exist yet?" checks.

  1. Counter(levels) counts each distinct value in the list in one go, giving Counter({'ERROR': 3, 'INFO': 1, 'WARN': 1}). counts.most_common(2) returns the two most frequent as (value, count) pairs.
  2. defaultdict(list) makes a dict where any missing key starts as an empty list [] automatically, so by_service[l["svc"]].append(...) just works even the first time a service is seen.
  3. The loop groups each log message under its service, ending up with {'api': ['boom','again'], 'web': ['503']}.
  4. defaultdict(float) starts missing keys at 0.0, so scores[cid] += ... can accumulate straight away — no "if cid in scores" guard needed. This is the exact pattern behind the course's RRF scoring.

What the output means: counts tallies the levels (3 errors), by_service groups messages per service, and scores accumulates a running number per id.

Try this: Replace defaultdict(list) with a plain {} and re-run — the first .append now raises KeyError because the key doesn't exist yet. That crash is precisely what defaultdict saves you from.

🔗 Used in the courseThe RRF fusion in Ch 3 Lab 3.5 accumulates into a score dict exactly like this. Counting alert severities / error levels is the natural way to summarize the monitoring data. Aggregating eval results by outcome uses the same grouping.

11 · Robust JSON — handling messy real data intermediate

Here's a 2 a.m. story that every agent builder eventually lives. Your agent has run flawlessly in testing for a week. Then a model returns tool arguments with a stray trailing comma, or an API omits a field that "always" used to be there, and your beautiful pipeline dies with a stack trace — mid-conversation, in front of a user. Nothing was wrong with your logic. The world just handed you data that wasn't as clean as your tests. This section is the small set of habits that keep that night from happening.

The core mindset shift is this: in §6 you learned to parse JSON assuming it's valid. In the real world, you must assume it might not be. Data arriving from a model, a network call, or a file you didn't write is untrusted — it can be malformed (not valid JSON at all) or merely incomplete (valid, but missing the key you need). Treating every external input as guilty-until-proven-innocent is the difference between a toy script and code you'd let run unattended.

Two techniques handle almost everything. The first is guarding the parse: wrap json.loads(text) in a try / except json.JSONDecodeError so that garbage input returns a tidy None (or an error object) instead of crashing the whole program. Think of it as a seatbelt — it doesn't stop the pothole, it stops the pothole from throwing you through the windscreen. The second is digging safely through nested data with chained .get() calls: data.get("status", {}).get("phase", "Unknown") reaches two levels down and, if either level is missing, calmly falls back to a default rather than raising a KeyError. Each .get with an empty-dict default keeps the next step alive.

One habit here quietly saves real money, and it surprises people, so it's worth calling out: when you serialise JSON that goes into a cached prompt prefix, pass sort_keys=True. Why? Prompt caching keys on the exact bytes of the prefix, and Python doesn't guarantee dict key order matches between runs — so an unsorted json.dumps() can emit the same data in a different order, change the bytes, and silently miss the cache you were paying to hit. Sorting the keys makes the output deterministic: identical data always produces identical text. It's a one-word habit (covered again in Ch 6) with an outsized payoff — the recurring lesson of this section is that robustness is a collection of small, cheap habits, not one heroic fix.

treat external input as guilty until proven valid — guard the parse untrusted text model / API / file try: json.loads(text) except JSONDecodeError valid → a dict use it safely broken → None caught, no crash then dig safely: data.get("status", {}).get("phase", "Unknown") — never a KeyError A seatbelt for parsing. Wrapping json.loads in try/except JSONDecodeError turns malformed input into a tidy None instead of a stack trace — then chained .get()s survive missing keys too.
🗺️ How to read this diagram

Follow the arrows left to right: untrusted text enters the guard in the middle, which then branches depending on whether the text was valid JSON.

  • The white box on the left is the untrusted input — anything from a model, a network call, or a file you didn't write, which you must assume could be malformed.
  • The purple box is the guard: json.loads wrapped in try / except json.JSONDecodeError. It is the single checkpoint every external input passes through.
  • Valid input takes the solid arrow to the green box (a usable dict); broken input takes the dashed arrow to the red box, where the except returns None instead of crashing. The line below adds the second habit — chained .get() with defaults so missing keys never raise a KeyError.

In short: one guard around the parse plus safe .get() chains turn "the world handed me messy data" from a 2 a.m. crash into a calm fallback.

Try it
pythonimport json

# 1. guard the parse — model/tool output isn't always valid JSON
def safe_parse(text):
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return None          # caller decides what to do

# 2. dig safely through nested data that might be missing keys
data = {"status": {"phase": "Running"}}
phase = data.get("status", {}).get("phase", "Unknown")   # 'Running', never KeyError

# 3. pretty-print for logs / debugging; sort keys for stable output
print(json.dumps(data, indent=2, sort_keys=True))

# 4. write JSON to a file
with open("out.json", "w") as f:
    json.dump(data, f, indent=2)
▶ How this works

Real JSON from a model or an API can be broken or missing fields. These techniques stop your agent from crashing on messy data — a running theme in production code.

  1. safe_parse wraps json.loads(text) in try / except. If the text isn't valid JSON, Python raises json.JSONDecodeError; the except catches it and returns None instead of letting the whole program die.
  2. data.get("status", {}).get("phase", "Unknown") digs two levels down safely: if "status" is missing it falls back to an empty dict {}, so the second .get still works and yields "Unknown" — never a KeyError.
  3. json.dumps(data, indent=2, sort_keys=True) pretty-prints with indentation and puts keys in a fixed order — readable for logs and stable for caching.
  4. The final with open("out.json", "w") as f: plus json.dump(data, f, indent=2) writes the data out to a file ("w" means "write").

What the output means: safe_parse returns a dict on good input or None on bad; phase is 'Running' here; and the dumps/dump lines print and save neatly formatted JSON.

Try this: Call safe_parse("not json!") and confirm you get None rather than a crash. Then delete the "status" key from data and re-run the phase line — it still returns 'Unknown' instead of erroring.

Why sort_keys=True matters for cachingCh 6 warns that an unsorted json.dumps() in a cached prompt prefix silently breaks prompt caching — because the key order can vary between runs, changing the bytes. Sorting makes the output deterministic. Small habit, real cost savings.
🔗 Used in the courseGuarded parsing is the Ch 4 gotcha ("always parse tool input with json.loads"). Safe nested access (.get(...,{}).get(...)) is how the "go real" kubectl -o json parsing survives missing fields (Lab 8d). json.dumps(..., sort_keys=True) is the Ch 6 caching fix.

Worked example · a log analyzer putting it together

Individual skills are one thing; the real test is whether they combine into something useful. So far each section taught one tool in isolation — a comprehension here, a Counter there. That's how you learn, but it's not how you work. Real code weaves several of these together in a handful of lines to answer a genuine question. This worked example does exactly that: it takes a pile of raw log dicts and answers "which services are erroring, and what are the most common errors?" — the sort of thing a DevOps agent asks a hundred times a day.

Think of it like cooking a dish after learning the knife skills separately. You already know how to filter (a comprehension), how to group and count (defaultdict), how to rank (sorted with a key), how to tally the top few (Counter.most_common), and how to format output cleanly (f-strings). The value of a worked example is seeing the order they go in and how each one's output becomes the next one's input — the flow, not the ingredients.

Watch for the shape of the pipeline as you read, because it's a pattern you'll reuse far beyond logs: filter down to what matters → group and count → rank → report. Almost every "summarize this data" task in the course follows that same four-step spine, whether it's logs, alerts, evaluation results, or retrieval hits. The mistake to avoid is trying to do everything in one tangled loop; notice instead how the code keeps each step small, named, and separate, so you could read, test, or change any one of them without disturbing the others. That separation is what makes the difference between a clever script and code a teammate can maintain.

the reusable "summarize this data" spine — each step feeds the next raw logs list of dicts filter comprehension group+count defaultdict rank sorted(key=) report Counter filter down → group & count → rank → report One spine, four tools. The analyzer chains a comprehension (filter), a defaultdict (group+count), sorted (rank), and Counter (report) — each step's output is the next step's input.
🗺️ How to read this diagram

Read left to right, following the blue arrows — this is the order the code runs, and each box names both the step and the Python tool that does it.

  • The white raw logs box is the input: a plain list of log dicts. The blue box filter keeps only what matters (the ERROR entries) with a comprehension.
  • The purple group + count box tallies per service with a defaultdict, and the amber rank box orders them worst-first with sorted(key=...).
  • The green report box turns the result into a human-readable summary with Counter.most_common and f-strings — the arrows show each step handing its output to the next.

In short: filter → group & count → rank → report. Keeping the four steps separate is what makes the analyzer easy to read, test, and change.

Worked example
log_analyzer.pyfrom collections import Counter, defaultdict

LOGS = [
    {"svc": "checkout-api", "level": "ERROR", "msg": "auth failed"},
    {"svc": "checkout-api", "level": "ERROR", "msg": "startup probe failed"},
    {"svc": "web-frontend", "level": "ERROR", "msg": "upstream 503"},
    {"svc": "web-frontend", "level": "INFO",  "msg": "served /"},
]

def summarize(logs):
    errors = [l for l in logs if l["level"] == "ERROR"]        # filter
    by_svc = defaultdict(int)                                     # group-count
    for e in errors:
        by_svc[e["svc"]] += 1
    ranked = sorted(by_svc.items(), key=lambda kv: -kv[1])       # worst first
    return errors, ranked

def main():
    errors, ranked = summarize(LOGS)
    print(f"{len(errors)} errors across {len(ranked)} services\n")
    for svc, n in ranked:
        print(f"  {svc:16} {n} error(s)")
    print("\ntop error messages:")
    for msg, n in Counter(e["msg"] for e in errors).most_common(3):
        print(f"  {n}x  {msg}")

if __name__ == "__main__":
    main()
3 errors across 2 services

  checkout-api     2 error(s)
  web-frontend     1 error(s)

top error messages:
  1x  auth failed
  1x  startup probe failed
  1x  upstream 503
▶ How this works

This ties the whole chapter together into a small tool the DevOps agent would really use: it takes a list of log dicts and answers "which services are erroring, and what are the most common errors?" using a filter, a group-count, sorting, and a Counter.

  1. errors = [l for l in logs if l["level"] == "ERROR"] is a filtering comprehension that keeps only the ERROR-level log dicts.
  2. by_svc = defaultdict(int) starts each service's count at 0, and the loop adds 1 per error, tallying errors per service.
  3. sorted(by_svc.items(), key=lambda kv: -kv[1]) sorts those (service, count) pairs by count, highest first (the - flips ascending into descending) — so the worst offender leads.
  4. main() prints a summary line, one line per service ({svc:16} pads the name to 16 characters so columns line up), then Counter(...).most_common(3) lists the three most frequent messages. The if __name__ == "__main__": guard runs main() only when you execute this file directly.

What the output means: The console block shows the result: 3 errors across 2 services, then checkout-api with 2 and web-frontend with 1, then the top error messages each seen once.

Try this: Add another ERROR log for web-frontend and re-run — watch the ranking flip so web-frontend climbs. The sort key does that automatically.

Exercises advanced

Exercise P2.1 — filter broken pods

Context: Filtering by a negative condition — everything not in a good state — is a routine comprehension, here picking out pods that aren't Running.

Your task: Given a list of pod dicts (each with name and status), use a comprehension to build a list of names whose status is not Running.

Requirements:

  • A list comprehension over the pods
  • Keep names where status is not Running
  • Result is a list of names, not full dicts

💡 Hint: Filter with if p["status"] != "Running" and project just p["name"] in the comprehension.

Solution
Setup to run this snippet
pods = [
    {"id": "x1", "text": "demo one", "name": "api-7f9c", "status": "Running"},
    {"id": "x2", "text": "demo two", "name": "worker-2d", "status": "CrashLoopBackOff"},
]
broken = [p["name"] for p in pods if p["status"] != "Running"]

Exercise P2.2 — build a message list

Context: A conversation history is just a list of role/content dicts that you append to over time — the data structure every chat API consumes.

Your task: Create a messages list with one user dict and one assistant dict (each with role and content), then append a second user message.

Requirements:

  • A list of message dicts
  • Each has a role and content
  • One user and one assistant to start
  • Append a second user message

💡 Hint: Start with a two-element list of {"role": ..., "content": ...} dicts and .append() the next user turn.

Exercise P2.3 — load & query JSON

Context: Loading nested JSON from a file and reaching into it is the everyday shape of reading state — here pulling the last log line of the first pod.

Your task: Save the nested state from §3 as state.json, load it with json.load, and print the last log line of the first pod.

Requirements:

  • Write the state to state.json
  • Load it back with json.load
  • Index into the first pod
  • Print its last log line

💡 Hint: After json.load, index the pods list [0] and its logs [-1] to reach the last line.

Solution
import json
state = json.load(open("state.json"))
print(state["pods"][0]["logs"][-1])

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

Group anagrams (classic)

Words are anagrams if their sorted letters match. Use that sorted string as a dict key — O(n·k log k).

pythonfrom collections import defaultdict
def group_anagrams(words):
    groups = defaultdict(list)
    for w in words:
        groups["".join(sorted(w))].append(w)   # key = canonical form
    return list(groups.values())
▶ How this works

Two words are anagrams if they use the same letters (e.g. "eat" and "tea"). The neat trick: if you sort the letters of each word, anagrams all produce the same sorted string — so that sorted string makes a perfect grouping key.

  1. groups = defaultdict(list) makes a dict where each new key auto-starts as an empty list, ready to collect words.
  2. "".join(sorted(w)) sorts word w's letters and glues them back into a string — the word's canonical form. "eat" and "tea" both become "aet".
  3. groups[...].append(w) files each original word under that canonical key, so all anagrams land in the same bucket.
  4. return list(groups.values()) hands back just the buckets — each a list of words that are anagrams of each other.

What the output means: For ["eat","tea","tan","ate"] you'd get groups like [['eat','tea','ate'], ['tan']]. The O(n·k log k) cost comes from sorting each of n words of length k.

Try this: Trace "listen" and "silent" by hand — both sort to "eilnst", so they'd share a bucket. Using a computed value as a dict key is a pattern worth remembering for interviews.

First unique character (classic)

Count with a Counter in one pass, then scan for the first char with count 1 — O(n).

pythonfrom collections import Counter
def first_uniq(s):
    freq = Counter(s)
    for i, c in enumerate(s):
        if freq[c] == 1:
            return i
    return -1
▶ How this works

Find the first character in a string that appears exactly once, and return its position. The efficient approach is two passes: count every character first, then walk the string once more looking for the first with a count of 1.

  1. freq = Counter(s) makes one pass over the string and records how many times each character appears.
  2. for i, c in enumerate(s): walks the string again, giving both the position i and the character c.
  3. if freq[c] == 1: checks the pre-computed count; the first character whose count is 1 is unique, so return i hands back its index immediately.
  4. return -1 after the loop is the fallback for when no character is unique — a common convention for "not found".

What the output means: For "leetcode" it returns 0 (the first l is unique); for "aabb" it returns -1. It's O(n) — two straight passes, no nested loops.

Try this: Run it on "aabbc" — the answer is 4, the position of c. Counting first, then scanning, avoids re-counting for every character (which would be the slow O(n²) way).

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Filter ERROR logs from a list of dictsBeginner

Context: Filtering a list of dicts with a comprehension is the chapter's favourite one-liner — the everyday move for picking the rows you care about out of structured data.

Your task: Given the lesson's LOGS list of {svc, level, msg} dicts, use a list comprehension to keep only the ERROR entries and print how many there are.

Requirements:

  • A list comprehension over the logs
  • Keep only entries whose level is ERROR
  • Print the count of errors

💡 Hint: One filtering comprehension [l for l in LOGS if l["level"] == "ERROR"], then len() it.

Show solution

A filtering comprehension over dicts — the chapter's favourite one-liner. Runnable:

LOGS = [
    {"svc": "checkout-api", "level": "ERROR", "msg": "auth failed"},
    {"svc": "web-frontend", "level": "INFO",  "msg": "served /"},
    {"svc": "web-frontend", "level": "ERROR", "msg": "upstream 503"},
]
errors = [l for l in LOGS if l["level"] == "ERROR"]
print(len(errors), "errors")   # 2 errors
Exercise 2 · Count errors per serviceIntermediate

Context: Grouping and counting is the core of any log analyzer. A defaultdict tallies per key and sorted puts the worst offenders first.

Your task: Group the ERROR logs by svc and count them, then print worst-first, using collections.defaultdict and sorted.

Requirements:

  • A defaultdict(int) counts per service
  • Iterate the errors incrementing the per-service count
  • Sort services by count descending
  • Print each service with its error count

💡 Hint: defaultdict(int) avoids key-existence checks; sort the items with key=lambda kv: -kv[1] for worst-first.

Show solution

The lesson's group-count + sort. Runnable (stdlib only):

from collections import defaultdict

errors = [
    {"svc": "checkout-api", "msg": "auth failed"},
    {"svc": "checkout-api", "msg": "probe failed"},
    {"svc": "web-frontend", "msg": "upstream 503"},
]
by_svc = defaultdict(int)
for e in errors:
    by_svc[e["svc"]] += 1
ranked = sorted(by_svc.items(), key=lambda kv: -kv[1])
for svc, n in ranked:
    print(f"{svc:16} {n} error(s)")
Exercise 3 · Top-N messages with CounterAdvanced

Context: For a top-N report, Counter.most_common does the ranking, and feeding it a generator expression avoids materializing an intermediate list.

Your task: Report the three most common error messages using collections.Counter.most_common, feeding it a generator expression over the messages.

Requirements:

  • Build a Counter from a generator expression
  • No intermediate list of messages
  • Use most_common(3)
  • Print each message with its count

💡 Hint: Counter(e["msg"] for e in errors) — the generator (no brackets) keeps it lazy — then .most_common(3).

Show solution

Counter + a generator expression, straight from the analyzer. Runnable:

from collections import Counter

errors = [{"msg": m} for m in
          ["auth failed", "upstream 503", "auth failed", "timeout", "auth failed"]]
top = Counter(e["msg"] for e in errors).most_common(3)
for msg, n in top:
    print(f"{n}x {msg}")   # 3x auth failed, then the rest
Exercise 4 · Parse a raw log line with regexExpert

Context: Real logs arrive as text, not tidy dicts. Just-enough regex with named groups turns each line into a dict, and non-matching lines are skipped rather than crashing.

Your task: Given lines like 2026-09-04 ERROR checkout-api | auth failed, use re to extract (level, svc, msg) into dicts, skipping lines that don't match.

Requirements:

  • A regex with named groups for level, svc, and msg
  • Match each line and skip non-matches
  • Collect the matches as dicts (via groupdict)
  • A garbage line produces no record

💡 Hint: Compile a named-group pattern and use m.groupdict() on a match; guard with if m: so a non-matching line is simply skipped.

Show solution

Uses the chapter's 'just enough regex' with a named-group pattern. Runnable:

import re

LINE = re.compile(r"^\S+ (?P<level>\w+) (?P<svc>[\w-]+) \| (?P<msg>.+)$")

raw = [
    "2026-09-04 ERROR checkout-api | auth failed",
    "garbage line that does not match",
    "2026-09-04 INFO web-frontend | served /",
]
records = []
for line in raw:
    m = LINE.match(line)
    if m:
        records.append(m.groupdict())
print(records)
Exercise 5 · Round-trip through JSON safelyProfessional

Context: Interchange happens over JSON, and real inputs are sometimes malformed. Robust JSON round-trips the data and guards the parse so bad input doesn't crash the analyzer.

Your task: Read logs from a JSON string, summarize error counts, and write the summary back to JSON — handling a malformed input string without crashing.

Requirements:

  • Parse the JSON input
  • Catch json.JSONDecodeError and return an error summary
  • Count levels with a Counter
  • Serialize the summary back to JSON
  • Malformed input returns an error object, not a crash

💡 Hint: Wrap json.loads in try/except for JSONDecodeError; on success, count and json.dumps the summary.

Show solution

json load/dump + a try/except guard for messy real data. Runnable:

import json
from collections import Counter

def summarize_json(text):
    try:
        logs = json.loads(text)
    except json.JSONDecodeError as e:
        return json.dumps({"error": f"bad json: {e.msg}"})
    counts = Counter(l["level"] for l in logs)
    return json.dumps({"by_level": dict(counts)}, sort_keys=True)

good = '[{"level":"ERROR"},{"level":"INFO"},{"level":"ERROR"}]'
print(summarize_json(good))       # {"by_level": {"ERROR": 2, "INFO": 1}}
print(summarize_json('{not json'))  # {"error": "bad json: ..."}
Exercise 6 · A reusable log-analyzer functionIndustry scenario

Context: The chapter comes together as one reusable function returning structured data — the exact shape a DevOps agent would call to summarize logs.

Your task: Package the chapter into one summarize(logs) returning error count, per-service ranking, and top-3 messages, proven with a small assertion.

Requirements:

  • Filter to ERROR entries
  • Rank services by error count
  • Compute the top-3 messages
  • Return a structured dict (not printed)
  • Assert the counts/ranking on a sample input

💡 Hint: Compose the earlier rungs — comprehension filter, defaultdict ranking, Counter top-3 — into one function that returns a dict, then assert its fields.

Show solution

The full P2 worked example, returned as structured data instead of printed. Runnable:

from collections import Counter, defaultdict

def summarize(logs):
    errors = [l for l in logs if l["level"] == "ERROR"]
    by_svc = defaultdict(int)
    for e in errors:
        by_svc[e["svc"]] += 1
    ranked = sorted(by_svc.items(), key=lambda kv: -kv[1])
    top = Counter(e["msg"] for e in errors).most_common(3)
    return {"error_count": len(errors), "by_service": ranked, "top_messages": top}

LOGS = [
    {"svc": "checkout-api", "level": "ERROR", "msg": "auth failed"},
    {"svc": "checkout-api", "level": "ERROR", "msg": "probe failed"},
    {"svc": "web-frontend", "level": "ERROR", "msg": "upstream 503"},
    {"svc": "web-frontend", "level": "INFO",  "msg": "served /"},
]
r = summarize(LOGS)
assert r["error_count"] == 3 and r["by_service"][0] == ("checkout-api", 2)
print(r)

✓ Checkpoint — ready for P3 when you can…

  • Index, slice, and loop over a list; append to it.
  • Look up and safely .get() values from a dict; loop over .items().
  • Read a nested "list of dicts" one bracket at a time.
  • Write a filtering list comprehension and a dict comprehension.
  • Load JSON and read a file with with open(...).
  • Use re.findall/re.split for words, numbers, and paragraphs.

Knowledge check check yourself

✓ Knowledge check

What is the difference between msg["role"] and msg.get("role") on a dictionary, and when should you prefer .get()?

Show answer
Square-bracket lookup msg["role"] raises a KeyError if the key is missing, while msg.get("role") returns None (or a default you supply) instead of crashing. Prefer .get() — often with a default — when a key might not be present, like p.get("logs", []).
✓ Knowledge check

Read a comprehension like [p["name"] for p in pods if p["status"] != "Running"] in plain English — what does it build, and what does the if clause do?

Show answer
It builds a list of the name of each pod in pods, keeping only the pods whose status is not 'Running' — reading as '[this] for each [item] in [collection] if [condition]'. The if clause filters which items contribute to the result.
© 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