Skip to content

← Modelling Data with Dataclasses step 24 of 28

Hard Framework

Diff Two Dataclass Records Field by Field

A user edits a profile, and the audit log should say which fields changed. You have the record from before the edit and the one from after. Write a function that compares two dataclass instances field by field. It reports each change with its path, such as inner.x for a field of a nested record.

solve("nested")
# {"error": "", "changes": {"inner.x": (1, 2)}}

Tell an instance from a class

Fill in is_dataclass_instance(obj), and keep its TypeIs return annotation. It returns True for a dataclass instance, and False for a dataclass class and for any other object. dataclasses.is_dataclass returns True for a dataclass and for its instances, so also rule out objects that are instances of type.

DataclassInstance is a type that exists only for the type checker and means "any dataclass instance". That is why the starter imports it under if TYPE_CHECKING: and writes it in quotes. Keep both.

Compare two records

Fill in diff(a, b). It returns a dict[str, tuple[object, object]] that maps each changed field's path to (old_value, new_value).

  1. If type(a) is not type(b), raise TypeError. That includes a base class and a subclass with the same fields.
  2. Visit dataclasses.fields(a) in declaration order. Skip a field whose compare is False.
  3. If both values are dataclass instances of the same class, call diff on them. Put the field name and a dot in front of each path it returns.
  4. Otherwise compare the values with !=. If they differ, record the field name with the tuple (old_value, new_value).

Some cases follow from these steps:

  • diff does not recurse into nested values of different classes. Step 4 compares them, so they appear as one change if they differ.
  • diff recurses only into dataclass instances. A list or a dict counts as one value, even if it holds dataclasses.
  • Store the original values, not copies, and leave both inputs unchanged.
  • Keep each change as a tuple. The tests tell a tuple from a list.

Write solve

solve(case) looks up a pair in CASES. Both values have the type object, so check each one with is_dataclass_instance before you call diff, and raise TypeError if either check fails. Return {"error": "", "changes": changes} on success. If the checks or diff raise TypeError, return {"error": "TypeError", "changes": {}}. Keep the supplied fixtures, and do not hard-code their results.

Case Result
identical No changes
nested "inner.x": (1, 2)
both "name": ("a", "b") and "inner.y": ("p", "q")
compare_false No changes, because only note differs and it has compare=False
class_arg TypeError, because Outer is a class, not an instance
different_classes TypeError, because Outer and Other are different classes

Type checking

Your code must pass mypy --strict without # type: ignore. Keep the signature diff[T: "DataclassInstance"](a: T, b: T). It does not make mypy check that a and b share a class, because mypy can choose DataclassInstance itself as T. That is why diff checks type(a) is type(b) when it runs.