|
| 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 | +} |
0 commit comments