Python for AI Engineers

◆ 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: import is Python's require/ESM import, but with a key difference: import config binds the whole module object (like import * as config from './config'), while from config import MODEL is like import { MODEL } from './config'. import numpy as np mirrors import 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 config should 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"] is process.env.KEY — but Python's bracket form throws on a missing key, whereas JS's process.env.KEY quietly yields undefined. 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 for pathlib. A defaultdict/Counter where someone hand-rolled if key not in d: d[key] = ... signals a writer who knows the stdlib. A bare json.loads(model_output) with no except json.JSONDecodeError around 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 the try block 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/except is try/catch; raise is throw; finally is finally. Python adds an else clause 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 usual catch (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-raise

This hides bugs — a failed API call looks identical to a successful one. A bare except: is worse still: it even swallows KeyboardInterrupt and SystemExit. 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, and assert used 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 with in classic JavaScript. The nearest equivalents are a try/finally where you manually clean up in finally, or the newer using/await using (explicit resource management) that calls [Symbol.dispose]. Python's with + __enter__/__exit__ predates and inspires that pattern.

Reading & judging: Manual open(...) / client = httpx.Client() without a matching with (or an explicit .close() in a finally) is a resource-leak smell — file handles and sockets pile up. Seeing with used around every file, client, lock, and DB session is a sign of careful code. A custom __exit__ that return Trues unconditionally is suspicious: it silently eats every exception in the block.

Try it: Write a @contextmanager called retry_budget that records a start time, yields, and in its finally prints how many seconds the wrapped block took and whether it raised. Wrap a json.loads of deliberately-broken JSON in it, catch the JSONDecodeError outside the with, 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.


PART II — TYPES, ASYNC & CRAFT (chapters 8–10)