Skip to content

Add R9111 to prefer slots for closed dataclass value types #11

Description

@leynos

Summary

Add an opinionated Pylint checker for closed standard-library dataclasses that do
not use slots.

Field Value
Message ID R9111
Symbol prefer-slots-for-dataclass
Checker name df12-dataclass-slots
Proposed module df12_python_lints/dataclass_slots.py
Dependency Land after #6, which currently allocates R9101 through R9110

The df12 house policy is intentionally strict:

A standard-library dataclass is presumed to describe a closed set of instance
state. It must use slots unless the source contains concrete evidence that the
type is deliberately open or extensible, or that dataclass(slots=True) would
be unsafe or ineffective.

Exported or public-library status is not an automatic exemption. This package
is internal to df12-productions, so compatibility exceptions should be explicit,
local, and explained rather than inferred from naming or __all__.

Message definition

_MSGS = {
    "R9111": (
        "Dataclass %r should declare slots=True",
        "prefer-slots-for-dataclass",
        (
            "Emitted when a standard-library dataclass appears to define closed "
            "instance state but neither requests generated slots nor declares an "
            "explicit __slots__ layout. Use dataclass(slots=True), adding "
            "weakref_slot=True when weak references are required. Intentional "
            "open-state or compatibility exceptions require an explained local "
            "suppression."
        ),
    ),
}

Attach the diagnostic to the dataclass decorator expression rather than the
ClassDef. This gives editors a precise highlight and permits a narrow
suppression on the decorator line.

Why this policy exists

dataclass(slots=True) generates a slot layout and returns a replacement class.
For ordinary value objects this makes their state explicit, removes accidental
attribute creation, and avoids an instance dictionary. Weak-reference support is
orthogonal and should be requested with weakref_slot=True when required.

Slots are not a harmless text substitution in every class, however. They can be
ineffective when an unslotted base already supplies __dict__; incompatible
with functools.cached_property and other dictionary-backed state; hazardous
around class identity capture; and constrained by Python's multiple-inheritance
layout rules. The checker therefore needs a deliberately narrow set of
hard, evidence-based exemptions.

References:

Recognition

Recognize the actual standard-library dataclasses.dataclass decorator through
scope-aware import resolution, including:

import dataclasses

@dataclasses.dataclass
class A: ...
import dataclasses as dc

@dc.dataclass()
class B: ...
from dataclasses import dataclass as record

@record
class C: ...

The direct import remains recognizable even though df12's normal Ruff policy
forbids it.

Do not match solely by spelling. A local function named dataclass, a shadowed
import, pydantic.dataclasses.dataclass, attrs, msgspec, or a decorator carrying
typing.dataclass_transform is outside this rule.

dataclasses.make_dataclass(...) is also outside scope because there is no
ClassDef on which to report.

Decision algorithm

For each ClassDef:

  1. Resolve its standard-library dataclass decorator. If none exists, return.
  2. If the decorator has the literal keyword slots=True, return.
  3. If the class declares local __slots__, return. Manual slots count as an
    explicit layout decision, including layouts that deliberately contain
    __dict__.
  4. If any hard hold-tongue condition below applies, return.
  5. Emit prefer-slots-for-dataclass with the class name as the message argument.

Require a lexically visible True

Only the literal singleton True satisfies the generated-slots check. These all
remain violations:

@dataclasses.dataclass
@dataclasses.dataclass()
@dataclasses.dataclass(slots=False)
@dataclasses.dataclass(slots=1)
@dataclasses.dataclass(slots=SLOTS)
@dataclasses.dataclass(**DATACLASS_OPTIONS)

Class layout should be obvious at the declaration site and must not vary through
configuration or indirection. An explicit slots=False is not an opt-out. Where
slots are intentionally impossible, use an explained Pylint suppression so the
reason survives beside the decision.

Other dataclass options do not alter eligibility. In particular, frozen=True,
eq=False, order=True, kw_only=True, unsafe_hash=True, and zero-field
dataclasses neither force nor suppress the message.

Hard hold-tongue conditions

These conditions suppress R9111 because adding generated slots would be unsafe,
ineffective, or semantically misleading. They are not general endorsements of
the underlying design.

1. The class visibly requires open instance state

Hold the diagnostic when any direct method of the class contains evidence that
an instance dictionary or undeclared state is part of the design:

  • a method decorated with the real functools.cached_property;
  • access to the instance's __dict__;
  • vars(instance_parameter);
  • dynamic setattr or delattr on the instance parameter;
  • assignment, augmented assignment, or deletion of self.attr where attr
    cannot be resolved to a dataclass field, an inherited field, a declared slot,
    or another class/inherited descriptor;
  • object.__setattr__ with a non-literal name, or with a literal name that is
    not declared instance state.

Use each method's actual first instance parameter rather than assuming it is
named self. Ignore static methods and nested classes. A declared
field(init=False) assigned in __post_init__ remains compatible and must not
suppress the rule.

Inference ambiguity should silence this checker rather than produce a speculative
message. The policy is draconian where the checker has evidence, not clairvoyant.

2. The class is an explicit extension boundary

Hold the diagnostic for:

  • classes deriving from the real abc.ABC or typing.Protocol;
  • classes containing a method decorated with the real abc.abstractmethod;
  • classes defining __init_subclass__;
  • classes whose explicit metaclass or class-header keywords make class creation
    identity-sensitive.

Do not infer extensibility from a Mixin suffix, public naming, export through
__all__, or the mere absence of typing.final. Naming heuristics are too soft
for a hard lint.

3. Decorator order can capture the pre-slots class

With slots=True, dataclass returns a replacement class. A decorator closer to
the class body runs first and can retain the original class object.

This ordering is eligible because dataclass runs first and outer decorators see
the replacement:

@registry.register
@dataclasses.dataclass
class Event:
    name: str

This ordering must make the checker hold its tongue:

@dataclasses.dataclass
@registry.register
class Event:
    name: str

In the second form, registry.register currently sees the same class that the
module exports, but adding slots=True would make that registry entry stale.

The initial implementation may whitelist a proven identity-preserving marker
such as typing.final below the dataclass decorator, but every whitelist entry
must have an explicit regression test. Unknown inner decorators always suppress
the diagnostic. Unknown outer decorators do not.

4. Python 3.12/3.13 class-cell hazards are present

The package currently supports Python 3.12 and later. Hold the diagnostic when a
direct method uses zero-argument super() or directly closes over __class__.
On the supported older runtimes the replacement class can leave those closures
bound to the original class.

Two-argument super(CurrentClass, self) does not suppress the rule. Revisit this
exemption when the package minimum moves beyond the affected runtimes.

5. The inherited layout is not provably slot-only

Adding slots to a subclass of an unslotted base does not remove the inherited
instance dictionary, so R9111 would promise a closed layout it cannot deliver.
Hold the diagnostic when any base lineage is:

  • known to provide __dict__;
  • unknown or not safely inferable;
  • a variable-length built-in that cannot accept a non-empty slot layout; or
  • part of a multiple-inheritance shape that would give more than one parent
    lineage non-empty slots.

Treat object and inferred empty-slot marker bases as layout-neutral.

Base analysis should be transitive. For a local dataclass base that is itself
eligible for R9111, treat it as prospectively slotted so a safe single-inheritance
hierarchy can report all missing slots=True declarations in one run.

Also make a module-level reverse pass over local inheritance. If a dataclass is
used as one of multiple direct bases, suppress R9111 on that base as well as on
the multiply inherited subclass. Otherwise independently slotting two formerly
unslotted dataclass bases can turn currently valid code into an instance-layout
conflict.

Cross-module reverse inheritance is unknowable to a normal Pylint checker. A
public base intentionally supporting downstream multiple inheritance therefore
needs an explained suppression; publicness alone still does not suppress the
message.

Public API policy

Do not inspect leading underscores, __all__, package exports, or repository
visibility.

A new public leaf value type should establish its closed layout from its first
release:

@typing.final
@dataclasses.dataclass(frozen=True, slots=True)
class Coordinate:
    latitude: float
    longitude: float

An existing public dataclass may have consumers that use vars(instance),
instance.__dict__, monkeypatch attributes, or subclass it through incompatible
layouts. The checker cannot infer release history or downstream use. Keep such a
class unslotted only with a narrow, explained suppression, for example:

@dataclasses.dataclass  # pylint: disable=prefer-slots-for-dataclass  # compatibility: consumers attach adapter state dynamically
class LegacyRecord:
    value: str

The existing lint-suppression-without-explanation checker should police the
reason. slots=False must not become a silent compatibility escape hatch.

Examples

Report

import dataclasses

@dataclasses.dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float
R9111: Dataclass 'Coordinate' should declare slots=True (prefer-slots-for-dataclass)

Preferred

import dataclasses

@dataclasses.dataclass(frozen=True, slots=True)
class Coordinate:
    latitude: float
    longitude: float

Preferred when weak references are required

@dataclasses.dataclass(slots=True, weakref_slot=True)
class Node:
    value: str

Weak-reference use is not a reason to retain __dict__, and the checker should
not attempt whole-program weak-reference inference.

No diagnostic because state is deliberately open

import dataclasses
import functools

@dataclasses.dataclass
class Report:
    source: str

    @functools.cached_property
    def rendered(self) -> str:
        return render(self.source)

No diagnostic because decorator ordering is identity-sensitive

@dataclasses.dataclass
@register_model
class Model:
    name: str

Implementation guidance

  • Implement a DataclassSlotsChecker(checkers.BaseChecker) with
    visit_classdef.
  • Keep import/decorator recognition binding-aware. Do not use raw qualified-name
    string matching without verifying the import binding.
  • Prefer small pure helpers for decorator classification, literal keyword
    extraction, direct-method state analysis, and base-layout classification.
  • Cache base-layout and local reverse-inheritance analysis per module to avoid
    quadratic repeated inference.
  • Use Astroid's ClassDef.slots() where it is reliable, but retain explicit
    handling for generated dataclass slots and inference failure.
  • Catch only the specific Astroid inference exceptions expected by the helper.
    An unresolved symbol should suppress R9111, not crash Pylint.
  • Do not execute or import the linted program.
  • Do not mutate the AST.
  • Do not add a configuration knob or decorator allowlist in v1. This is a house
    convention, not a general-purpose policy engine.
  • Do not provide an automatic fix. The replacement-class, inheritance, and
    compatibility hazards require a human decision even when the emitted case is
    intended to be safe.

If the checker approaches the repository's 400-line module limit, split reusable
classification into a focused private module rather than compressing the logic.

Verification

Add tests/test_dataclass_slots.py using pylint.testutils.CheckerTestCase.
At minimum, cover the following matrix.

Must report

  • bare @dataclasses.dataclass;
  • @dataclasses.dataclass();
  • slots=False;
  • non-literal and indirect slot values;
  • module and decorator import aliases;
  • frozen, mutable, ordered, eq=False, keyword-only, public, private, nested,
    and zero-field leaf dataclasses;
  • a safe outer decorator with dataclass innermost;
  • a safe local single-inheritance chain whose bases are already or prospectively
    slot-only;
  • weakref_slot=True without literal slots=True.

Must remain silent

  • literal slots=True;
  • slots=True, weakref_slot=True;
  • explicit local __slots__;
  • a shadowed or unrelated decorator named dataclass;
  • pydantic, attrs, msgspec, and dataclass_transform decorators;
  • cached_property, self.__dict__, vars(self), dynamic attribute names, and
    assignments to undeclared instance attributes;
  • declared init=False fields assigned in __post_init__ only because those are
    slot-compatible;
  • abc.ABC, typing.Protocol, abstractmethod, and __init_subclass__;
  • class-header keywords or an explicit metaclass;
  • an unknown decorator below dataclass;
  • zero-argument super() or a direct __class__ closure;
  • an unslotted or unknown base;
  • unsafe multiple-inheritance layouts, including local dataclass bases that are
    later combined as multiple parents;
  • variable-length built-in bases;
  • inference failures.

Add a small Hypothesis property over irrelevant dataclass keyword combinations and
keyword ordering: for an otherwise eligible class, the checker is silent exactly
when the decorator contains the literal pair slots=True. Pin any shrunk
counterexample found during implementation as a normal regression test.

Update the end-to-end shim test so R9111 fires in a fixture and a clean slotted
dataclass remains silent.

Repository integration

After #6 lands:

  • register DataclassSlotsChecker in df12_python_lints.__init__.register;
  • export it through __all__;
  • update the package docstring and README message inventory from ten to eleven
    messages;
  • add the rule and its exceptions to docs/users-guide.md;
  • document the import-resolution, decorator-order, and layout-analysis strategy
    in docs/developers-guide.md;
  • update tests/test_plugin.py with df12-dataclass-slots;
  • update property and end-to-end inventories as applicable;
  • add no new runtime dependency beyond Pylint/Astroid;
  • pass make all and the opt-in property/model checks used by the repository.

Out of scope

  • requiring frozen=True or typing.final;
  • validating the contents of a manual __slots__ declaration;
  • detecting all downstream vars() or monkeypatch use;
  • choosing weakref_slot=True through whole-program analysis;
  • custom dataclass-like frameworks;
  • dataclasses.make_dataclass;
  • a codemod or Pylint autofix;
  • changing Python's general subclassing policy.

Acceptance criteria

  • R9111 is registered as prefer-slots-for-dataclass.
  • Real stdlib dataclass imports and aliases are recognized without spelling-only false positives.
  • Only a literal slots=True or explicit local __slots__ satisfies the rule.
  • All hard hold-tongue conditions above have focused regression tests.
  • Public/exported status alone never suppresses the diagnostic.
  • Intentional compatibility exceptions use ordinary explained Pylint suppressions.
  • The checker cannot crash on failed Astroid inference.
  • Plugin registration, documentation, property tests, and end-to-end coverage are updated.
  • make all passes.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions