◆ Chapter 07
Modules, the standard library, errors, and context managers
Imports, the standard library, exception handling, and the with-statement — how real Python projects are wired together.
~2,450 words · chapter 7 of 15
You can read a lot of GenAI code once you recognise three recurring shapes: how files pull each other in (imports), how they lean on the batteries-included standard library, and how they fail safely (exceptions and with). This chapter makes all three legible.
7.1 Modules and imports, deeper
A module is just a .py file. A package is a directory of modules. When you write import agent, Python finds agent.py, runs it top to bottom once, and binds the resulting namespace to the name agent.
That "runs it once" part surprises JS developers. Everything at the top level of a module executes on first import — function/class definitions, but also any loose statements.
# file: config.py
print("loading config...") # runs the FIRST time config is imported
MODEL = "claude-opus-4"
API_BASE = "https://api.example.com"
def make_client():
return {"model": MODEL, "base": API_BASE}
# file: main.py
import config # => loading config... (printed here, on import)
import config # (silent — modules are cached in sys.modules)
print(config.MODEL) # => claude-opus-4
client = config.make_client()
The four import forms you will meet:
import json # bind the module; use as json.loads(...)
from json import loads, dumps # bind two names directly; use loads(...)
import numpy as np # alias — the near-universal np/pd convention
from json import * # bind everything public — AVOID (see below)
from module import * dumps every public name into your namespace. It is discouraged because you can no longer tell where a name came from, and it can silently shadow builtins or earlier imports. A reader (and a linter) can't trace parse(...) back to its origin. Prefer explicit imports.
JS → Python:
importis Python'srequire/ESMimport, but with a key difference:import configbinds the whole module object (likeimport * as config from './config'), whilefrom config import MODELis likeimport { MODEL } from './config'.import numpy as npmirrorsimport np from ...aliasing. There is no default export — every top-level name is a named export.
The __name__ == "__main__" guard. Every module has a magic variable __name__. When you run a file directly (python main.py), Python sets that file's __name__ to the string "__main__". When the same file is imported by another module, its __name__ is set to the module's own name ("main"). So this block runs only on direct execution, never on import:
def summarize(text: str) -> str:
return text[:100] + "..."
if __name__ == "__main__":
# a quick self-test / CLI entry point — skipped when imported
print(summarize("some long transcript " * 20))
This is the idiom for "library code that also has a script mode". Importing the module gives you summarize without triggering the demo. In JS the nearest cousin is checking require.main === module (CommonJS) or import.meta.main (newer runtimes).
Where Python looks. On import x, Python searches the paths in sys.path (a list): the script's own directory, then installed-package locations (site-packages inside your virtual environment), in order. The first match wins. If two things are named the same, shadowing bugs follow — never name your file json.py next to code that imports the stdlib json.
Circular imports are a design smell. If a.py imports b at the top, and b.py imports a at the top, whichever loads first hits a half-built module and you get an ImportError or a mysterious None. It signals two modules that should be one, or that need a shared third module. Seeing an import moved inside a function (a lazy import) is often a patch for exactly this.
Reading & judging: Module-level side effects — network calls, file writes, reading env vars at import time — are a hazard.
import configshould not silently open a socket. If importing a module does real work, tests become slow and order-dependent. Prefer work inside functions the caller invokes.
7.2 A standard-library tour for reading GenAI code
You don't need to memorise these — you need to recognise them.
pathlib.Path — modern filesystem paths. Prefer it over the old string-based os.path. A Path is an object; the / operator joins path segments cross-platform.
from pathlib import Path
prompts_dir = Path("prompts")
system_file = prompts_dir / "system.txt" # Path('prompts/system.txt')
if system_file.exists():
text = system_file.read_text(encoding="utf-8") # whole file → str, one call
print(len(text))
Path("out").mkdir(exist_ok=True) # make dir, don't error if present
(Path("out") / "reply.json").write_text('{"ok": true}')
.read_text() / .write_text() handle open-and-close for you. .glob("*.txt") yields matching paths.
os and os.environ — env vars and API keys. os.environ is a dict-like of environment variables. This is how nearly every LLM client finds its key.
import os
key = os.environ["OPENAI_API_KEY"] # raises KeyError if the var is MISSING
key = os.environ.get("OPENAI_API_KEY") # returns None if missing — no crash
model = os.environ.get("MODEL", "claude-opus-4") # with a default
The bracket form fails loudly and immediately if the key is absent — usually what you want at startup, because a missing key is fatal. The .get form hides the problem until later.
JS → Python:
os.environ["KEY"]isprocess.env.KEY— but Python's bracket form throws on a missing key, whereas JS'sprocess.env.KEYquietly yieldsundefined. The Python.get("KEY")form is the one that behaves like JS.
json — parse and serialise. Constantly used to read LLM tool-call arguments and write structured output.
import json
data = json.loads('{"tool": "search", "args": {"q": "python"}}') # str → dict
print(data["tool"]) # => search
pretty = json.dumps(data, indent=2) # dict → str, human-readable
LLMs are asked to return JSON but sometimes emit malformed text. Guard the parse:
raw = '{"score": 0.9, oops}' # model produced broken JSON
try:
result = json.loads(raw)
except json.JSONDecodeError as e:
print(f"model returned invalid JSON at position {e.pos}") # => ... position 18
datetime and time — timestamps and backoff.
from datetime import datetime, timezone
import time
now = datetime.now(timezone.utc).isoformat() # '2026-07-06T...+00:00'
time.sleep(2) # block 2 seconds — retry backoff
time.sleep is what you see between retries after a rate-limit error.
collections — specialised containers.
from collections import defaultdict, Counter, namedtuple
groups = defaultdict(list) # missing key auto-creates an empty list
groups["assistant"].append("hi") # no KeyError, no setup needed
counts = Counter(["gpt", "claude", "gpt"]) # tallies items
print(counts.most_common(1)) # => [('gpt', 2)]
Msg = namedtuple("Msg", ["role", "text"]) # a tiny immutable record type
m = Msg("user", "hello")
print(m.role) # => user
itertools — lazy iterator tools. Recognise these; they operate on streams without building big lists.
from itertools import chain, islice, groupby
merged = list(chain([1, 2], [3, 4])) # => [1, 2, 3, 4] (flatten)
first3 = list(islice(range(1000), 3)) # => [0, 1, 2] (take N, e.g. from a stream)
# groupby groups CONSECUTIVE equal items — sort first if you want true grouping
functools — function utilities.
from functools import cache, partial, wraps, reduce
@cache # memoise: same args → cached result
def embed(text: str) -> int:
return len(text) # pretend this is an expensive API call
as_gpt = partial(print, "[gpt]") # pre-fill an argument
as_gpt("done") # => [gpt] done
total = reduce(lambda a, b: a + b, [1, 2, 3], 0) # => 6 (fold a list to one value)
@wraps is used inside decorators to preserve the wrapped function's name and docstring — you'll see it whenever you read decorator code.
enum.Enum — named constant sets.
from enum import Enum
class Role(Enum):
USER = "user"
ASSISTANT = "assistant"
print(Role.USER.value) # => user
re — regular expressions. Recognise the shape; the pattern lives in a raw string r"...".
import re
m = re.search(r"\$(\d+\.\d{2})", "cost is $12.50") # find a price
if m:
print(m.group(1)) # => 12.50
logging — the right way to emit diagnostics (full treatment later). Just know that logger.info(...) / logger.error(...) beats print() in real code because it carries levels, timestamps, and can be silenced or routed.
typing — type hints like list[str], dict[str, int], X | None. Covered in depth in its own chapter; for now, read them as documentation the tools can check.
Reading & judging: Code still using
os.path.join(...)and string paths everywhere isn't wrong, but new code should reach forpathlib. Adefaultdict/Counterwhere someone hand-rolledif key not in d: d[key] = ...signals a writer who knows the stdlib. A barejson.loads(model_output)with noexcept json.JSONDecodeErroraround LLM output is a latent crash.
7.3 Errors and exceptions
Python signals failure by raising an exception, which you catch with try/except.
try:
n = int("not a number")
except ValueError as e: # catch a SPECIFIC exception type
print(f"bad input: {e}") # => bad input: invalid literal for int()...
else:
print("ran only if NO exception") # optional
finally:
print("always runs — cleanup") # optional, runs on success or failure
except SpecificError— handle one class of failure.else— runs only if thetryblock raised nothing.finally— runs no matter what, even if the exception propagates. Cleanup goes here.
Catch specific types. Exceptions form a hierarchy: ValueError, KeyError, TimeoutError etc. all inherit from Exception. Catching a narrow type means you only intercept failures you understand.
raise ValueError("temperature must be between 0 and 2") # raise your own
Chaining with from. When you catch one error and raise a clearer one, from e preserves the original for the traceback — invaluable for debugging.
try:
cfg = json.loads(raw_config)
except json.JSONDecodeError as e:
raise RuntimeError("config file is not valid JSON") from e
Custom exception classes let callers catch your failures precisely:
class RateLimitError(Exception):
"""Raised when the model API returns HTTP 429."""
class ModelOutputError(Exception):
"""Raised when the model's response can't be parsed."""
assert checks an invariant during development — but Python run with the -O (optimise) flag strips every assert. So never use assert to validate untrusted input or enforce security; use a real if ... raise instead.
assert 0 <= temp <= 2 # fine as an internal sanity check
if not api_key: # correct way to validate real input
raise ValueError("API key required")
Robust handling around an LLM call. Here's the shape you'll read (and write) constantly — catch the specific things that go wrong with a network+parse pipeline, and let genuinely unexpected errors propagate:
import json, time
def call_model(client, prompt: str, retries: int = 3) -> dict:
for attempt in range(retries):
try:
resp = client.complete(prompt, timeout=30) # network call
return json.loads(resp.text) # parse structured output
except TimeoutError:
time.sleep(2 ** attempt) # exponential backoff, retry
except RateLimitError:
time.sleep(5) # wait, then retry
except json.JSONDecodeError as e:
raise ModelOutputError("model did not return JSON") from e
raise TimeoutError(f"failed after {retries} attempts")
JS → Python:
try/exceptistry/catch;raiseisthrow;finallyisfinally. Python adds anelseclause JS lacks. Python catches by exception type (except ValueError) rather than by inspecting a single caught value — closer to matching error classes than JS's usualcatch (e) { if (e instanceof ...) }.
Reading & judging: The number-one red flag is the silent swallow:
try: risky() except Exception: # or worse, bare `except:` (also catches Ctrl-C, exit) pass # failure vanishes with no log, no re-raiseThis hides bugs — a failed API call looks identical to a successful one. A bare
except:is worse still: it even swallowsKeyboardInterruptandSystemExit. Legitimate broad catches log and re-raise (or return an explicit error). Also watch for catch-and-continue in a loop that quietly drops half the results, andassertused to validate real input (stripped in production).
7.4 Context managers — the with statement
A context manager guarantees that setup and teardown happen as a pair — even if the body raises. The canonical case is files:
with open("transcript.txt", "r", encoding="utf-8") as f:
text = f.read()
# file is CLOSED here, automatically — even if read() had raised
Without with, a leak is one early return or exception away:
f = open("transcript.txt")
data = process(f.read()) # if this raises, the file is never closed → leak
f.close()
The with version closes the file no matter how the block exits. You'll see the same pattern for HTTP clients, locks, and database sessions:
import httpx
with httpx.Client(timeout=30) as client: # opens a connection pool
r = client.get("https://api.example.com/models")
# connections released here, even on error
Multiple contexts stack in one statement:
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read().upper())
Writing your own. A class becomes a context manager by defining __enter__ (setup, returns the object bound after as) and __exit__ (teardown, runs on the way out — even on exception):
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self.start
print(f"took {self.elapsed:.3f}s") # runs even if the body raised
# return False (the default) → any exception still propagates
with Timer():
sum(range(1_000_000)) # => took 0.0XXs
The three __exit__ arguments describe any exception that occurred (all None on clean exit). Returning True would suppress the exception — usually you don't, so it propagates.
The lighter way, for simple setup/teardown, is @contextlib.contextmanager — write one function, yield the resource in the middle:
from contextlib import contextmanager
import time
@contextmanager
def timer(label: str):
start = time.perf_counter()
try:
yield # control returns to the with-body here
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timer("embedding"):
total = sum(range(1_000_000)) # => embedding: 0.0XXs
Everything before yield is setup; everything after (in the finally) is guaranteed teardown.
JS → Python: There's no direct
within classic JavaScript. The nearest equivalents are atry/finallywhere you manually clean up infinally, or the newerusing/await using(explicit resource management) that calls[Symbol.dispose]. Python'swith+__enter__/__exit__predates and inspires that pattern.
Reading & judging: Manual
open(...)/client = httpx.Client()without a matchingwith(or an explicit.close()in afinally) is a resource-leak smell — file handles and sockets pile up. Seeingwithused around every file, client, lock, and DB session is a sign of careful code. A custom__exit__thatreturn Trues unconditionally is suspicious: it silently eats every exception in the block.
Try it: Write a
@contextmanagercalledretry_budgetthat records a start time,yields, and in itsfinallyprints how many seconds the wrapped block took and whether it raised. Wrap ajson.loadsof deliberately-broken JSON in it, catch theJSONDecodeErroroutside thewith, and confirm the teardown still ran. This exercises context managers,finally, and specific-exception handling together — the exact trio you'll meet in real agent retry loops.