AI EngineeringZero to ProductionHome·About·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

An ordered, changeable collection. Square brackets. This is what messages, content, tools, and search results all are.

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

Key → value pairs. Curly braces. Every message, tool result, and API object is a dict.

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

Real data is lists inside dicts inside lists. The trick is to read it one bracket at a time.

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

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

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.

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

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

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 find patterns in text. You need only a little for this course — mainly finding words and simple patterns in logs.

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

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.

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

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.

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

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

Real JSON (from a tool, an API, a file) can be malformed or missing fields. Two techniques keep your agent from crashing on it.

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

Lists, dicts, comprehensions, Counter, defaultdict, sorting, and f-string formatting — combined into a tool the DevOps agent would actually use to summarize logs.

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