Skip to content

Commit 4bd6b14

Browse files
guillaume-dequennesonartech
authored andcommitted
CLP-1218 Fix S8514 false positives on generated dataclass methods
GitOrigin-RevId: 640918cfee5ef475e76cd7b76bd6b541a2ea0856
1 parent 5f222c0 commit 4bd6b14

2 files changed

Lines changed: 250 additions & 1 deletion

File tree

‎python-checks/src/main/java/org/sonar/python/checks/DataclassFieldDefinitionCheck.java‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,23 @@
2020
import org.sonar.check.Rule;
2121
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2222
import org.sonar.plugins.python.api.SubscriptionContext;
23+
import org.sonar.plugins.python.api.cfg.ControlFlowGraph;
24+
import org.sonar.plugins.python.api.symbols.v2.UsageV2;
2325
import org.sonar.plugins.python.api.tree.AssignmentStatement;
2426
import org.sonar.plugins.python.api.tree.CallExpression;
2527
import org.sonar.plugins.python.api.tree.ClassDef;
2628
import org.sonar.plugins.python.api.tree.Decorator;
2729
import org.sonar.plugins.python.api.tree.Expression;
2830
import org.sonar.plugins.python.api.tree.ExpressionList;
31+
import org.sonar.plugins.python.api.tree.FunctionDef;
32+
import org.sonar.plugins.python.api.tree.LambdaExpression;
2933
import org.sonar.plugins.python.api.tree.Name;
34+
import org.sonar.plugins.python.api.tree.ReturnStatement;
3035
import org.sonar.plugins.python.api.tree.Tree;
36+
import org.sonar.plugins.python.api.types.v2.FunctionType;
3137
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
3238
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
39+
import org.sonar.python.checks.utils.Expressions;
3340

3441
@Rule(key = "S8514")
3542
public class DataclassFieldDefinitionCheck extends PythonSubscriptionCheck {
@@ -66,11 +73,66 @@ private static boolean isDataclassDecorator(Decorator decorator, SubscriptionCon
6673
}
6774

6875
private static void checkStatement(SubscriptionContext ctx, Tree statement) {
69-
if (statement instanceof AssignmentStatement assignmentStatement && !isLikelyIntentionalClassVar(assignmentStatement)) {
76+
if (statement instanceof AssignmentStatement assignmentStatement && !isLikelyIntentionalClassVar(assignmentStatement)
77+
&& !isMethodAssignment(assignmentStatement.assignedValue(), ctx)) {
7078
ctx.addIssue(assignmentStatement, MESSAGE_MISSING_ANNOTATION);
7179
}
7280
}
7381

82+
private static boolean isMethodAssignment(Expression expression, SubscriptionContext ctx) {
83+
Expression value = Expressions.removeParentheses(expression);
84+
if (isFunction(value)) {
85+
return true;
86+
}
87+
// Source function return types are not always inferred. Recognize local factories
88+
// returning a function without exempting ordinary data-producing factory calls.
89+
if (value instanceof CallExpression call && call.callee() instanceof Name name && name.symbolV2() != null) {
90+
List<UsageV2> bindings = name.symbolV2().usages().stream().filter(UsageV2::isBindingUsage).toList();
91+
return bindings.size() == 1
92+
&& bindings.get(0).kind() == UsageV2.Kind.FUNC_DECLARATION
93+
&& bindings.get(0).tree().parent() instanceof FunctionDef function
94+
&& isMethodFactory(function, ctx);
95+
}
96+
return false;
97+
}
98+
99+
private static boolean isMethodFactory(FunctionDef function, SubscriptionContext ctx) {
100+
if (function.asyncKeyword() != null || !function.decorators().isEmpty() || hasDataReturnOrYield(function.body())) {
101+
return false;
102+
}
103+
// Checking explicit returns alone would miss paths that implicitly return None.
104+
ControlFlowGraph cfg = ctx.cfg(function);
105+
return cfg != null && !cfg.end().predecessors().isEmpty()
106+
&& cfg.end().predecessors().stream().allMatch(block -> {
107+
List<Tree> elements = block.elements();
108+
return !elements.isEmpty() && returnsFunction(elements.get(elements.size() - 1));
109+
});
110+
}
111+
112+
private static boolean returnsFunction(Tree tree) {
113+
return tree instanceof ReturnStatement statement && statement.expressions().size() == 1
114+
&& isFunction(statement.expressions().get(0));
115+
}
116+
117+
private static boolean hasDataReturnOrYield(Tree tree) {
118+
if (tree.is(Tree.Kind.YIELD_EXPR, Tree.Kind.YIELD_STMT) || (tree instanceof ReturnStatement && !returnsFunction(tree))) {
119+
return true;
120+
}
121+
Tree nestedBody = switch (tree) {
122+
case FunctionDef nested -> nested.body();
123+
case ClassDef nested -> nested.body();
124+
case LambdaExpression lambda -> lambda.expression();
125+
default -> null;
126+
};
127+
// Traverse arguments, return expressions and defaults, but not nested bodies.
128+
return tree.children().stream().filter(child -> child != nestedBody).anyMatch(DataclassFieldDefinitionCheck::hasDataReturnOrYield);
129+
}
130+
131+
private static boolean isFunction(Expression expression) {
132+
Expression value = Expressions.removeParentheses(expression);
133+
return value.is(Tree.Kind.LAMBDA) || value.typeV2() instanceof FunctionType;
134+
}
135+
74136
private static boolean isLikelyIntentionalClassVar(AssignmentStatement assignment) {
75137
List<ExpressionList> lhsList = assignment.lhsExpressions();
76138
if (lhsList.size() != 1) {

‎python-checks/src/test/resources/checks/dataclassFieldDefinition.py‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,190 @@ class UnderscorePrefixedAttributes:
211211
__tablename__ = "users"
212212
__abstract__ = True
213213
_PRIVATE_CONST = 42
214+
215+
216+
def make_method(operation):
217+
def method(self, *args):
218+
return operation(*args)
219+
return method
220+
221+
222+
def method(self):
223+
return 42
224+
225+
226+
@dataclass
227+
class GeneratedMethods:
228+
direct = method
229+
generated = make_method(sum)
230+
anonymous = lambda self: 42
231+
count = 42 # Noncompliant
232+
items = list() # Noncompliant
233+
234+
235+
def nested_generated_methods():
236+
@dataclass
237+
class Operations:
238+
bitwise_and = make_method(sum)
239+
bitwise_or = make_method(sum)
240+
count = 42 # Noncompliant
241+
242+
243+
def make_data():
244+
def method(self):
245+
return 42
246+
return 42
247+
248+
249+
def make_pair():
250+
return method, 42
251+
252+
253+
async def make_coroutine():
254+
return method
255+
256+
257+
def make_generator():
258+
yield 42
259+
return method
260+
261+
262+
class CallableData:
263+
def __call__(self):
264+
return 42
265+
266+
267+
@dataclass
268+
class FactoryDataStillRequiresAnnotations:
269+
data = make_data() # Noncompliant
270+
pair = make_pair() # Noncompliant
271+
coroutine = make_coroutine() # Noncompliant
272+
generator = make_generator() # Noncompliant
273+
callable_data = CallableData() # Noncompliant
274+
field_default = field(default=42) # Noncompliant
275+
276+
277+
def make_lambda():
278+
return lambda self: 42
279+
280+
281+
@dataclass
282+
class LambdaFactory:
283+
generated = make_lambda()
284+
parenthesized = (lambda self: 42)
285+
286+
287+
def make_mixed_value(as_method=False):
288+
if as_method:
289+
return lambda self: 42
290+
return 42
291+
292+
293+
def make_rebound_value():
294+
return lambda self: 42
295+
296+
297+
make_rebound_value = int
298+
299+
300+
def make_generator_in_call():
301+
print((yield 42))
302+
return lambda self: 42
303+
304+
305+
@dataclass
306+
class ReviewRegressionControls:
307+
mixed = make_mixed_value() # Noncompliant
308+
rebound = make_rebound_value() # Noncompliant
309+
generator = make_generator_in_call() # Noncompliant
310+
311+
312+
def make_optional_method(as_method=False):
313+
if as_method:
314+
return method
315+
316+
317+
def make_method_or_none(as_method=False):
318+
if as_method:
319+
return method
320+
return
321+
322+
323+
def make_no_value():
324+
pass
325+
326+
327+
def make_redefined_value():
328+
return method
329+
330+
331+
def make_redefined_value():
332+
return 0
333+
334+
335+
def make_generator_with_yield_from():
336+
print((yield from [42]))
337+
return method
338+
339+
340+
def make_generator_in_default():
341+
def nested(value=(yield 42)):
342+
return value
343+
return nested
344+
345+
346+
def make_generator_in_lambda_default():
347+
return lambda self, value=(yield 42): value
348+
349+
350+
def replace_factory(factory):
351+
return int
352+
353+
354+
@replace_factory
355+
def make_decorated_value():
356+
return method
357+
358+
359+
@dataclass
360+
class AmbiguousFactoryResults:
361+
implicit_none = make_optional_method() # Noncompliant
362+
bare_return = make_method_or_none() # Noncompliant
363+
no_return = make_no_value() # Noncompliant
364+
redefined = make_redefined_value() # Noncompliant
365+
delegated_generator = make_generator_with_yield_from() # Noncompliant
366+
generator_default = make_generator_in_default() # Noncompliant
367+
generator_lambda_default = make_generator_in_lambda_default() # Noncompliant
368+
decorated = make_decorated_value() # Noncompliant
369+
370+
371+
def make_alternative_methods(first=True):
372+
if first:
373+
return method
374+
else:
375+
return lambda self: 42
376+
377+
378+
def make_generator_method():
379+
def generated(self):
380+
yield 42
381+
return generated
382+
383+
384+
def make_lambda_generator_method():
385+
return lambda self: (yield 42)
386+
387+
388+
def make_method_with_nested_class():
389+
class Nested:
390+
def generated(self):
391+
yield 42
392+
return method
393+
394+
395+
@dataclass
396+
class MoreGeneratedMethods:
397+
alternatives = make_alternative_methods()
398+
generator_method = make_generator_method()
399+
lambda_generator_method = make_lambda_generator_method()
400+
nested_class = make_method_with_nested_class()

0 commit comments

Comments
 (0)