Skip to content

← Modelling Data with Dataclasses step 22 of 28

Medium Primitives

Convert and Validate a Port Number on Assignment

The last two exercises turned outside JSON into checked records. Once a value is inside an object, though, code can still change it. Here you convert a port number from outside input, then compare three classes that hold it. Port is a frozen dataclass, LoosePort is a plain dataclass that checks nothing, and GuardedPort checks every assignment.

solve(8080, "443")
# {"port": "Port(value=8080)", "port_frozen": "FrozenInstanceError",
#  "loose": "LoosePort(value='443')", "guarded": "GuardedPort(value=443)",
#  "hash_equal": True}

Use only the standard library.

Convert a port number

to_port(raw: object) -> int accepts an int or a string of digits and returns a port from 1 to 65535. Leading zeros are fine, so "00443" becomes 443. Check in this order:

  1. A bool raises TypeError("port must not be a bool").
  2. A string must pass raw.isdecimal(), or raise ValueError(f"port is not numeric: {raw!r}"). isdecimal() rejects signs, spaces, decimal points and superscripts such as "²". Convert a string that passes with int(raw), and let any error from that call propagate.
  3. An int stays as it is. Any other type raises TypeError(f"port must be int or str, got {type(raw).__name__}").
  4. A number outside 1 to 65535 raises ValueError(f"port out of range: {number}").

So the string "-1" is not numeric, and the integer -1 is out of range.

Write three classes

  • Port is a frozen dataclass with slots=True and one field, value: int. Add a classmethod parse(raw: object) that calls to_port and builds a Port from the result. Assigning to value raises FrozenInstanceError, and equal ports have equal hashes. Calling Port(...) directly skips parse, so only parse checks the value.
  • LoosePort is a plain dataclass with value: int and no checks. Python does not check annotations at runtime, so code can store a string in it.
  • GuardedPort is a plain class with __slots__ = ("value",) and the annotation value: int. Its __init__ accepts any object. Write a __setattr__ that runs every assignment to value through to_port and stores the result with object.__setattr__. For any other name, raise AttributeError(f"unknown attribute {name!r}"). Its repr is GuardedPort(value=<the int>).

Python calls __setattr__ for every obj.name = value, including the assignment in your own __init__. Inside __setattr__, the line self.value = ... would call __setattr__ again, without end. That is why you store through object.__setattr__. Because to_port raises before you store anything, a rejected assignment keeps the old value.

What solve returns

solve(initial, reassign) returns a dict with five keys. The starter's helpers _frozen_error, _loose_repr and _hash_equal already compute three of them.

Key Value
"port" repr(Port.parse(initial)), or the conversion error
"port_frozen" "FrozenInstanceError" after you try to assign reassign to the port's value, or "" if parsing failed
"loose" The repr of LoosePort(0) after you assign reassign to value. It ignores initial.
"guarded" The repr of GuardedPort(initial) after you assign reassign to value, or the first conversion error. If the constructor fails, skip the assignment.
"hash_equal" Whether Port.parse(8080) and Port.parse("8080") are equal and have equal hashes

Format a caught TypeError or ValueError as "<class name>: <message>", such as "ValueError: port out of range: 0". Compute each key on its own, so a bad initial does not stop the loose and hash_equal keys.

Assign reassign with setattr(instance, "value", reassign). mypy rejects guarded.value = reassign, because reassign is an object and the attribute is an int. Your __setattr__ accepts any input, but mypy checks the annotation.

Going further

The attrs library does this for you, with field(converter=...) and on_setattr hooks. A method such as set_value(raw: object) is another choice, and it shows the conversion in its signature.