Skip to content

Commit 51ed079

Browse files
Seppli11sonartech
authored andcommitted
SONARPY-2926 Rule S7494: Unnecessary generator - rewrite as a comprehension (#265)
GitOrigin-RevId: 3e22edf0ae5c95d70da198aa13099d30a481b758
1 parent f738def commit 51ed079

7 files changed

Lines changed: 309 additions & 0 deletions

File tree

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
@@ -382,6 +382,7 @@ public Stream<Class<?>> getChecks() {
382382
ReferencedBeforeAssignmentCheck.class,
383383
RegexComplexityCheck.class,
384384
RegexLookaheadCheck.class,
385+
RewriteCollectionConstructorAsComprehensionCheck.class,
385386
UnconditionalAssertionCheck.class,
386387
UndefinedNameAllPropertyCheck.class,
387388
UnencryptedSageMakerNotebookCheck.class,
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
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.List;
20+
import java.util.Map;
21+
import java.util.Optional;
22+
import javax.annotation.Nullable;
23+
import org.sonar.check.Rule;
24+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
25+
import org.sonar.plugins.python.api.tree.Argument;
26+
import org.sonar.plugins.python.api.tree.CallExpression;
27+
import org.sonar.plugins.python.api.tree.ComprehensionClause;
28+
import org.sonar.plugins.python.api.tree.ComprehensionExpression;
29+
import org.sonar.plugins.python.api.tree.Expression;
30+
import org.sonar.plugins.python.api.tree.Name;
31+
import org.sonar.plugins.python.api.tree.RegularArgument;
32+
import org.sonar.plugins.python.api.tree.Tree;
33+
import org.sonar.plugins.python.api.tree.Tuple;
34+
import org.sonar.plugins.python.api.types.v2.PythonType;
35+
import org.sonar.plugins.python.api.types.v2.TriBool;
36+
import org.sonar.python.semantic.v2.SymbolV2;
37+
import org.sonar.python.types.v2.TypeCheckBuilder;
38+
import org.sonar.python.types.v2.TypeCheckMap;
39+
40+
@Rule(key = "S7494")
41+
public class RewriteCollectionConstructorAsComprehensionCheck extends PythonSubscriptionCheck {
42+
43+
private static final Map<String, String> COLLECTION_MESSAGES = Map.of(
44+
"list", "Replace list constructor call with a list comprehension.",
45+
"set", "Replace set constructor call with a set comprehension.",
46+
"dict", "Replace dict constructor call with a dictionary comprehension."
47+
);
48+
49+
private TypeCheckMap<String> collectionTypeCheckerMap = null;
50+
private TypeCheckBuilder dictTypeChecker = null;
51+
52+
@Override
53+
public void initialize(Context context) {
54+
collectionTypeCheckerMap = new TypeCheckMap<>();
55+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, ctx -> {
56+
for (var collectionEntry : COLLECTION_MESSAGES.entrySet()) {
57+
TypeCheckBuilder typeChecker = ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(collectionEntry.getKey());
58+
collectionTypeCheckerMap.put(typeChecker, collectionEntry.getValue());
59+
60+
if ("dict".equals(collectionEntry.getKey())) {
61+
dictTypeChecker = typeChecker;
62+
}
63+
}
64+
});
65+
66+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, ctx -> {
67+
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
68+
List<Argument> arguments = callExpression.arguments();
69+
70+
String message = getMessageForConstructor(callExpression).orElse(null);
71+
if (message == null) {
72+
return;
73+
}
74+
75+
ComprehensionExpression generator = getSingleGeneratorArg(arguments).orElse(null);
76+
if (generator == null) {
77+
return;
78+
}
79+
80+
if (isGeneratorTransformingData(generator, callExpression.callee().typeV2())) {
81+
ctx.addIssue(callExpression.callee(), message);
82+
}
83+
});
84+
}
85+
86+
private Optional<String> getMessageForConstructor(CallExpression callExpression) {
87+
return collectionTypeCheckerMap.getOptionalForType(callExpression.callee().typeV2());
88+
}
89+
90+
private static Optional<ComprehensionExpression> getSingleGeneratorArg(List<Argument> arguments) {
91+
if (arguments.size() != 1) {
92+
return Optional.empty();
93+
}
94+
95+
if (!(arguments.get(0) instanceof RegularArgument regularArg)) {
96+
return Optional.empty();
97+
}
98+
99+
Expression argument = regularArg.expression();
100+
if (!argument.is(Tree.Kind.GENERATOR_EXPR)) {
101+
return Optional.empty();
102+
}
103+
104+
return Optional.of((ComprehensionExpression) argument);
105+
}
106+
107+
private boolean isGeneratorTransformingData(ComprehensionExpression generator, PythonType methodType) {
108+
if (hasMultipleNestedClauses(generator.comprehensionFor())) {
109+
return true;
110+
}
111+
if (dictTypeChecker.check(methodType) == TriBool.TRUE) {
112+
return isDictTransformingData(generator);
113+
} else {
114+
return isListOrSetComprehensionTransformingData(generator);
115+
}
116+
}
117+
118+
private static boolean hasMultipleNestedClauses(ComprehensionClause clause) {
119+
return clause.nestedClause() != null;
120+
}
121+
122+
private static boolean isDictTransformingData(ComprehensionExpression generator) {
123+
Expression resultExpr = generator.resultExpression();
124+
Expression loopVarExpr = generator.comprehensionFor().loopExpression();
125+
126+
var resultTuple = getTwoNamedTuple(resultExpr);
127+
var varTuple = getTwoNamedTuple(loopVarExpr);
128+
129+
if (resultTuple != null && varTuple != null) {
130+
return !resultTuple.equals(varTuple);
131+
}
132+
133+
return true;
134+
}
135+
136+
private static TwoNamedTuple getTwoNamedTuple(Expression expr) {
137+
if (expr instanceof Tuple tuple && tuple.elements().size() == 2) {
138+
var elements = tuple.elements();
139+
if (elements.get(0) instanceof Name name1 && elements.get(1) instanceof Name name2) {
140+
return new TwoNamedTuple(name1.symbolV2(), name2.symbolV2());
141+
}
142+
}
143+
return null;
144+
}
145+
146+
private record TwoNamedTuple(@Nullable SymbolV2 symbol1, @Nullable SymbolV2 symbol2) {
147+
}
148+
149+
private static boolean isListOrSetComprehensionTransformingData(ComprehensionExpression generator) {
150+
Expression elementExpr = generator.resultExpression();
151+
Expression loopVarExpr = generator.comprehensionFor().loopExpression();
152+
153+
if (elementExpr instanceof Name elementName && loopVarExpr instanceof Name loopVarName) {
154+
return elementName.symbolV2() != loopVarName.symbolV2();
155+
}
156+
157+
return true;
158+
}
159+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<p>This rule raises an issue when <code>list()</code>, <code>set()</code> or <code>dict()</code> is unnecessarily used around a generator
2+
expression.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>Using <code>list()</code>, <code>set()</code>, or <code>dict()</code> around a generator expression is redundant when a corresponding comprehension
5+
can directly express the same operation. Comprehensions are clearer, more concise, and often more readable than the equivalent constructor/generator
6+
expression combination.</p>
7+
<p>This principle applies to all three built-in collection types: <code>list</code>, <code>set</code>, and <code>dict</code>:</p>
8+
<ul>
9+
<li> Use <code>[f(x) for x in foo]</code> instead of <code>list(f(x) for x in foo)</code> </li>
10+
<li> Use <code>{f(x) for x in foo}</code> instead of <code>set(f(x) for x in foo)</code> </li>
11+
<li> Use <code>{k: v for k, v in items}</code> instead of <code>dict((k, v) for k, v in items)</code> </li>
12+
</ul>
13+
<h3>Exceptions</h3>
14+
<p>If the generator expression doesn’t filter or modify the collection, the rule does not raise. For example, <code>list(x for x in foo)</code> is
15+
simply copying the iterable <code>foo</code> into a list, which is equivalent to <code>list(foo)</code> and covered by a different rule.</p>
16+
<h2>How to fix it</h2>
17+
<p>Replace the collection constructor with the appropriate comprehension syntax.</p>
18+
<h3>Code examples</h3>
19+
<h4>Noncompliant code example</h4>
20+
<pre data-diff-id="1" data-diff-type="noncompliant">
21+
def f(x):
22+
return x * 2
23+
24+
list(f(x) for x in range(5)) # Noncompliant
25+
</pre>
26+
<h4>Compliant solution</h4>
27+
<pre data-diff-id="1" data-diff-type="compliant">
28+
def f(x):
29+
return x * 2
30+
31+
[f(x) for x in range(5)] # Compliant
32+
</pre>
33+
<h2>Resources</h2>
34+
<h3>Documentation</h3>
35+
<ul>
36+
<li> Python Documentation - <a href="https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions">List Comprehensions</a> </li>
37+
<li> Python Documentation - <a href="https://docs.python.org/3/tutorial/datastructures.html#dictionaries">Dictionaries</a> </li>
38+
<li> Python Documentation - <a href="https://docs.python.org/3/tutorial/datastructures.html#sets">Sets</a> </li>
39+
</ul>
40+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"title": "Comprehensions should be used instead of constructors around generator expressions",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Minor",
11+
"ruleSpecification": "RSPEC-7494",
12+
"sqKey": "S7494",
13+
"scope": "All",
14+
"quickfix": "targeted",
15+
"code": {
16+
"impacts": {
17+
"MAINTAINABILITY": "LOW"
18+
},
19+
"attribute": "CONVENTIONAL"
20+
}
21+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@
258258
"S7488",
259259
"S7491",
260260
"S7493",
261+
"S7494",
261262
"S7498",
262263
"S7499",
263264
"S7501",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
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 org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class RewriteCollectionConstructorAsComprehensionCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/rewriteCollectionConstructorAsComprehension.py", new RewriteCollectionConstructorAsComprehensionCheck());
27+
}
28+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
2+
def func(x):
3+
return x * 2
4+
5+
def lists():
6+
list(func(x) for x in range(5)) # Noncompliant {{Replace list constructor call with a list comprehension.}}
7+
#^^^^
8+
9+
list(x + 1 for x in range(10)) # Noncompliant
10+
list((x, y) for x in range(3) for y in range(2)) # Noncompliant
11+
list(x for x in range(10) if x % 2 == 0) # Noncompliant
12+
13+
list(x for x in range(10))
14+
list(range(10))
15+
list()
16+
list([1, 2, 3])
17+
18+
def sets():
19+
set(func(x) for x in range(5)) # Noncompliant {{Replace set constructor call with a set comprehension.}}
20+
#^^^
21+
set(x * 2 for x in items) # Noncompliant
22+
set(x for x in range(10) if x > 5) # Noncompliant
23+
24+
set(x for x in range(10))
25+
set()
26+
set([1, 2, 3])
27+
set(x for x,y in range(10)) # Noncompliant
28+
set(y for x in range(10)) # Noncompliant
29+
30+
def dicts():
31+
dict((k, v) for k, v in items.items())
32+
dict((k, func(v)) for k, v in items.items()) # Noncompliant {{Replace dict constructor call with a dictionary comprehension.}}
33+
#^^^^
34+
dict((v, k) for k, v in items.items()) # Noncompliant
35+
dict((k.upper(), v) for k, v in items.items()) # Noncompliant
36+
dict((k, v) for k, v in items.items() if k.startswith('a')) # Noncompliant
37+
dict(tuple(k, v) for k, v in items.items() if k.startswith('a')) # Noncompliant
38+
dict(tuple([k, v]) for k, v in items.items()) # Noncompliant
39+
dict((t, t) for t in items) # Noncompliant
40+
dict((t, t, t) for t in items) # Noncompliant
41+
dict((t,) for t in items) # Noncompliant
42+
43+
def compliant_examples():
44+
[func(x) for x in range(5)]
45+
{func(x) for x in range(5)}
46+
{k: func(v) for k, v in items.items()}
47+
{v: k for k, v in items.items()}
48+
49+
def other():
50+
list(map(func, range(10)))
51+
list([x for x in range(10)])
52+
result = list(func(x) for x in range(5)) # Noncompliant
53+
54+
list(*args)
55+
list(**args)
56+
57+
def multiple_clauses():
58+
list((i, j) for i in range(3) for j in range(2)) # Noncompliant
59+
[(i, j) for i in range(3) for j in range(2)]

0 commit comments

Comments
 (0)