We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 9 of 25
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=Trueandweakref_slot=Trueby default,auto_detect=True, 36.4 ns construction (versus 43.5 ns for a plain dataclass). -
Converters, running on
__init__and — withon_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_propertythat 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
bool→TypeError("port must not be a bool")(checked first:boolis a subclass ofint, so a laterisinstance(raw, int)would acceptTrueas port 1); -
a non-numeric
str→ValueError(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:
-
Port—@dataclass(frozen=True, slots=True)withvalue: intand aparseclassmethod. Immutable, hashable, no assignment possible at all. -
LoosePort— a plain mutable dataclass. Nothing validates, ever. It is here to be the control. -
GuardedPort— a hand-written class with__slots__ = ("value",)whose__setattr__runsto_porton every assignment, including the one inside__init__. This is the attrson_setattrbehaviour, 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 reassign — whatever 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.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.