Python for AI Engineers

◆ Chapter 06

Classes, objects, and dataclasses (OOP the Pythonic way)

Classes, dunder methods and dataclasses — OOP the Pythonic way, and how to read the object models LLM SDKs are built on.

~2,393 words · chapter 6 of 15

You already know classes from JS. Python's version is close in spirit but differs in three ways that matter constantly when reading AI code: self is written out explicitly, "magic methods" (__like_this__) let objects plug into language syntax, and @dataclass removes almost all the boilerplate you're used to writing by hand. Master those three and most GenAI codebases become readable.

Classes, __init__, and the explicit self

class ChatSession:
    """Holds a running conversation with an LLM."""

    def __init__(self, model: str, system: str = "You are helpful."):
        # __init__ is the constructor. It runs when you call ChatSession(...).
        # It does NOT return the object — Python creates the object and hands
        # it to you as `self`; __init__ just fills it in.
        self.model = model              # instance attribute
        self.system = system
        self.messages: list[dict] = []  # each session gets its OWN list

    def add(self, role: str, content: str) -> None:
        # Every method takes `self` as its first parameter, explicitly.
        self.messages.append({"role": role, "content": content})

session = ChatSession("claude-opus-4")
session.add("user", "Hello")
print(session.messages)
# => [{'role': 'user', 'content': 'Hello'}]

Why is self a written-out parameter when JS gets this for free? Because Python has one rule instead of two: session.add(...) is just sugar for ChatSession.add(session, ...). The instance is passed as the first argument like any other. Nothing is implicit or bound by call-site magic, so there is no this-rebinding foot-gun. You never call add with self yourself — Python fills it in — but you always write it in the definition.

JS → Python: class Foo { constructor(x){ this.x = x } } becomes class Foo: with def __init__(self, x): self.x = x. JS this (dynamically bound, easy to lose in callbacks) is Python's self (an ordinary explicit parameter that can never be the "wrong" object). Python has no new keyword — you call the class directly: Foo(3). Python classes are real objects created at definition time, not desugared prototype chains, so there's no Foo.prototype to reach for.

Instance vs class attributes — and a classic bug

An attribute assigned inside __init__ via self.x belongs to that instance. An attribute assigned in the class body is shared by all instances:

class Agent:
    provider = "anthropic"   # class attribute — one copy, shared
    tools = []               # DANGER: also shared across all instances!

    def __init__(self, name: str):
        self.name = name     # instance attribute — one per object

a, b = Agent("planner"), Agent("coder")
a.tools.append("search")
print(b.tools)  # => ['search']   ← b sees a's change; they share ONE list

provider as a shared constant is fine. tools = [] is a bug: both agents mutate the same list. The fix is to create a fresh list per instance inside __init__ (self.tools = []), which is exactly what @dataclass automates with default_factory below.

Reading & judging: A mutable class attribute (= [], = {}) that instances then mutate is one of the most common real Python bugs. When you see a list or dict assigned directly in a class body and later .append-ed or index-assigned, suspect accidental sharing. Constants (strings, numbers, tuples) in the class body are safe because they're immutable.

Dunder / magic methods — the heart of readable objects

"Dunder" = double-underscore. These methods let your object hook into Python syntax. This is where Python's OOP earns its keep in AI libraries.

class Prompt:
    def __init__(self, text: str, tokens: list[str]):
        self.text = text
        self.tokens = tokens

    def __repr__(self) -> str:
        # Unambiguous, for developers. Shown in the REPL, debuggers, logs,
        # and inside lists. Aim to make it look like valid constructor code.
        return f"Prompt(text={self.text!r}, tokens={self.tokens})"

    def __str__(self) -> str:
        # Human-friendly, for print() / f-strings / end users.
        return self.text

    def __eq__(self, other) -> bool:
        # Defines what == means. Without it, == is identity (same object).
        return isinstance(other, Prompt) and self.tokens == other.tokens

    def __len__(self) -> int:
        return len(self.tokens)          # enables len(prompt)

    def __getitem__(self, i):
        return self.tokens[i]            # enables prompt[0] and slicing

    def __iter__(self):
        return iter(self.tokens)         # enables `for tok in prompt`

p = Prompt("hi there", ["hi", "there"])
print(repr(p))            # => Prompt(text='hi there', tokens=['hi', 'there'])
print(str(p))            # => hi there
print(len(p))            # => 2
print(p[0])              # => hi
print([t.upper() for t in p])  # => ['HI', 'THERE']  (works because __iter__)

__repr__ vs __str__ trips everyone up. __str__ is the pretty face for users; __repr__ is the debugging face for you. If you define only one, define __repr__ — Python falls back to it when __str__ is missing, and it's what shows up in logs, stack traces, and when the object sits inside a list. {self.text!r} uses the !r conversion to insert the repr of text (with quotes), so the output is copy-pasteable.

Two more dunders that appear all over agent frameworks:

class Tool:
    """A callable tool an agent can invoke."""
    def __init__(self, name: str, fn):
        self.name = name
        self.fn = fn

    def __call__(self, **kwargs):
        # Makes the INSTANCE callable like a function: tool(query="...").
        return self.fn(**kwargs)

search = Tool("search", lambda query: f"results for {query!r}")
print(search(query="python"))   # => results for 'python'
print(callable(search))         # => True

class Timer:
    def __enter__(self):                 # runs at `with` entry
        print("start"); return self
    def __exit__(self, exc_type, exc, tb):  # runs at `with` exit, even on error
        print("done")

with Timer():                            # context manager (full detail: Ch. 7)
    print("working")
# => start / working / done

__call__ is why an agent's tool object can be used like tool(...) while still being an object with a .name, schema, and state. __enter__/__exit__ power the with statement — the standard way to manage resources like open clients or streaming responses.

Operator overloading is the same idea: __add__ defines +, __lt__ defines <, and so on. Libraries use it sparingly (e.g. combining prompt templates with +); don't reach for it unless the operator's meaning is obvious.

Reading & judging: An object with no __repr__ prints as <myapp.Agent object at 0x10f3c2a50> — undebuggable. When logs or error dumps show those hex-address blobs, the class is missing a __repr__; that's a real code-quality smell in anything you'll have to operate. Conversely, a clean __repr__ is a sign someone expected the object to be debugged in production.

@property, @classmethod, @staticmethod

class Usage:
    def __init__(self, prompt_tokens: int, completion_tokens: int):
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_tokens

    @property
    def total_tokens(self) -> int:
        # A computed attribute. Accessed as `u.total_tokens` (NO parentheses),
        # but recomputed on each access. Great for derived values.
        return self.prompt_tokens + self.completion_tokens

    @classmethod
    def from_response(cls, resp: dict) -> "Usage":
        # Alternative constructor. `cls` is the class itself, so subclasses
        # get the right type back. This is the `Model.from_config(...)` pattern.
        return cls(resp["input_tokens"], resp["output_tokens"])

    @staticmethod
    def price(tokens: int, per_million: float) -> float:
        # No self, no cls — just a plain function grouped with the class
        # for namespacing. Doesn't touch instance or class state.
        return tokens / 1_000_000 * per_million

u = Usage.from_response({"input_tokens": 100, "output_tokens": 50})
print(u.total_tokens)                 # => 150   (no parentheses!)
print(Usage.price(150, 15.0))         # => 0.00225

@property turns a method into a read-only attribute (add a matching @total_tokens.setter if you need assignment). @classmethod is the idiomatic "named constructor" — you'll see Model.from_config(...), Client.from_env(), Message.from_dict(...) everywhere because Python allows only one __init__. @staticmethod is just a function that lives in the class's namespace.

JS → Python: @property ≈ JS get total() {...} / set total(v) {...}. @classmethod has no direct JS equivalent — the closest is a static from(...) factory method, but Python's cls makes it subclass-aware automatically. @staticmethod ≈ JS static method.

Inheritance, super(), MRO — and why composition often wins

class Agent:
    def __init__(self, name: str):
        self.name = name

    def act(self) -> str:
        return f"{self.name} thinking"

class ToolAgent(Agent):                       # ToolAgent IS-A Agent
    def __init__(self, name: str, tools: list[str]):
        super().__init__(name)                # call parent __init__ first
        self.tools = tools

    def act(self) -> str:                      # override
        return super().act() + f" with {len(self.tools)} tools"

ta = ToolAgent("researcher", ["search", "calc"])
print(ta.act())              # => researcher thinking with 2 tools
print(isinstance(ta, Agent)) # => True   (ToolAgent instances are Agents too)

super().__init__(...) runs the parent's constructor — forget it and self.name never gets set. Python supports multiple inheritance (a class can have several parents). When an attribute could come from more than one parent, Python resolves it using the MRO (Method Resolution Order) — a deterministic linearization of the class graph you can inspect with ClassName.__mro__. In practice, deep multiple inheritance is hard to follow; most modern AI libraries prefer shallow hierarchies plus composition — an object holds collaborators rather than inheriting from them:

class RagAgent:
    # Composition: RagAgent HAS-A retriever and HAS-A model, rather than
    # inheriting from both. Easier to test, swap, and reason about.
    def __init__(self, retriever, model):
        self.retriever = retriever
        self.model = model

Use isinstance(x, Agent) to check type membership (it respects inheritance). Prefer it over type(x) == Agent, which rejects subclasses.

Reading & judging: Inheritance more than 2–3 levels deep, or a subclass that overrides most of its parent, usually signals inheritance being used where composition fits — a maintenance trap. A single class doing retrieval and prompting and HTTP and parsing is a "god-class"; good code splits those into collaborators. When you see super().__init__ missing in a subclass constructor, expect half-initialized objects.

@dataclass — the modern default for data holders

Most classes in AI code just bundle data. Writing __init__, __repr__, and __eq__ by hand for those is pure boilerplate. @dataclass generates all three from your type-annotated fields:

from dataclasses import dataclass, field

@dataclass
class Message:
    role: str
    content: str
    name: str | None = None          # field with a default

@dataclass(frozen=True)              # frozen=True → immutable (can't reassign)
class ModelConfig:
    name: str
    temperature: float = 0.7
    stop: list[str] = field(default_factory=list)  # SAFE mutable default

m = Message("user", "hello")
print(m)                    # => Message(role='user', content='hello', name=None)
print(m == Message("user", "hello"))  # => True   (field-by-field __eq__, free)

cfg = ModelConfig("claude-opus-4")
# cfg.temperature = 0.9     # would raise FrozenInstanceError — it's frozen

Two things to internalize. First, field(default_factory=list) is the correct way to default to an empty list — it calls list() fresh for each instance, dodging the shared-mutable-default bug from earlier (dataclasses actually raise an error if you try stop: list = [] directly, which is a nice guardrail). Second, frozen=True makes instances immutable and hashable — ideal for config objects and cache keys, and aligned with the "new objects, never mutate" discipline these codebases favour.

JS → Python: A dataclass is roughly a TS interface plus a generated constructor, equals, and toString, in one declaration. There's no JS built-in equivalent — the nearest is hand-writing a class or using a library. Where TS readonly/Object.freeze are advisory, frozen=True is enforced at runtime.

Dataclasses are for internal, trusted data — they annotate types but do not validate them (Message(role=123, content=None) constructs happily). When data crosses a trust boundary — API request bodies, LLM tool-call arguments, config files — you want Pydantic, which validates and coerces against the types. Chapter 8 covers Pydantic; reach for it at the edges and dataclasses in the core.

Reading & judging: A hand-written class whose entire body is __init__ assigning self.x = x plus a __repr__ should almost always be a @dataclass — verbose hand-rolled versions often hide subtle bugs (a forgotten field in __eq__, a mutating default). Conversely, a @dataclass used where inputs are untrusted is a validation gap — that's Pydantic's job.

Protocols and duck typing vs explicit interfaces

Python's default typing philosophy is duck typing: "if it walks like a duck…" — code doesn't care about an object's class, only that it has the method being called. typing.Protocol makes that idea checkable by tools, describing a shape without inheritance:

from typing import Protocol, runtime_checkable

@runtime_checkable
class ChatModel(Protocol):
    # Anything with a matching .chat() method counts as a ChatModel —
    # NO base class, NO registration. This is structural typing.
    def chat(self, prompt: str) -> str: ...

class AnthropicModel:                 # note: does NOT inherit ChatModel
    def chat(self, prompt: str) -> str:
        return f"[claude] {prompt}"

def run(model: ChatModel, prompt: str) -> str:
    return model.chat(prompt)         # type-checkers accept AnthropicModel

print(run(AnthropicModel(), "hi"))    # => [claude] hi
print(isinstance(AnthropicModel(), ChatModel))  # => True (runtime_checkable)

Contrast with abc.ABC, an explicit interface a class must inherit and whose abstract methods it must implement — enforced at instantiation:

from abc import ABC, abstractmethod

class Retriever(ABC):
    @abstractmethod
    def search(self, query: str) -> list[str]: ...

# Retriever()  → TypeError: Can't instantiate abstract class ... 'search'

Use a Protocol when you want to accept anything shaped right (great for pluggable model/tool backends you don't own). Use an ABC when you're defining a base others must explicitly extend and you want a hard error if they forget a method.

JS → Python: A Protocol is the direct analogue of a TS interface — structural, no inheritance needed, checked statically. An abc.ABC is a nominal interface like an abstract class you must extends. TS interfaces vanish at runtime; a @runtime_checkable Protocol can also be tested live with isinstance.

When not to reach for a class

Many excellent GenAI codebases are function-first. A class buys you little if there's no persistent state — a single function that takes inputs and returns a result is clearer than a class instantiated once and called once. Use a class (or dataclass) when you have state that several methods share, multiple interchangeable implementations behind one interface, or objects that benefit from __repr__/__eq__. Use a plain function for a stateless transformation, and a plain dict for loose, short-lived bags of data.

Reading & judging: A class with one method (besides __init__) and no meaningful state is a function wearing a costume — mentally rewrite it as def. A dict passed through ten functions with keys accessed everywhere would be safer as a @dataclass (typos become errors, not silent KeyErrors). Judge for the right altitude of OOP: enough structure to make state and interfaces explicit, not so much that a two-line transform hides inside three classes.

Try it: Write a @dataclass(frozen=True) Message(role, content), then a mutable @dataclass Conversation whose messages field uses field(default_factory=list). Add a token_count @property, a Conversation.from_pairs(...) @classmethod alternative constructor, and __len__/__iter__ so len(convo) and for msg in convo work. Then define a ChatModel Protocol with .chat(prompt) and a fake class satisfying it without inheriting — confirm isinstance passes with @runtime_checkable. You'll have touched every tool in this chapter.