Skip to content

Commit 62e8a63

Browse files
sonar-nigel[bot]sonartech
authored andcommitted
SONARPY-3952 Rule S8494 Attributes should only be assigned if they are declared in "__slots__" (#995)
GitOrigin-RevId: d6373896199c687df6e52bbbb71b8a717ac0ba19
1 parent 4cd3fdc commit 62e8a63

File tree

6 files changed

+1241
-0
lines changed

6 files changed

+1241
-0
lines changed

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ public Stream<Class<?>> getChecks() {
404404
SkLearnEstimatorDontInitializeEstimatedValuesCheck.class,
405405
SleepZeroInAsyncCheck.class,
406406
SortedMinMaxCheck.class,
407+
SlotsAssignmentCheck.class,
407408
SpecialMethodParamListCheck.class,
408409
SpecialMethodReturnTypeCheck.class,
409410
SQLQueriesCheck.class,
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import java.util.ArrayList;
20+
import java.util.HashSet;
21+
import java.util.List;
22+
import java.util.Objects;
23+
import java.util.Optional;
24+
import java.util.Set;
25+
import javax.annotation.Nullable;
26+
import org.sonar.check.Rule;
27+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
28+
import org.sonar.plugins.python.api.SubscriptionContext;
29+
import org.sonar.plugins.python.api.symbols.v2.SymbolV2;
30+
import org.sonar.plugins.python.api.symbols.v2.UsageV2;
31+
import org.sonar.plugins.python.api.types.BuiltinTypes;
32+
import org.sonar.plugins.python.api.tree.AssignmentStatement;
33+
import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
34+
import org.sonar.plugins.python.api.tree.ClassDef;
35+
import org.sonar.plugins.python.api.tree.CompoundAssignmentStatement;
36+
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
37+
import org.sonar.plugins.python.api.tree.Expression;
38+
import org.sonar.plugins.python.api.tree.ExpressionList;
39+
import org.sonar.plugins.python.api.tree.FunctionDef;
40+
import org.sonar.plugins.python.api.tree.KeyValuePair;
41+
import org.sonar.plugins.python.api.tree.ListLiteral;
42+
import org.sonar.plugins.python.api.tree.Name;
43+
import org.sonar.plugins.python.api.tree.Parameter;
44+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
45+
import org.sonar.plugins.python.api.tree.RegularArgument;
46+
import org.sonar.plugins.python.api.tree.SetLiteral;
47+
import org.sonar.plugins.python.api.tree.Statement;
48+
import org.sonar.plugins.python.api.tree.StringLiteral;
49+
import org.sonar.plugins.python.api.tree.Tree;
50+
import org.sonar.plugins.python.api.tree.Tuple;
51+
import org.sonar.plugins.python.api.types.v2.ClassType;
52+
import org.sonar.plugins.python.api.types.v2.FunctionType;
53+
import org.sonar.plugins.python.api.types.v2.PythonType;
54+
import org.sonar.plugins.python.api.types.v2.TypeWrapper;
55+
import org.sonar.python.checks.utils.Expressions;
56+
import org.sonar.python.tree.TreeUtils;
57+
58+
@Rule(key = "S8494")
59+
public class SlotsAssignmentCheck extends PythonSubscriptionCheck {
60+
61+
private static final String MESSAGE = "Add \"%s\" to the class's \"__slots__\".";
62+
63+
/**
64+
* Built-in C types whose instances don't have __dict__ by default.
65+
* When subclassing these, __slots__ restrictions still apply even though
66+
* we can't find a ClassDef to inspect.
67+
*/
68+
private static final Set<String> BUILTIN_TYPES_WITHOUT_DICT = Set.of(
69+
BuiltinTypes.INT, BuiltinTypes.FLOAT, BuiltinTypes.COMPLEX,
70+
BuiltinTypes.STR, BuiltinTypes.BYTES,
71+
BuiltinTypes.BOOL, BuiltinTypes.LIST, BuiltinTypes.TUPLE,
72+
BuiltinTypes.SET, BuiltinTypes.DICT, BuiltinTypes.NONE_TYPE,
73+
"type", "frozenset", "memoryview", "bytearray", "range");
74+
75+
@Override
76+
public void initialize(Context context) {
77+
context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, SlotsAssignmentCheck::checkClass);
78+
}
79+
80+
private static void checkClass(SubscriptionContext ctx) {
81+
ClassDef classDef = (ClassDef) ctx.syntaxNode();
82+
PythonType classType = classDef.name().typeV2();
83+
if (!(classType instanceof ClassType classTypeV2)) {
84+
return;
85+
}
86+
87+
// Find __slots__ assignment in this class body
88+
Set<String> ownSlots = extractOwnSlots(classDef);
89+
if (ownSlots == null) {
90+
// No __slots__ found or dynamic/unsupported form
91+
return;
92+
}
93+
94+
if (ownSlots.contains("__dict__")) {
95+
// Explicit opt-out: __dict__ in slots means no restriction
96+
return;
97+
}
98+
99+
if (classTypeV2.hasUnresolvedHierarchy()) {
100+
// Can't safely analyze hierarchy
101+
return;
102+
}
103+
104+
// Build allowed slots: own + ancestors
105+
Set<String> allowedSlots = new HashSet<>(ownSlots);
106+
Set<ClassType> visited = new HashSet<>();
107+
if (!collectAncestorSlots(classTypeV2, classDef, allowedSlots, visited)) {
108+
// Some resolvable parent has no __slots__, so __dict__ is available
109+
return;
110+
}
111+
112+
// Check each instance method
113+
for (FunctionDef functionDef : TreeUtils.topLevelFunctionDefs(classDef)) {
114+
PythonType funcType = functionDef.name().typeV2();
115+
if (!(funcType instanceof FunctionType ft) || !ft.isInstanceMethod()) {
116+
continue;
117+
}
118+
List<Parameter> params = TreeUtils.positionalParameters(functionDef);
119+
if (!params.isEmpty()) {
120+
String selfName = params.get(0).name().name();
121+
SelfAttributeAssignmentVisitor visitor = new SelfAttributeAssignmentVisitor(selfName, allowedSlots, ctx);
122+
functionDef.body().accept(visitor);
123+
}
124+
}
125+
}
126+
127+
/**
128+
* Extracts the slot names from the __slots__ assignment in the class body.
129+
* Uses the last __slots__ assignment, since Python's class body executes top-to-bottom
130+
* and the metaclass reads __slots__ from the final namespace value.
131+
* Returns null if no __slots__ is found or if the form is not a supported literal.
132+
* Returns an empty set if __slots__ = [] or __slots__ = ().
133+
*/
134+
@Nullable
135+
private static Set<String> extractOwnSlots(ClassDef classDef) {
136+
Set<String> result = null;
137+
for (Statement stmt : classDef.body().statements()) {
138+
if (stmt instanceof AssignmentStatement assignment) {
139+
List<ExpressionList> lhsExpressions = assignment.lhsExpressions();
140+
if (lhsExpressions.size() == 1) {
141+
List<Expression> lhs = lhsExpressions.get(0).expressions();
142+
if (lhs.size() == 1 && lhs.get(0).is(Tree.Kind.NAME) && "__slots__".equals(((Name) lhs.get(0)).name())) {
143+
// Found __slots__ assignment; keep iterating to use the last one
144+
result = extractSlotNames(assignment.assignedValue());
145+
}
146+
}
147+
}
148+
}
149+
return result;
150+
}
151+
152+
/**
153+
* Extracts slot names from the RHS of a __slots__ assignment.
154+
* Returns null if the form is not a supported literal.
155+
*/
156+
@Nullable
157+
private static Set<String> extractSlotNames(Expression value) {
158+
List<Expression> elements;
159+
if (value instanceof ListLiteral listLiteral) {
160+
elements = listLiteral.elements().expressions();
161+
} else if (value instanceof Tuple tuple) {
162+
elements = tuple.elements();
163+
} else if (value instanceof SetLiteral setLiteral) {
164+
elements = setLiteral.elements();
165+
} else if (value instanceof DictionaryLiteral dictionaryLiteral) {
166+
elements = extractDictKeys(dictionaryLiteral);
167+
} else if (value instanceof StringLiteral stringLiteral) {
168+
// Single string: __slots__ = 'attr_name'
169+
return Set.of(stringLiteral.trimmedQuotesValue());
170+
} else {
171+
// Dynamic or unsupported form
172+
return null;
173+
}
174+
return elements != null ? extractStringLiterals(elements) : null;
175+
}
176+
177+
/**
178+
* Extracts key expressions from a dictionary literal.
179+
* Returns null if any element is not a KeyValuePair with a string key (e.g. unpacking).
180+
*/
181+
@Nullable
182+
private static List<Expression> extractDictKeys(DictionaryLiteral dictionaryLiteral) {
183+
List<Expression> keys = new ArrayList<>();
184+
for (Tree element : dictionaryLiteral.elements()) {
185+
if (!(element instanceof KeyValuePair kvp)) {
186+
return null;
187+
}
188+
keys.add(kvp.key());
189+
}
190+
return keys;
191+
}
192+
193+
/**
194+
* Collects trimmed string values from a list of expressions.
195+
* If an element is a Name, attempts to resolve it to a StringLiteral via single-assignment analysis.
196+
* Returns null if any element cannot be resolved to a string literal.
197+
*/
198+
@Nullable
199+
private static Set<String> extractStringLiterals(List<Expression> elements) {
200+
Set<String> slots = new HashSet<>();
201+
for (Expression element : elements) {
202+
if (element instanceof StringLiteral stringLiteral) {
203+
slots.add(stringLiteral.trimmedQuotesValue());
204+
} else if (element instanceof Name name) {
205+
Optional<Expression> resolved = Expressions.singleAssignedNonNameValue(name);
206+
if (resolved.isPresent() && resolved.get() instanceof StringLiteral stringLiteral) {
207+
slots.add(stringLiteral.trimmedQuotesValue());
208+
} else {
209+
return null;
210+
}
211+
} else {
212+
return null;
213+
}
214+
}
215+
return slots;
216+
}
217+
218+
/**
219+
* Collects slot names from all ancestor classes into allowedSlots.
220+
* Uses ClassType (v2) for type hierarchy filtering (FQN, object skipping) and
221+
* resolves parent ClassDef AST nodes via SymbolV2's CLASS_DECLARATION usages.
222+
* Returns false if any resolvable parent has no __slots__ (meaning __dict__ is available).
223+
*/
224+
private static boolean collectAncestorSlots(ClassType classType, ClassDef classDef, Set<String> allowedSlots,
225+
Set<ClassType> visited) {
226+
for (TypeWrapper parentWrapper : classType.superClasses()) {
227+
PythonType parentType = parentWrapper.type();
228+
if (!(parentType instanceof ClassType parentClassType)
229+
|| "object".equals(parentClassType.fullyQualifiedName())
230+
|| !visited.add(parentClassType)) {
231+
continue;
232+
}
233+
if (!processParentType(parentClassType, classDef, allowedSlots, visited)) {
234+
return false;
235+
}
236+
}
237+
return true;
238+
}
239+
240+
private static boolean processParentType(ClassType parentClassType, ClassDef classDef, Set<String> allowedSlots,
241+
Set<ClassType> visited) {
242+
Optional<ClassDef> parentClassDef = findParentClassDef(classDef, parentClassType);
243+
if (parentClassDef.isEmpty()) {
244+
// Can't inspect the parent's __slots__. Built-in C types don't provide __dict__
245+
// on their instances, so slots restrictions still apply when subclassing them.
246+
// For all other types, we can't determine if __dict__ is available, so bail out.
247+
String fqn = parentClassType.fullyQualifiedName();
248+
return fqn != null && BUILTIN_TYPES_WITHOUT_DICT.contains(fqn);
249+
}
250+
return processParentClass(parentClassDef.get(), allowedSlots, visited);
251+
}
252+
253+
private static Optional<ClassDef> findParentClassDef(ClassDef childClassDef, ClassType parentClassType) {
254+
if (childClassDef.args() == null) {
255+
return Optional.empty();
256+
}
257+
for (var arg : childClassDef.args().arguments()) {
258+
if (arg instanceof RegularArgument regArg) {
259+
Expression expr = regArg.expression();
260+
if (expr instanceof Name name && name.typeV2() == parentClassType) {
261+
return findClassDefFromSymbolV2(name.symbolV2());
262+
}
263+
}
264+
}
265+
return Optional.empty();
266+
}
267+
268+
/**
269+
* Finds the ClassDef AST node from a SymbolV2 by looking for its CLASS_DECLARATION usage.
270+
*/
271+
private static Optional<ClassDef> findClassDefFromSymbolV2(@Nullable SymbolV2 symbolV2) {
272+
if (symbolV2 == null) {
273+
return Optional.empty();
274+
}
275+
return symbolV2.usages().stream()
276+
.filter(usage -> usage.kind() == UsageV2.Kind.CLASS_DECLARATION)
277+
.map(usage -> TreeUtils.firstAncestorOfKind(usage.tree(), Tree.Kind.CLASSDEF))
278+
.filter(Objects::nonNull)
279+
.map(ClassDef.class::cast)
280+
.findFirst();
281+
}
282+
283+
private static boolean processParentClass(ClassDef parentClassDef, Set<String> allowedSlots, Set<ClassType> visited) {
284+
Set<String> parentSlots = extractOwnSlots(parentClassDef);
285+
if (parentSlots == null || parentSlots.contains("__dict__")) {
286+
return false;
287+
}
288+
289+
allowedSlots.addAll(parentSlots);
290+
291+
PythonType parentType = parentClassDef.name().typeV2();
292+
if (!(parentType instanceof ClassType parentClassType)) {
293+
return true;
294+
}
295+
return !parentClassType.hasUnresolvedHierarchy() && collectAncestorSlots(parentClassType, parentClassDef, allowedSlots, visited);
296+
}
297+
298+
private static class SelfAttributeAssignmentVisitor extends BaseTreeVisitor {
299+
private final String selfName;
300+
private final Set<String> allowedSlots;
301+
private final SubscriptionContext ctx;
302+
303+
SelfAttributeAssignmentVisitor(String selfName, Set<String> allowedSlots, SubscriptionContext ctx) {
304+
this.selfName = selfName;
305+
this.allowedSlots = allowedSlots;
306+
this.ctx = ctx;
307+
}
308+
309+
@Override
310+
public void visitAssignmentStatement(AssignmentStatement assignment) {
311+
for (ExpressionList exprList : assignment.lhsExpressions()) {
312+
for (Expression expr : exprList.expressions()) {
313+
checkQualifiedExpression(expr);
314+
}
315+
}
316+
// Do not call super to avoid visiting RHS as assignment targets
317+
}
318+
319+
@Override
320+
public void visitCompoundAssignment(CompoundAssignmentStatement compoundAssignment) {
321+
checkQualifiedExpression(compoundAssignment.lhsExpression());
322+
// Do not call super
323+
}
324+
325+
@Override
326+
public void visitFunctionDef(FunctionDef functionDef) {
327+
// Stop recursion into nested functions to avoid false positives
328+
// where an inner function's first param shadows self
329+
}
330+
331+
@Override
332+
public void visitClassDef(ClassDef classDef) {
333+
// Stop recursion into nested classes to avoid false positives
334+
}
335+
336+
private void checkQualifiedExpression(Expression expr) {
337+
if (!expr.is(Tree.Kind.QUALIFIED_EXPR)) {
338+
return;
339+
}
340+
QualifiedExpression qualifiedExpr = (QualifiedExpression) expr;
341+
Expression qualifier = qualifiedExpr.qualifier();
342+
if (!qualifier.is(Tree.Kind.NAME)) {
343+
return;
344+
}
345+
if (!selfName.equals(((Name) qualifier).name())) {
346+
return;
347+
}
348+
String attrName = qualifiedExpr.name().name();
349+
if (!allowedSlots.contains(attrName)) {
350+
ctx.addIssue(qualifiedExpr.name(), String.format(MESSAGE, attrName));
351+
}
352+
}
353+
}
354+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<p>This rule raises an issue when code assigns a value to an attribute that is not listed in the class's <code>__slots__</code> declaration.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>In Python, the <code>__slots__</code> class attribute is used to explicitly declare which instance attributes a class can have. When a class defines
4+
<code>__slots__</code>, Python restricts instances to only those attributes, preventing the creation of new attributes dynamically.</p>
5+
<p>Assigning to an attribute that is not in <code>__slots__</code> causes Python to raise an <code>AttributeError</code> at runtime.</p>
6+
<h2>How to fix it</h2>
7+
<p>Add the missing attribute to the <code>__slots__</code> declaration.</p>
8+
<h3>Code examples</h3>
9+
<h4>Noncompliant code example</h4>
10+
<pre data-diff-id="1" data-diff-type="noncompliant">
11+
class User:
12+
__slots__ = ['name', 'email']
13+
14+
def __init__(self, name, email, age):
15+
self.name = name
16+
self.email = email
17+
self.age = age # Noncompliant: "age" is not in __slots__
18+
</pre>
19+
<h4>Compliant solution</h4>
20+
<pre data-diff-id="1" data-diff-type="compliant">
21+
class User:
22+
__slots__ = ['name', 'email', 'age']
23+
24+
def __init__(self, name, email, age):
25+
self.name = name
26+
self.email = email
27+
self.age = age
28+
</pre>
29+
<h2>Resources</h2>
30+
<h3>Documentation</h3>
31+
<ul>
32+
<li>Python Documentation - <a href="https://docs.python.org/3/reference/datamodel.html#slots">__slots__</a></li>
33+
</ul>

0 commit comments

Comments
 (0)