Skip to content

← Modelling Data with Dataclasses step 17 of 28

Hard Framework

Update a Frozen Record with replace()

A thermometer reports in Celsius or Kelvin. Your frozen Reading record takes the number and its scale, and works out Fahrenheit in __post_init__. The scale is an InitVar, which you met in the exercise Compute Cents from an Amount with __post_init__. The record uses it once and does not store it. Now copy a Kelvin reading with dataclasses.replace and change nothing:

solve(373.15, "k", {}, 5, 3)
# {"error": "", "celsius": 373.15, "fahrenheit": 703.67,
#  "base_fahrenheit": 212.0, "duration": 3}

The copy says 703.67 degrees Fahrenheit instead of 212. replace builds the copy by calling __init__ with the stored fields, and __init__ runs __post_init__ again. The record never stored the scale, so __post_init__ gets the default, "c", and reads 373.15 as Celsius. Passing {"scale": "k"} as the change keeps the Kelvin meaning.

This exercise shows the bug on purpose. In your own code, store the scale as a real field, or convert to one unit in __post_init__ and name the field after that unit. The exercise needs Python 3.13 or newer, for copy.replace.

Complete Reading

  • Keep Reading frozen. Declare celsius: float, then scale: InitVar[str] = "c", then fahrenheit: float = field(init=False, default=0.0).
  • Write __post_init__(self, scale: str) -> None, and compute fahrenheit from this table:
scale fahrenheit
"c" self.celsius * 9.0 / 5.0 + 32.0
"k" (self.celsius - 273.15) * 9.0 / 5.0 + 32.0
anything else raise ValueError(f"unknown scale: {scale!r}")
  • Store the result with object.__setattr__, because the record is frozen.
  • celsius keeps the number exactly as given, for either scale. Do not convert or overwrite it.
  • Inputs are finite numbers. You do not need to check that a temperature is possible.

Describe the changes with a TypedDict

EvolveArgs lists the changes that evolve accepts. Declare it as a TypedDict with total=False, which makes every key optional. Give it two keys, celsius: float and scale: str. An empty dictionary is a valid set of changes.

Write evolve

evolve(obj, changes) returns dataclasses.replace(obj, **changes).

  • If changes has a "fahrenheit" key, raise TypeError before you call replace. __post_init__ computes fahrenheit, so callers must not set it.
  • Do not change obj or changes.
  • Any other unknown key reaches __init__, which raises TypeError.

Keep the EvolveArgs type on the parameter. solve passes the runtime dictionary in with cast(EvolveArgs, changes). A cast only tells mypy what type to assume, and it does not check or remove keys. So the tests can still send keys such as "bogus".

Add replace to Duration

copy.replace(obj, **changes) makes a changed copy of any object that has a __replace__ method. Dataclasses get one from the decorator. Duration is a plain class, so you write it:

  • __replace__(self, /, **changes: int) -> Duration returns a new Duration whose seconds are changes.get("seconds", self.seconds). The original stays unchanged.
  • It must handle no changes and a seconds change. No other keys reach it. Accept any integer, including a negative one.

Write solve

  1. Build base = Reading(celsius, scale).
  2. Call evolve(base, cast(EvolveArgs, changes)).
  3. If step 1 or 2 raises TypeError or ValueError, put the exception's class name, such as "TypeError", in "error". Return 0.0 for the three temperature keys and 0 for "duration", even if step 1 succeeded.
  4. Otherwise, call copy.replace(Duration(duration_seconds), seconds=duration_change) and return:
Key Value
"error" ""
"celsius" evolved.celsius
"fahrenheit" evolved.fahrenheit
"base_fahrenheit" base.fahrenheit
"duration" the new Duration's seconds

Do not round the temperatures. Your code must pass mypy --strict.