Lesson 1: Classes
Why classes appear this late
Module 2 introduced dataclasses as records and deliberately stopped there. Six modules of real work followed without a single class defined by hand, which was the point: most Python code needs functions and records, and reaching for a class by default produces objects that hold nothing and do nothing except wrap a function you could have called directly.
Now there is a reason. The recipe-extractor project talks to a model provider, and that conversation needs a client that holds an HTTP connection pool, a configured retry policy, a circuit breaker with mutable state, and a set of credentials. Those things belong together, they persist across calls, and the operations on them need access to all of them.
That is what a class is for.
Behaviour with state, contrasted with dataclasses as state alone
A dataclass is a bundle of values. A class is a bundle of values plus the operations that use them.
# A record: state alone, no behaviour that needs the state
@dataclass(frozen=True)
class Document:
id: str
content: str
content_hash: str
# A class: state that operations depend on, and that changes over time
class ProviderClient:
def __init__(self, api_key: str, base_url: str, *, timeout: httpx.Timeout) -> None:
self._client = httpx.Client(base_url=base_url, timeout=timeout)
self._api_key = api_key
self._breaker = CircuitBreaker()
def generate(self, messages: list[dict]) -> dict:
self._breaker.before_call()
try:
response = self._client.post(
"/generate",
json={"messages": messages},
headers={"Authorization": f"Bearer {self._api_key}"},
)
except httpx.HTTPError as exc:
self._breaker.record_failure()
raise ProviderError("transport failure") from exc
self._breaker.record_success()
return response.json()
The distinction worth holding onto is not "data versus logic". It is whether the operations need the state, and whether the state changes.
Document has neither property. Nothing about a document changes, and the functions operating on documents work fine receiving one as a parameter.
ProviderClient has both. The circuit breaker's failure count changes with every call. The HTTP client holds a connection pool that must be reused rather than recreated. And generate cannot work without all three pieces of state, so passing them as parameters to a free function would mean threading three arguments through every call site.
The test to apply. If you are considering a class, ask whether every method uses the instance state. If a method ignores self entirely, it should be a function. If a class has one method and its constructor only stores arguments that the method uses, it is a function wearing a costume, and Module 3's closures do the same job with less ceremony.
# A function wearing a costume
class TextCleaner:
def __init__(self, lowercase: bool) -> None:
self.lowercase = lowercase
def clean(self, text: str) -> str:
cleaned = strip_html(text)
return cleaned.lower() if self.lowercase else cleaned
# The same thing, honestly
def make_cleaner(*, lowercase: bool) -> Callable[[str], str]:
def clean(text: str) -> str:
cleaned = strip_html(text)
return cleaned.lower() if lowercase else cleaned
return clean
Both work. The second is smaller, and it composes into the cleaning pipeline from Module 3 without an adapter.
init, instance versus class attributes, and encapsulation
__init__ sets up an instance. It should do exactly that and nothing else.
class ProviderClient:
def __init__(self, api_key: str, base_url: str) -> None:
if not api_key:
raise ValueError("api_key is required")
self._api_key = api_key
self._client = httpx.Client(base_url=base_url)
Validate arguments, assign attributes, and stop. An __init__ that makes a network call, reads a file, or performs a database query creates an object that cannot be constructed in a test without those resources being available. When setup genuinely requires work, use a separate classmethod so that construction and connection stay separable:
@classmethod
def connect(cls, settings: Settings) -> "ProviderClient":
"""Build a client and verify the credentials work."""
client = cls(settings.api_key, settings.provider_url)
client.check_health()
return client
Instance attributes belong to one object. Class attributes are shared by all of them.
class ProviderClient:
DEFAULT_MODEL = "recipe-extract-v2" # class attribute, shared, constant
_instances_created = 0 # class attribute, shared, mutable
def __init__(self, api_key: str) -> None:
self._api_key = api_key # instance attribute, per object
ProviderClient._instances_created += 1
Class attributes are appropriate for constants. Mutable class attributes are a trap of the same shape as the mutable default argument from Module 3: every instance shares one object, and a change made through one instance is visible from all of them. That is occasionally what you want and usually a bug.
class Registry:
tools: list[str] = [] # shared by every instance, almost certainly wrong
def add(self, name: str) -> None:
self.tools.append(name) # mutates the shared list
Encapsulation in Python is a convention, not a mechanism. A single leading underscore means "this is internal, do not rely on it". Nothing enforces it, and that is deliberate: the language trusts you to read the signal.
class ProviderClient:
def __init__(self, api_key: str) -> None:
self._api_key = api_key # internal, by convention
self.model = "default" # public, part of the interface
The value of the convention is that it tells a reader which attributes are the interface and which are implementation details you may change. Prefix anything a caller should not touch, and treat an underscore-prefixed attribute on someone else's class as off limits, because it may disappear in the next version.
A double leading underscore triggers name mangling, which is a different feature intended to avoid attribute collisions in inheritance hierarchies. It is not a privacy mechanism, and using it for that purpose mostly makes debugging harder.
repr, eq, and debuggable objects
An object that prints as <ProviderClient object at 0x7f8b2c> tells you nothing when a test fails at two in the morning.
class ProviderClient:
def __init__(self, api_key: str, base_url: str, model: str) -> None:
self._api_key = api_key
self.base_url = base_url
self.model = model
def __repr__(self) -> str:
return f"ProviderClient(base_url={self.base_url!r}, model={self.model!r})"
Three rules for a good __repr__.
Include what identifies the object and omit the rest. The base URL and model identify this client. The connection pool does not.
Never include secrets. The API key is absent from the repr above, deliberately. A repr appears in log lines, in tracebacks, in debugger output, and in test failure messages, so anything in it ends up somewhere you did not intend. This is the same discipline as the logging decorator in Module 3.
Use !r for values. It applies repr to each field, so strings appear quoted and the difference between 4 and "4" is visible, which is exactly what you need when debugging a type problem.
__eq__ defines what equality means. By default, two objects are equal only if they are the same object, which is rarely what you want for anything record-like.
Dataclasses generate __eq__ for you, comparing all fields, which is one reason Module 2 preferred them for records. For a hand-written class you decide:
class ModelResponse:
def __init__(self, text: str, model: str, request_id: str) -> None:
self.text = text
self.model = model
self.request_id = request_id
def __eq__(self, other: object) -> bool:
if not isinstance(other, ModelResponse):
return NotImplemented
return (self.text, self.model) == (other.text, other.model)
def __hash__(self) -> int:
return hash((self.text, self.model))
Two details matter. Returning NotImplemented rather than False for an unrelated type lets Python try the reflected comparison on the other object, which is the correct protocol. And defining __eq__ without __hash__ makes the class unhashable, so instances can no longer go in a set or be used as dictionary keys. If equality is value-based, hash must be too, and both must use the same fields.
Note the deliberate choice above: request_id is excluded from equality, because two responses with the same content are the same response for comparison purposes even though they came from different requests. That is a decision about meaning, and it is the kind of thing to state in a docstring.
Concept check. You have a class with a constructor that stores three configuration values and a single method that uses all three. Should it be a class?
Answer
Probably not. A single method plus a constructor that only stores its arguments is the shape Module 3 called a closure, and make_thing(a, b, c) returning a configured function does the same job with less code and composes into pipelines directly.
Reasons it might still deserve to be a class: if the state changes between calls, if you expect more methods soon and they will share the state, if it needs to be substitutable for other implementations behind a Protocol as Lesson 2 covers, or if it holds a resource with a lifecycle such as a connection pool that must be closed. Any of those justify the class. Storing three values and calling one method does not.