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.
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
An ordered, changeable collection. Square brackets. This is what messages, content, tools, and search results all are.
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)
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.
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-1is the last item.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.pods.append("db")adds a new item onto the end of the list, changing it in place."web-frontend" in podschecks whether an item is present and gives backTrueorFalse.for p in pods:visits each item in turn; the indentedprint(p)runs once per item, withpholding 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".
messages = [...] 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
Key → value pairs. Curly braces. Every message, tool result, and API object is a dict.
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)
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.
msg["role"]looks up the value stored under the key"role"and returns'user'. If the key isn't there, this form crashes with aKeyError.msg.get("missing")is the safe look-up: if the key is absent it returnsNoneinstead of crashing.msg.get("ns", "staging")lets you supply a fallback value to use when the key is missing.msg["content"] = "updated"changes the value for an existing key (or adds it if new).msg.keys()lists all the keys.for key, val in msg.items():loops over both parts of each pair at once, so inside the loopkeyandvalhold 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.
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", []).{"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
Real data is lists inside dicts inside lists. The trick is to read it one bracket at a time.
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'
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.
state["pods"]steps into the outer dict and hands you the list of pod dicts.state["pods"][0]adds[0]to grab the first pod from that list — itself a dict.state["pods"][0]["name"]steps one more level in, into that pod's"name"key, giving'checkout-api'.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.
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
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
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).
point = (0.1, 0.9)makes a tuple — handy for a fixed pair like x/y coordinates.chunk, score = ("faq.md#0", 0.87)is unpacking: Python hands the first part tochunkand the second toscorein one line. You'll see this a lot when a function returns two things.seen = {"a", "b"}makes a set.seen.add("a")does nothing visible because"a"is already there — sets silently ignore duplicates."a" in seenis 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.
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
A comprehension builds a list (or dict) in one line. It reads: "[this] for each [item] in [collection] if [condition]". You'll see these constantly.
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
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.
- The long way starts with an empty list
names = []and appendsp["name"]inside aforloop — three lines to collect the names. names = [p["name"] for p in pods]does exactly the same in one line: "takep["name"]for eachpinpods".- Adding
if p["status"] != "Running"filters: only pods that aren't Running contribute, sobrokenends up as['b']. {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 returnsTrue/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.
[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
JSON is text that maps 1:1 to Python lists+dicts. It's how fixtures, configs, and API payloads are stored. Files are read with open().
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()
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.
import jsonloads Python's built-in JSON toolkit.with open("fixtures.json") as f:opens the file andjson.load(f)reads it straight into Python dicts/lists. Thewithblock auto-closes the file when done.json.loads('{"replicas": 3}')parses a JSON string (note thes— "load string") into a dict.json.dumps({...})goes the other way, turning Python into a JSON string.- The last
with open("crashloop.md") as f:plusf.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.
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.json.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 find patterns in text. You need only a little for this course — mainly finding words and simple patterns in logs.
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.
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.
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".re.findall(r"\d+", ...)uses\d+("one or more digits") to pull the numbers['3','7']out of the text.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.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);\bis 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".
re.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
Beyond append, you'll sort results by score, take the top-k, and reverse. sorted() with a key is the single most useful tool for ranking — it's how re-ranking and RRF work.
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]
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.
sorted(scores)returns a new list in ascending order (the original is untouched). Addingreverse=Trueflips it to descending — biggest first.sorted(chunks, key=lambda c: c["score"], reverse=True)sorts a list of dicts by their"score". Thelambdais a tiny throwaway function that, given a chunkc, returns the value to sort on.ranked[:3]then takes the top 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..index(0.9)finds where a value sits,.count(2)tallies how many times it appears, andreversed(...)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.
sorted(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
Two loop helpers you'll use constantly: enumerate gives you the index alongside the item (for numbering citations), and zip walks two lists in parallel.
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
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.
for i, text in enumerate(chunks, 1):loops overchunksbut also givesi, a counter that starts at 1 (the1argument) instead of the usual 0 — so the numbering reads naturally for humans."\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.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.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.
enumerate(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 tools from the collections module that turn fiddly grouping/counting loops into clean code — exactly what you need when aggregating log lines, alerts, or eval results.
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
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.
Counter(levels)counts each distinct value in the list in one go, givingCounter({'ERROR': 3, 'INFO': 1, 'WARN': 1}).counts.most_common(2)returns the two most frequent as(value, count)pairs.defaultdict(list)makes a dict where any missing key starts as an empty list[]automatically, soby_service[l["svc"]].append(...)just works even the first time a service is seen.- The loop groups each log message under its service, ending up with
{'api': ['boom','again'], 'web': ['503']}. defaultdict(float)starts missing keys at0.0, soscores[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.
11 · Robust JSON — handling messy real data intermediate
Real JSON (from a tool, an API, a file) can be malformed or missing fields. Two techniques keep your agent from crashing on 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)
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.
safe_parsewrapsjson.loads(text)intry / except. If the text isn't valid JSON, Python raisesjson.JSONDecodeError; theexceptcatches it and returnsNoneinstead of letting the whole program die.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.getstill works and yields"Unknown"— never aKeyError.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.- The final
with open("out.json", "w") as f:plusjson.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.
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.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
Lists, dicts, comprehensions, Counter, defaultdict, sorting, and f-string formatting — combined into a tool the DevOps agent would actually use to summarize logs.
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
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.
errors = [l for l in logs if l["level"] == "ERROR"]is a filtering comprehension that keeps only the ERROR-level log dicts.by_svc = defaultdict(int)starts each service's count at0, and the loop adds 1 per error, tallying errors per service.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.main()prints a summary line, one line per service ({svc:16}pads the name to 16 characters so columns line up), thenCounter(...).most_common(3)lists the three most frequent messages. Theif __name__ == "__main__":guard runsmain()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.
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())
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.
groups = defaultdict(list)makes a dict where each new key auto-starts as an empty list, ready to collect words."".join(sorted(w))sorts wordw's letters and glues them back into a string — the word's canonical form. "eat" and "tea" both become"aet".groups[...].append(w)files each original word under that canonical key, so all anagrams land in the same bucket.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.
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
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.
freq = Counter(s)makes one pass over the string and records how many times each character appears.for i, c in enumerate(s):walks the string again, giving both the positioniand the characterc.if freq[c] == 1:checks the pre-computed count; the first character whose count is 1 is unique, soreturn ihands back its index immediately.return -1after 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.
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
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)")
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
Counterfrom 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
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)
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.JSONDecodeErrorand 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: ..."}
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.splitfor words, numbers, and paragraphs.
Knowledge check check yourself
What is the difference between msg["role"] and msg.get("role") on a dictionary, and when should you prefer .get()?
Show answer
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", []).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
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.