Skip to content

← Modelling Data with Dataclasses step 10 of 28

Medium Primitives

Freeze Nested Data into a Hashable Cache Key

You cache the result of a slow report, keyed by the settings that produced it. The settings arrive as a nested dict, so you wrap them in a frozen dataclass to use as the key. Building the key works. Hashing it does not:

from collections.abc import Hashable
from dataclasses import dataclass


@dataclass(frozen=True)
class CacheKey:
    name: str
    payload: Hashable


key = CacheKey("report", {"region": "eu", "days": [1, 2]})
hash(key)  # TypeError: unhashable type: 'dict'

The generated hash of a frozen dataclass hashes its fields, and a dict has no hash. frozen=True stops you from assigning a new payload, but it does not freeze the dict inside. Convert the nested data into tuples and frozensets, which are hashable, before you build the key:

deep_freeze({"b": 1, "a": [1, 2]})
# (("a", (1, 2)), ("b", 1))

What to write

Fill in deep_freeze(obj). Convert the value with the first row that matches, and freeze every value inside it the same way:

Input Output
a Mapping a tuple of (key, frozen_value) pairs, sorted by key
a set or frozenset a frozenset of frozen elements
a list or tuple a tuple of frozen elements, in the original order
a dataclass instance (class_name, field_pairs), described below
anything else the value itself
  • For a dataclass instance, class_name is type(obj).__name__. field_pairs is a tuple of (field_name, frozen_value) pairs in dataclasses.fields order, not sorted. Test for an instance with is_dataclass(obj) and not isinstance(obj, type).
  • Test isinstance(obj, (list, tuple)), not isinstance(obj, Sequence), so a string stays a string. A NamedTuple matches the tuple row.
  • Keep duplicates in lists and tuples. Sets drop duplicates as usual.
  • Do not change the input.

Mapping keys are strings. Every other value is a string, an integer, a finite float, a boolean, bytes or None.

An empty mapping becomes (). A nested set becomes a frozenset, not a sorted list. The tests check the container types as well as their contents.

Complete CacheKey and solve

Decorate CacheKey with frozen=True and slots=True, and keep its fields name: str and payload: Hashable.

In solve(config), build:

  • frozen, the result of deep_freeze(config).
  • key, a CacheKey named "k" that holds frozen.
  • twin, a second CacheKey named "k" that holds a new deep_freeze(config).
  • raw_key, a CacheKey named "k" that holds the raw config, passed as cast(Hashable, config). cast tells mypy to treat the dict as Hashable. It does not change the object, so hashing raw_key still fails.

Return these keys:

Key Value
"frozen" frozen
"raw_hashable" whether hash(config) succeeds
"frozen_hashable" whether hash(frozen) succeeds
"raw_key_hashable" whether hash(raw_key) succeeds
"key_hash_stable" whether key is hashable and hash(key) == hash(twin)
"key_equal" whether key == twin
"mutation_error" what _mutation_error(key) returns

Use the supplied _hashable and _mutation_error helpers, and do not change them. FrozenInstanceError is a subclass of AttributeError, so _mutation_error catches it first.

For the config in the example, the three hashability flags are False, True and False. key_hash_stable and key_equal are True, and mutation_error is "FrozenInstanceError".

Going further

This format loses a few differences. A list and a tuple with the same elements freeze to the same value. So do two dataclasses with the same class name and fields. For a real cache, check that the key keeps every difference your computation depends on.