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:
- Resolve its standard-library dataclass decorator. If none exists, return.
- If the decorator has the literal keyword
slots=True, return.
- If the class declares local
__slots__, return. Manual slots count as an
explicit layout decision, including layouts that deliberately contain
__dict__.
- If any hard hold-tongue condition below applies, return.
- 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
Summary
Add an opinionated Pylint checker for closed standard-library dataclasses that do
not use slots.
R9111prefer-slots-for-dataclassdf12-dataclass-slotsdf12_python_lints/dataclass_slots.pyR9101throughR9110The df12 house policy is intentionally strict:
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
Attach the diagnostic to the
dataclassdecorator expression rather than theClassDef. This gives editors a precise highlight and permits a narrowsuppression 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=Truewhen required.Slots are not a harmless text substitution in every class, however. They can be
ineffective when an unslotted base already supplies
__dict__; incompatiblewith
functools.cached_propertyand other dictionary-backed state; hazardousaround 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:
dataclasses.dataclass__slots__functools.cached_propertyand its__dict__requirementRecognition
Recognize the actual standard-library
dataclasses.dataclassdecorator throughscope-aware import resolution, including:
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 shadowedimport,
pydantic.dataclasses.dataclass, attrs, msgspec, or a decorator carryingtyping.dataclass_transformis outside this rule.dataclasses.make_dataclass(...)is also outside scope because there is noClassDefon which to report.Decision algorithm
For each
ClassDef:slots=True, return.__slots__, return. Manual slots count as anexplicit layout decision, including layouts that deliberately contain
__dict__.prefer-slots-for-dataclasswith the class name as the message argument.Require a lexically visible
TrueOnly the literal singleton
Truesatisfies the generated-slots check. These allremain violations:
Class layout should be obvious at the declaration site and must not vary through
configuration or indirection. An explicit
slots=Falseis not an opt-out. Whereslots 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-fielddataclasses 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:
functools.cached_property;__dict__;vars(instance_parameter);setattrordelattron the instance parameter;self.attrwhereattrcannot 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 isnot 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 declaredfield(init=False)assigned in__post_init__remains compatible and must notsuppress 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:
abc.ABCortyping.Protocol;abc.abstractmethod;__init_subclass__;identity-sensitive.
Do not infer extensibility from a
Mixinsuffix, public naming, export through__all__, or the mere absence oftyping.final. Naming heuristics are too softfor a hard lint.
3. Decorator order can capture the pre-slots class
With
slots=True,dataclassreturns a replacement class. A decorator closer tothe class body runs first and can retain the original class object.
This ordering is eligible because
dataclassruns first and outer decorators seethe replacement:
This ordering must make the checker hold its tongue:
In the second form,
registry.registercurrently sees the same class that themodule exports, but adding
slots=Truewould make that registry entry stale.The initial implementation may whitelist a proven identity-preserving marker
such as
typing.finalbelow the dataclass decorator, but every whitelist entrymust 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 thisexemption 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:
__dict__;lineage non-empty slots.
Treat
objectand 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=Truedeclarations 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 repositoryvisibility.
A new public leaf value type should establish its closed layout from its first
release:
An existing public dataclass may have consumers that use
vars(instance),instance.__dict__, monkeypatch attributes, or subclass it through incompatiblelayouts. The checker cannot infer release history or downstream use. Keep such a
class unslotted only with a narrow, explained suppression, for example:
The existing
lint-suppression-without-explanationchecker should police thereason.
slots=Falsemust not become a silent compatibility escape hatch.Examples
Report
Preferred
Preferred when weak references are required
Weak-reference use is not a reason to retain
__dict__, and the checker shouldnot attempt whole-program weak-reference inference.
No diagnostic because state is deliberately open
No diagnostic because decorator ordering is identity-sensitive
Implementation guidance
DataclassSlotsChecker(checkers.BaseChecker)withvisit_classdef.string matching without verifying the import binding.
extraction, direct-method state analysis, and base-layout classification.
quadratic repeated inference.
ClassDef.slots()where it is reliable, but retain explicithandling for generated dataclass slots and inference failure.
An unresolved symbol should suppress R9111, not crash Pylint.
convention, not a general-purpose policy engine.
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.pyusingpylint.testutils.CheckerTestCase.At minimum, cover the following matrix.
Must report
@dataclasses.dataclass;@dataclasses.dataclass();slots=False;eq=False, keyword-only, public, private, nested,and zero-field leaf dataclasses;
slot-only;
weakref_slot=Truewithout literalslots=True.Must remain silent
slots=True;slots=True, weakref_slot=True;__slots__;dataclass;dataclass_transformdecorators;cached_property,self.__dict__,vars(self), dynamic attribute names, andassignments to undeclared instance attributes;
init=Falsefields assigned in__post_init__only because those areslot-compatible;
abc.ABC,typing.Protocol,abstractmethod, and__init_subclass__;super()or a direct__class__closure;later combined as multiple parents;
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 shrunkcounterexample found during implementation as a normal regression test.
Update the end-to-end shim test so
R9111fires in a fixture and a clean slotteddataclass remains silent.
Repository integration
After #6 lands:
DataclassSlotsCheckerindf12_python_lints.__init__.register;__all__;messages;
docs/users-guide.md;in
docs/developers-guide.md;tests/test_plugin.pywithdf12-dataclass-slots;make alland the opt-in property/model checks used by the repository.Out of scope
frozen=Trueortyping.final;__slots__declaration;vars()or monkeypatch use;weakref_slot=Truethrough whole-program analysis;dataclasses.make_dataclass;Acceptance criteria
R9111is registered asprefer-slots-for-dataclass.slots=Trueor explicit local__slots__satisfies the rule.make allpasses.