Skip to content

← Data Modelling and Invariants step 21 of 25

Medium Primitives

__post_init__ and InitVar: parameters that are consumed, not stored

The way a psycopg connection ends up inside a JSON response body is this: someone needed a database handle inside a domain object, stored it as a real field, and six months later a different someone wrote json.dumps(asdict(order)). InitVar is the mechanism that stops it — a constructor parameter that is consumed, not kept.

__post_init__

The generated __init__ ends with a call to self.__post_init__(...) if and only if the class defines one. Facts worth having in muscle memory:

  • It is not called at all when init=False, because there is no generated __init__ to call it from. Putting your invariant check there and then adding init=False silently disables the check.
  • A dataclass __init__ never calls super().__init__(). If you inherit from a non-dataclass that needs initialising, __post_init__ is where you do it, by hand.
  • On a frozen dataclass, self.x = ... inside __post_init__ raises FrozenInstanceError like anywhere else. The sanctioned escape is object.__setattr__(self, "x", value) — which is exactly what the generated frozen __init__ does for every field.

InitVar[T]

rounding: InitVar[str] = "half_even" produces a field entry whose _field_type is _FIELD_INITVAR. Consequences:

  • it becomes an __init__ parameter, in declaration order, with its default;
  • it is forwarded positionally to __post_init__, in declaration order, so the parameter names in __post_init__ do not have to match — but the order does;
  • it is not stored on the instance, and does not appear in fields(), __repr__, __eq__, asdict() or replace().

That last line is the whole point. The value shapes construction and then disappears. A database session, a clock, a feature-flag snapshot, a rounding policy — all belong here, not in a field.

The derived-field pattern

minor_units: int = field(init=False, default=0)

init=False keeps it out of the constructor; __post_init__ computes it once and writes it with object.__setattr__. It is in fields(), repr, eq and asdict(), because it is a real part of the value. (replace() will recompute it — see the replace() lesson.)


Your task

Complete the frozen Money:

  • amount: Decimal and currency: str are ordinary fields;
  • minor_units: int is derived — init=False, computed in __post_init__;
  • rounding: InitVar[str] defaults to "half_even" and is never stored.

__post_init__ must:

  1. raise ValueError(f"unknown ISO-4217 currency: {currency!r}") if the currency is not in MINOR_UNIT_EXPONENT (note the !r — the message is asserted);
  2. raise ValueError(f"unknown rounding mode: {rounding!r}") for an unknown mode;
  3. otherwise set minor_units to the amount expressed in the currency’s smallest unit, quantized with the requested rounding. USD has exponent 2 (so 10.001000), JPY has exponent 0 (12341234), KWD has 3. Use Decimal(1).scaleb(-exponent) as the quantize step and int(quantized.scaleb(exponent)) to get the integer.

Then implement solve(amount, currency, rounding) -> dict[str, object] returning:

  • "error" — the exception message, or "" on success (with the other keys empty/false);
  • "field_names"[f.name for f in fields(money)];
  • "as_dict"asdict(money) with every value passed through str();
  • "eq_vs_half_even" — whether this Money equals the same amount and currency built with "half_even".

That last key is the sharp edge. rounding is not a field, so equality never looks at it — but minor_units is, and rounding changes it. 1.005 USD rounds to 100 under half-even and 101 under half-up, so the two objects are not equal even though the thing that made them differ is invisible in the repr. An InitVar is excluded from equality; its effects are not.

Types. mypy models __post_init__ as overriding a method on a synthetic supertype, so if you get an InitVar parameter type wrong the error is reported against “supertype dataclass — confusing the first time, unambiguous once you have seen it.