chapter 01
Python by Example
Variables, collections, functions, classes - every idea as data in, data out.
1 / 9
Goal: by the end of this chapter you can read and write the Python you'll need for the rest of the book. Every concept is shown as data going in → transformation → data going out.
We are not covering "everything about Python." We're covering exactly what an agent-building researcher touches constantly: variables, collections, functions, classes, errors, and modules.
1.1 Variables and types - labelled boxes
A variable is a label stuck on a box holding a value. Python figures out the box's type for you.
This is different from languages like Java or C, where you must declare the type up front
(int age = 36;). Python looks at the value you assigned and infers the type at runtime - this
is called dynamic typing.
name = "Ada" age = 36 is_researcher = True ┌─────────┐ ┌──────┐ ┌──────────────┐ │ "Ada" │ │ 36 │ │ True │ └─────────┘ └──────┘ └──────────────┘ type: str type: int type: bool
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Why f-strings matter here: every prompt you send an LLM later is built by stitching
variables into a string. f"Answer this: {user_question}" is the exact same mechanic.
The three ways to build a string, and why f-strings win
You'll see three styles of string-building in the wild. All three produce the same output, but only one of them scales to real prompt-engineering code:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
f-strings can also hold small expressions, not just variable names - useful when you're formatting numbers coming back from an API (like a similarity score or a token count):
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Type coercion - Python won't silently mix strings and numbers
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: assuming Python will auto-convert numbers to strings like some looser languages do. It won't, on purpose - a silent conversion here is exactly the kind of thing that hides bugs. f-strings sidestep the whole issue, which is another reason to prefer them.
Mutable vs immutable - a distinction that will bite you later
Some types can be changed in place (mutable: lists, dicts, sets). Some can't (immutable: strings, ints, tuples). This matters the moment two variables point at the same object:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
a ──┐
├──▶ [1, 2, 3, 4] (one list, two labels pointing at it)
b ──┘This exact bug - two variables silently sharing one mutable list - shows up later when you
pass a conversation history around between functions. If you want an independent copy, say so
explicitly: b = a.copy() for lists, b = dict(a) for dicts.
1.2 Collections - the shapes your data will actually take
Almost everything an API gives you back (LM Studio included) is a list of dictionaries. So this section is the most important one in the chapter.
Lists - an ordered sequence
fruits = ["apple", "banana", "cherry"]
index: 0 1 2
┌───────┐ ┌────────┐ ┌────────┐
│ apple │ │ banana │ │ cherry │
└───────┘ └────────┘ └────────┘Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Lists support slicing - grabbing a sub-range without a loop. You'll use this constantly to trim conversation history down to "the last N messages" so you don't blow past a model's context window (Chapter 4 explains why that limit exists):
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Other list operations you'll reach for often:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Dictionaries - key/value pairs (this IS what JSON is)
user = {"name": "Ada", "role": "researcher"}
key: "name" ──▶ "Ada"
key: "role" ──▶ "researcher"Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Looking up a key that doesn't exist raises a KeyError. Since API responses often have
optional fields, you almost always want .get() instead of [...], because it lets you
supply a fallback instead of crashing:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Iterating a dictionary - three flavors, pick based on what you need:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Lists of dictionaries - the shape you'll see everywhere
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This exact structure is what you send to every LLM API in this book. If you understand "a list containing dictionaries," you already understand the data shape of an entire chat conversation.
Visual transformation - a list of dicts in, a list of names out:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
INPUT TRANSFORM OUTPUT
[{"name":"Ada","age":36}, [u["name"] for u in users] ["Ada", "Alan"]
{"name":"Alan","age":41}] ─────────────────────────▶List comprehensions can also filter, using an if at the end. Read it out loud as
"give me X for each item in the list, but only if the condition holds":
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Dict comprehensions follow the same idea, but build a dictionary instead of a list - handy for reshaping an API response into something easier to look up by key:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: writing a
forloop with an empty list and.append()when a comprehension would do it in one line. Neither is "wrong," but comprehensions are the idiom you'll see in every real codebase, including every example in this book from here on.
Tuples & sets (brief - you'll use these rarely)
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Tuples are used whenever a function needs to hand back more than one value at once - Python quietly packs multiple return values into a tuple for you:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Sets are useful the moment you need to deduplicate or do fast membership checks - checking
item in some_set is much faster than item in some_list once a collection gets large,
because a set is backed by a hash table instead of a linear scan.
1.3 Control flow
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Indentation is not a style choice in Python - it is the syntax. The block belonging to an
if, for, while, or def is defined entirely by how far its lines are indented. Mixing
tabs and spaces, or indenting inconsistently, causes an IndentationError before your program
even runs.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
for loops in Python iterate over items directly, not over index numbers like in C-style
languages. When you do need the index, use enumerate instead of manually tracking a
counter:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
break exits a loop entirely; continue skips to the next iteration without exiting. Both
show up in agent code when scanning a list of tool results looking for one specific thing:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: forgetting the colon
:at the end ofif,for,while, anddeflines. Python's error message ("SyntaxError: expected ':'") is usually clear enough to spot this quickly once you know to look for it.
1.4 Functions - the universal input → output box
This is the single most important shape in programming, and it's exactly how you'll think about calling an LLM later: inputs go in, a return value comes out.
┌─────────────────────────┐
in ──▶│ def add(a, b): │──▶ out
│ return a + b │
└─────────────────────────┘Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
A function that has no return statement doesn't error - it just implicitly returns None.
This trips people up when they expect a value back and silently get nothing:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Functions can also return multiple values at once, using the tuple-unpacking trick from section 1.2:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
*args and **kwargs - you WILL see these in API client code
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
**kwargs scoops up any extra named arguments into a dictionary. Every LLM Python SDK
(openai, anthropic, etc.) uses this so you can pass model-specific settings without the
function needing to know about all of them in advance.
*args does the same thing but for extra positional (unnamed) arguments, collecting them
into a tuple instead of a dict:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
You can combine both in a single function definition - the order is always
positional, *args, keyword defaults, **kwargs:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Lambdas are small, throwaway, unnamed functions - useful as a one-off argument to something
like sorted(), but not meant to replace a real def for anything with logic worth naming:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: using a mutable default argument like
def f(items=[]). Python creates that default list once, when the function is defined, and reuses the same object on every call that doesn't pass its own - so appends from a previous call leak into the next one. The fix isdef f(items=None): items = items or []inside the function body.
1.5 Classes - a blueprint for a box that has both data AND behavior
A class is a template. An instance (object) is one specific thing built from that template.
class Agent: agent1 = Agent("Researcher") agent2 = Agent("Coder")
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ name │──────▶ │ name: Researcher│ │ name: Coder │
│ think() │ builds │ think() │ │ think() │
└────────────────┘ └────────────────┘ └────────────────┘
(blueprint) (object 1) (object 2)Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Why you need this: in Chapter 6 your Agent class will hold conversation memory exactly
like self.history above. In Chapter 7 you'll define a Provider base class that
different AI providers "plug into" - same blueprint idea, different implementations.
self is just "this particular instance" - nothing more mysterious than that
Every method you define inside a class takes self as its first parameter, and Python fills
it in automatically when you call the method on an instance. agent1.think("entropy") is
really shorthand for Agent.think(agent1, "entropy") - Python just hides the boilerplate.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Class attributes vs instance attributes
Attributes defined directly under the class body (not inside __init__) are shared across
every instance, unless a specific instance overrides them. This is a frequent source of
surprise bugs when the shared value is itself mutable (like a list):
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Inheritance - a specialised blueprint built on a general one
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This tiny pattern is the entire idea behind Chapter 7's multi-provider system: one shared
contract (send), many interchangeable implementations.
You can also add a second provider and prove they're interchangeable - that's the whole point of the pattern:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
raise NotImplementedError in the base class is a deliberate trap: if you forget to override
send() in a subclass, calling it crashes loudly and immediately, instead of silently doing
nothing. That's much easier to debug than a method that quietly returns None.
Common mistake: confusing a class (the blueprint,
Agent) with an instance (an actual object,agent1). You call methods on instances (agent1.think(...)), and you typically only reference the class itself when creating a new instance (Agent("Researcher")) or checking a type (isinstance(agent1, Agent)).
1.6 Errors - things WILL go wrong when calling a network API
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
When you call LM Studio or a cloud API later, the network can fail, the server can be off,
the response can be malformed JSON. You will wrap those calls in try/except - this is why
we cover it here, not as a footnote.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Catching specific exceptions, not everything blindly
You can chain multiple except clauses to handle different failure types differently - a
timeout might mean "retry," while a bad API key means "stop and tell the user immediately."
Treating them the same hides bugs:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: writing a bare
except:(orexcept Exception:) that swallows every possible error, including ones you didn't anticipate, like a typo in your own code. This makes bugs disappear silently instead of failing loudly where you can see them. Catch the specific exception type you expect, and let everything else propagate up so you notice it.
Raising your own exceptions
You're not limited to catching errors Python or a library raises - you can raise your own, which is how you'll enforce "this agent tool must not be called with an empty query" type rules later in the book:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
1.7 Modules and imports - what import really does
my_project/ ├── main.py └── tools.py
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
import tools literally runs tools.py once and gives you a handle to everything it defined,
namespaced under tools.. When you later write from openai import OpenAI, you are doing the
exact same thing - except openai isn't a file sitting next to yours, it's a package that had
to be installed first. That installation step is the entire subject of Chapter 2.
Aliasing imports
Long or clashing module names are commonly given a short alias with as - you'll see this
constantly with data libraries (import pandas as pd) even though this book stays inside the
standard library and the small mocked modules listed in the intro:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
if __name__ == "__main__": - why you'll see this in almost every script
When Python runs a file directly, that file's special __name__ variable is set to the string
"__main__". When the same file is instead imported by another file, __name__ is set to
the module's name instead. This lets a file define reusable functions AND have its own
runnable demo code, without the demo code firing every time someone imports it:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: putting top-level "test it out"
print()calls at the bottom of a module with noif __name__ == "__main__":guard. The moment another file imports that module, all that debug output fires unexpectedly. Guard it.
1.8 Practice: transform some data (do this before moving on)
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
INPUT (list of dicts) TRANSFORM OUTPUT (dict)
[{"user":"Ada","tokens":1}, loop + accumulate by key {"Ada": 2, "Alan": 3}
{"user":"Alan","tokens":3}, ─────────────────────────▶
{"user":"Ada","tokens":1}]A second worked example - combining several of this chapter's ideas at once
This one chains a list comprehension, a dict, a function, and error handling together - roughly the density of code you'll be writing by Chapter 6:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Try extending it yourself: write a function count_by_role(messages) that returns
{"system": 1, "user": 2, "assistant": 1} for the list above, using the same
accumulate-by-key pattern from the first example.
Check yourself
Before moving to Chapter 2, make sure you can answer these without scrolling back up:
- What's the difference between
user["age"]anduser.get("age")when"age"isn't a key inuser? - Why does appending to a list stored in one variable also change what a second variable pointing at the same list sees?
- What does
**kwargscollect, and why do LLM SDKs rely on it so heavily? - What's the difference between a class attribute and an instance attribute, and which one
would you use for
self.historyon anAgent? - Why is
raise NotImplementedErrorinside a base class's method a deliberate design choice rather than an oversight? - What does
if __name__ == "__main__":actually check, and what breaks if you omit it in a file meant to be imported elsewhere?
If that all made sense, you're ready for Chapter 2 - where we stop hand-writing every file and start using Python's real project tooling.