Skip to content

← Modelling Data with Dataclasses step 14 of 28

Hard Framework

Check a Class Before You Add slots=True

Before you add slots=True to a group of dataclasses, you want a list of the classes that might break. The article Save Memory with slots=True named the two changes that cause trouble. Objects lose __dict__, and the decorator returns a new class. Write a checker that reads a class and names the risks it finds.

solve("Clean", [3, 12], [])               # []
solve("WithCachedProperty", [3, 12], [])  # ["cached-property"]
solve("UsesZeroArgSuper", [3, 12], [])    # ["zero-arg-super"]
solve("UsesZeroArgSuper", [3, 14], [])    # []

Terms you need

  • A descriptor is an object stored on a class. It runs code when you read or set the attribute of the same name on an instance. property and cached_property are descriptors. A descriptor can also define __set_name__(owner, name), which Python calls when it creates the class.
  • Zero-argument super() is a call to super() with no arguments inside a method. Python finds the class through a hidden variable named __class__, which holds the class you wrote the method in. On Python 3.12, slots=True leaves __class__ pointing at the old class, so the call raises TypeError. Python 3.14 fixed this. Recent 3.13 patch releases have the fix too, but older ones do not.
  • func.__code__.co_freevars lists the names a function reads from an enclosing scope. A method that calls super() has "__class__" there.
  • func.__code__.co_names lists the global and attribute names the code uses. self.__dict__ puts "__dict__" there.

What to write

Fill in slots_safety_check:

def slots_safety_check(
    cls: "type[DataclassInstance]",
    target_python: tuple[int, int],
    migrating: frozenset[str],
) -> list[str]:
  • Return the reasons sorted alphabetically, each one once.
  • Only read the class. Do not change it, create instances, call its methods or apply dataclass(slots=True).
  • target_python is the Python version you plan to deploy on. Use it only in the comparison with (3, 14), and ignore sys.version_info.

Check each entry the class defines

Loop over vars(cls).items(), the names and values the class defines itself. Skip inherited entries. For each value:

  • Add "cached-property" if the value is a functools.cached_property. It stores its result in __dict__.
  • Otherwise, add "set-name-descriptor" if hasattr(type(value), "__set_name__") is true. Python calls __set_name__ with the original class, before slots=True replaces it, so the descriptor remembers the wrong class. A value never gets both reasons.

Then call the supplied _as_function(value). It returns the function inside a method, a classmethod or a staticmethod, and None for anything else, such as a property or a cached_property. When it returns a function func, add every reason below that matches:

  • "zero-arg-super" if "__class__" is in func.__code__.co_freevars and target_python < (3, 14).
  • "instance-dict-access" if "__dict__" is in func.__code__.co_names.
  • "init-subclass-kwargs" if the entry's name is __init_subclass__ and inspect.signature(func) has more than one parameter. Count cls and parameters with defaults. When a subclass gets slots=True, the decorator builds its new class without the keywords from its class line. A required parameter then raises TypeError, and one with a default gets the default.

Do not look inside nested functions, property getters or other callable objects.

Check the base classes

A class cannot inherit from two unrelated classes that both have slots. Python raises TypeError: multiple bases have instance lay-out conflict. The rule here only approximates Python's. Loop over cls.__mro__[1:], every class that cls inherits from, and skip object. Count an ancestor when either of these is true:

  • Its __name__ is in migrating, the classes about to get slots=True.
  • bool(getattr(base, "__slots__", ())) is true, so it already has slots.

If more than one ancestor counts, add "multiple-slotted-bases". cls itself is not in cls.__mro__[1:], so here only the two mixins count:

solve("Combined", [3, 14], ["LeftMixin", "RightMixin", "Combined"])
# ["multiple-slotted-bases"]

What solve does

Keep solve and the fixture classes as they are. solve looks up target in REGISTRY, turns the [major, minor] list into a tuple and turns migrating into a frozenset. Every input names a class in REGISTRY.

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.

The zero-argument super() fix is CPython issue 90562.