Skip to content

← Data Modelling and Invariants step 9 of 25

Medium Primitives

Converters and validate-on-assignment: what attrs buys you

Two features justify adding attrs to a project that already has dataclasses, and neither is “less boilerplate”. They are converters (normalising a value at the object’s boundary, on construction and on assignment) and cached_property that works on slotted classes (otherwise you choose between memory and memoisation). This exercise builds both by hand, so you can see the shape of what the dependency buys — and where the stdlib stops.

What @attrs.define gives you that @dataclass does not

  • slots=True and weakref_slot=True by default, auto_detect=True, 36.4 ns construction (versus 43.5 ns for a plain dataclass).
  • Converters, running on __init__ and — with on_setattr — on every assignment. The stdlib has no equivalent at all.
  • Composable validators, Factory(takes_self=True), field(alias=), field transformers, and a documented __init_subclass__ story.
  • cached_property that actually works under slots.

The honest correction

attrs does not protect you from mutable defaults. items: list[int] = [] is accepted and silently shared, where stdlib dataclasses raises ValueError. On that specific axis the stdlib is the safer library. attrs’ own documentation is equally blunt about scope: it “emphatically does not try to be a validation library” — validators are assertions about your own data, not a parser for untrusted input.

And a typing footgun: mypy’s attrs plugin detects fields by function name only. Wrap attrs.field() in a house helper — def required(**kw): return attrs.field(**kw) — and every field declared with it becomes invisible to the checker.

Why the stdlib cannot express a converter

A dataclass’s __init__ parameter type is the field type; there is one annotation and it has to serve both. So Port(value="8080") cannot type-check while port.value is an int. The stdlib answer is a named constructor:

@classmethod
def parse(cls, raw: object) -> "Port":
    return cls(to_port(raw))

Widening lives in parse; the field stays honest. That is a better API than a silent converter, in one respect — the coercion is visible at the call site — and worse in another: nothing stops a caller from bypassing it, and nothing at all validates an assignment.


Your task

Implement to_port(raw) and three value objects over the same rule.

to_port accepts an int or an all-digits str and returns an int in 1..65535. Exact messages, because they are asserted:

  • a boolTypeError("port must not be a bool") (checked first: bool is a subclass of int, so a later isinstance(raw, int) would accept True as port 1);
  • a non-numeric strValueError(f"port is not numeric: {raw!r}");
  • anything else → TypeError(f"port must be int or str, got {type(raw).__name__}");
  • out of range → ValueError(f"port out of range: {number}").

Then:

  1. Port@dataclass(frozen=True, slots=True) with value: int and a parse classmethod. Immutable, hashable, no assignment possible at all.
  2. LoosePort — a plain mutable dataclass. Nothing validates, ever. It is here to be the control.
  3. GuardedPort — a hand-written class with __slots__ = ("value",) whose __setattr__ runs to_port on every assignment, including the one inside __init__. This is the attrs on_setattr behaviour, written out.

solve(initial, reassign) returns five keys:

key value
"port" repr(Port.parse(initial)), or "<ExcName>: <message>"
"port_frozen" "FrozenInstanceError" from assigning to it, or "" if construction failed
"loose" repr(LoosePort) after assigning reassignwhatever it was
"guarded" repr(GuardedPort) after assigning reassign, or the error
"hash_equal" Port.parse(8080) == Port.parse("8080"), and equal hashes

Assign through setattr(guarded, "value", reassign) rather than guarded.value = reassign. mypy checks the assignment against the declared attribute type, not against your __setattr__ signature — so the literal form is an assignment error even though the whole point of the class is that it accepts wider input. attrs’ converters have exactly the same static blind spot, which is worth knowing before you rely on them at a trust boundary.

Look at what the "loose" column does across the cases. A plain mutable dataclass will store the string 'abc' in a field annotated int and tell you nothing — at construction, at assignment, or at repr time. That is not a dataclass flaw; it is what “annotations are not runtime checks” means.