Skip to content

Commit 1c496e9

Browse files
adamtuftDanielNoordPierre-Sassoulas
authored
Add a new declare-non-slot error code (#9564)
--------- Co-authored-by: Daniël van Noord <13665637+DanielNoord@users.noreply.github.com> Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
1 parent afd5edf commit 1c496e9

File tree

9 files changed

+171
-8
lines changed

9 files changed

+171
-8
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Student:
2+
__slots__ = ("name",)
3+
4+
name: str
5+
surname: str # [declare-non-slot]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Student:
2+
__slots__ = ("name", "surname")
3+
4+
name: str
5+
surname: str

doc/user_guide/checkers/features.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ Classes checker Messages
282282
Used when a method has an attribute different the "self" as first argument.
283283
This is considered as an error since this is a so common convention that you
284284
shouldn't break it!
285+
:declare-non-slot (E0245): *No such name %r in __slots__*
286+
Raised when a type annotation on a class is absent from the list of names in
287+
__slots__, and __slots__ does not contain a __dict__ entry.
285288
:unexpected-special-method-signature (E0302): *The special method %r expects %s param(s), %d %s given*
286289
Emitted when a special method was defined with an invalid number of
287290
parameters. If it has too few or too many, it might not work at all.

doc/user_guide/messages/messages_overview.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ All messages in the error category:
6969
error/catching-non-exception
7070
error/class-variable-slots-conflict
7171
error/continue-in-finally
72+
error/declare-non-slot
7273
error/dict-iter-missing-items
7374
error/duplicate-argument-name
7475
error/duplicate-bases

doc/whatsnew/fragments/9499.feature

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add new `declare-non-slot` error which reports when a class has a `__slots__` member and a type hint on the class is not present in `__slots__`.
2+
3+
Refs #9499

pylint/checkers/classes/class_checker.py

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,12 @@ def _has_same_layout_slots(
702702
"Used when a class tries to extend an inherited Enum class. "
703703
"Doing so will raise a TypeError at runtime.",
704704
),
705+
"E0245": (
706+
"No such name %r in __slots__",
707+
"declare-non-slot",
708+
"Raised when a type annotation on a class is absent from the list of names in __slots__, "
709+
"and __slots__ does not contain a __dict__ entry.",
710+
),
705711
"R0202": (
706712
"Consider using a decorator instead of calling classmethod",
707713
"no-classmethod-decorator",
@@ -870,6 +876,7 @@ def _dummy_rgx(self) -> Pattern[str]:
870876
"invalid-enum-extension",
871877
"subclassed-final-class",
872878
"implicit-flag-alias",
879+
"declare-non-slot",
873880
)
874881
def visit_classdef(self, node: nodes.ClassDef) -> None:
875882
"""Init visit variable _accessed."""
@@ -878,6 +885,50 @@ def visit_classdef(self, node: nodes.ClassDef) -> None:
878885
self._check_proper_bases(node)
879886
self._check_typing_final(node)
880887
self._check_consistent_mro(node)
888+
self._check_declare_non_slot(node)
889+
890+
def _check_declare_non_slot(self, node: nodes.ClassDef) -> None:
891+
if not self._has_valid_slots(node):
892+
return
893+
894+
slot_names = self._get_classdef_slots_names(node)
895+
896+
# Stop if empty __slots__ in the class body, this likely indicates that
897+
# this class takes part in multiple inheritance with other slotted classes.
898+
if not slot_names:
899+
return
900+
901+
# Stop if we find __dict__, since this means attributes can be set
902+
# dynamically
903+
if "__dict__" in slot_names:
904+
return
905+
906+
for base in node.bases:
907+
ancestor = safe_infer(base)
908+
if not isinstance(ancestor, nodes.ClassDef):
909+
continue
910+
# if any base doesn't have __slots__, attributes can be set dynamically, so stop
911+
if not self._has_valid_slots(ancestor):
912+
return
913+
for slot_name in self._get_classdef_slots_names(ancestor):
914+
if slot_name == "__dict__":
915+
return
916+
slot_names.append(slot_name)
917+
918+
# Every class in bases has __slots__, our __slots__ is non-empty and there is no __dict__
919+
920+
for child in node.body:
921+
if isinstance(child, nodes.AnnAssign):
922+
if child.value is not None:
923+
continue
924+
if isinstance(child.target, nodes.AssignName):
925+
if child.target.name not in slot_names:
926+
self.add_message(
927+
"declare-non-slot",
928+
args=child.target.name,
929+
node=child.target,
930+
confidence=INFERENCE,
931+
)
881932

882933
def _check_consistent_mro(self, node: nodes.ClassDef) -> None:
883934
"""Detect that a class has a consistent mro or duplicate bases."""
@@ -1482,6 +1533,24 @@ def _check_functools_or_not(self, decorator: nodes.Attribute) -> bool:
14821533

14831534
return "functools" in dict(import_node.names)
14841535

1536+
def _has_valid_slots(self, node: nodes.ClassDef) -> bool:
1537+
if "__slots__" not in node.locals:
1538+
return False
1539+
1540+
for slots in node.ilookup("__slots__"):
1541+
# check if __slots__ is a valid type
1542+
if isinstance(slots, util.UninferableBase):
1543+
return False
1544+
if not is_iterable(slots) and not is_comprehension(slots):
1545+
return False
1546+
if isinstance(slots, nodes.Const):
1547+
return False
1548+
if not hasattr(slots, "itered"):
1549+
# we can't obtain the values, maybe a .deque?
1550+
return False
1551+
1552+
return True
1553+
14851554
def _check_slots(self, node: nodes.ClassDef) -> None:
14861555
if "__slots__" not in node.locals:
14871556
return
@@ -1515,13 +1584,19 @@ def _check_slots(self, node: nodes.ClassDef) -> None:
15151584
continue
15161585
self._check_redefined_slots(node, slots, values)
15171586

1518-
def _check_redefined_slots(
1519-
self,
1520-
node: nodes.ClassDef,
1521-
slots_node: nodes.NodeNG,
1522-
slots_list: list[nodes.NodeNG],
1523-
) -> None:
1524-
"""Check if `node` redefines a slot which is defined in an ancestor class."""
1587+
def _get_classdef_slots_names(self, node: nodes.ClassDef) -> list[str]:
1588+
1589+
slots_names = []
1590+
for slots in node.ilookup("__slots__"):
1591+
if isinstance(slots, nodes.Dict):
1592+
values = [item[0] for item in slots.items]
1593+
else:
1594+
values = slots.itered()
1595+
slots_names.extend(self._get_slots_names(values))
1596+
1597+
return slots_names
1598+
1599+
def _get_slots_names(self, slots_list: list[nodes.NodeNG]) -> list[str]:
15251600
slots_names: list[str] = []
15261601
for slot in slots_list:
15271602
if isinstance(slot, nodes.Const):
@@ -1531,6 +1606,16 @@ def _check_redefined_slots(
15311606
inferred_slot_value = getattr(inferred_slot, "value", None)
15321607
if isinstance(inferred_slot_value, str):
15331608
slots_names.append(inferred_slot_value)
1609+
return slots_names
1610+
1611+
def _check_redefined_slots(
1612+
self,
1613+
node: nodes.ClassDef,
1614+
slots_node: nodes.NodeNG,
1615+
slots_list: list[nodes.NodeNG],
1616+
) -> None:
1617+
"""Check if `node` redefines a slot which is defined in an ancestor class."""
1618+
slots_names: list[str] = self._get_slots_names(slots_list)
15341619

15351620
# Slots of all parent classes
15361621
ancestors_slots_names = {

tests/functional/r/regression_02/regression_5479.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Test for a regression on slots and annotated assignments.
22
Reported in https://github.com/pylint-dev/pylint/issues/5479
33
"""
4-
# pylint: disable=too-few-public-methods, unused-private-member, missing-class-docstring, missing-function-docstring
4+
# pylint: disable=too-few-public-methods, unused-private-member, missing-class-docstring, missing-function-docstring, declare-non-slot
55

66
from __future__ import annotations
77

tests/functional/s/slots_checks.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,62 @@ class Parent:
128128

129129
class ChildNotAffectedByValueInSlot(Parent):
130130
__slots__ = ('first', )
131+
132+
133+
class ClassTypeHintNotInSlotsWithoutDict:
134+
__slots__ = ("a", "b")
135+
136+
a: int
137+
b: str
138+
c: bool # [declare-non-slot]
139+
140+
141+
class ClassTypeHintNotInSlotsWithDict:
142+
__slots__ = ("a", "b", "__dict__")
143+
144+
a: int
145+
b: str
146+
c: bool
147+
148+
149+
class BaseNoSlots:
150+
pass
151+
152+
153+
class DerivedWithSlots(BaseNoSlots):
154+
__slots__ = ("age",)
155+
156+
price: int
157+
158+
159+
class BaseWithSlots:
160+
__slots__ = ("a", "b",)
161+
162+
163+
class DerivedWithMoreSlots(BaseWithSlots):
164+
__slots__ = ("c",)
165+
166+
# Is in base __slots__
167+
a: int
168+
169+
# Not in any base __slots__
170+
d: int # [declare-non-slot]
171+
e: str= "AnnAssign.value is not None"
172+
173+
174+
class BaseWithSlotsDict:
175+
__slots__ = ("__dict__", )
176+
177+
class DerivedTypeHintNotInSlots(BaseWithSlotsDict):
178+
__slots__ = ("other", )
179+
180+
a: int
181+
def __init__(self) -> None:
182+
super().__init__()
183+
self.a = 42
184+
185+
186+
class ClassWithEmptySlotsAndAnnotation:
187+
__slots__ = ()
188+
189+
a: int

tests/functional/s/slots_checks.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ invalid-slots:81:0:81:16:TwelfthBad:Invalid __slots__ object:UNDEFINED
1414
class-variable-slots-conflict:114:17:114:24:ValueInSlotConflict:Value 'first' in slots conflicts with class variable:UNDEFINED
1515
class-variable-slots-conflict:114:45:114:53:ValueInSlotConflict:Value 'fourth' in slots conflicts with class variable:UNDEFINED
1616
class-variable-slots-conflict:114:36:114:43:ValueInSlotConflict:Value 'third' in slots conflicts with class variable:UNDEFINED
17+
declare-non-slot:138:4:138:5:ClassTypeHintNotInSlotsWithoutDict:No such name 'c' in __slots__:INFERENCE
18+
declare-non-slot:170:4:170:5:DerivedWithMoreSlots:No such name 'd' in __slots__:INFERENCE

0 commit comments

Comments
 (0)