Skip to content

Commit 2c1ea64

Browse files
thomas-serre-sonarsourcesonartech
authored andcommitted
SONARPY-2946 Rule S7506: Dictionary comprehensions shouldn't use a static key (#264)
GitOrigin-RevId: 77773b5151b63293a1148464a8be7de664ef2590
1 parent 6c38d33 commit 2c1ea64

7 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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.function.Predicate;
20+
import org.sonar.check.Rule;
21+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
22+
import org.sonar.plugins.python.api.tree.DictCompExpression;
23+
import org.sonar.plugins.python.api.tree.Expression;
24+
import org.sonar.plugins.python.api.tree.StringElement;
25+
import org.sonar.plugins.python.api.tree.StringLiteral;
26+
import org.sonar.plugins.python.api.tree.Tree;
27+
import org.sonar.python.checks.utils.Expressions;
28+
import org.sonar.python.tree.TreeUtils;
29+
30+
@Rule(key = "S7506")
31+
public class DictionaryStaticKeyCheck extends PythonSubscriptionCheck {
32+
33+
@Override
34+
public void initialize(Context context) {
35+
context.registerSyntaxNodeConsumer(Tree.Kind.DICT_COMPREHENSION, ctx -> {
36+
DictCompExpression dictCompExpression = (DictCompExpression) ctx.syntaxNode();
37+
Expression keyExpression = dictCompExpression.keyExpression();
38+
if (isLiteralDictKey(keyExpression) || isAssignedFromLiteralDictKey(keyExpression)) {
39+
ctx.addIssue(keyExpression, "Don't use a static key in a dictionary comprehension.");
40+
}
41+
});
42+
}
43+
44+
private static boolean isLiteralDictKey(Expression expression) {
45+
if (expression instanceof StringLiteral stringLiteral) {
46+
return !isFString(stringLiteral);
47+
}
48+
return false;
49+
}
50+
51+
private static boolean isAssignedFromLiteralDictKey(Expression expression) {
52+
return Expressions.ifNameGetSingleAssignedNonNameValue(expression)
53+
.map(TreeUtils.toInstanceOfMapper(StringLiteral.class))
54+
.filter(Predicate.not(DictionaryStaticKeyCheck::isFString))
55+
.isPresent();
56+
}
57+
58+
private static boolean isFString(StringLiteral stringLiteral) {
59+
return stringLiteral.stringElements().stream().anyMatch(StringElement::isInterpolated);
60+
}
61+
}

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
@@ -159,6 +159,7 @@ public Stream<Class<?>> getChecks() {
159159
DedicatedAssertionCheck.class,
160160
DeprecatedNumpyTypesCheck.class,
161161
DictionaryDuplicateKeyCheck.class,
162+
DictionaryStaticKeyCheck.class,
162163
DirectTypeComparisonCheck.class,
163164
DisabledEFSEncryptionCheck.class,
164165
DisabledESDomainEncryptionCheck.class,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<p>This rule raises an issue when a dictionary comprehension uses a static key.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Dictionary comprehensions are a concise way to create dictionaries, typically by dynamically generating key-value pairs during iteration. When the
4+
key part of a dictionary comprehension is static (e.g., a string literal like <code>"key"</code> or a variable defined outside the comprehension that
5+
isn’t updated during the comprehension), each iteration of the comprehension will attempt to assign a value to this <strong>same single
6+
key</strong>.</p>
7+
<p>The consequence is that the dictionary will only contain one entry for that static key, and its value will be the one computed during the
8+
<strong>last</strong> iteration of the comprehension. This behavior is often a misunderstanding of how dictionary comprehensions work or a logical
9+
error, as the intention is usually to create multiple distinct key-value pairs.</p>
10+
<p>Consider this example:</p>
11+
<pre>
12+
data = ["apple", "banana", "cherry"]
13+
14+
# Each iteration overwrites the value associated with "fruit_type"
15+
result_dict = {"fruit_type": value.capitalize() for value in data}
16+
# After the first iteration: {"fruit_type": "Apple"}
17+
# After the second iteration: {"fruit_type": "Banana"}
18+
# Final result: {"fruit_type": "Cherry"}
19+
</pre>
20+
<p>In the code above, the loop iterates three times, but because <code>"fruit_type"</code> is always the same key, the final dictionary
21+
<code>result_dict</code> will only be <code>{'fruit_type': 'CHERRY'}</code>. All previous assignments for this key are overwritten. This is usually
22+
not what the developer intends when using a comprehension over <code>data</code>.</p>
23+
<p>If the goal was to have multiple distinct keys, the key expression in the comprehension must vary with each iteration.</p>
24+
<h2>How to fix it</h2>
25+
<p>To fix this issue ensure that the key expression within the dictionary comprehension is dynamic, meaning it changes with each iteration, typically
26+
by using the iteration variable(s). This ensures that unique keys are generated, leading to a dictionary with multiple entries as usually
27+
intended.</p>
28+
<pre data-diff-id="1" data-diff-type="noncompliant">
29+
data = ["some", "Data"]
30+
31+
output_dict = {"key": value.upper() for value in data} # Noncompliant: "key" is not modify for each iteration
32+
</pre>
33+
<pre data-diff-id="1" data-diff-type="compliant">
34+
data = ["some", "Data"]
35+
36+
output_dict = {value: value.upper() for value in data} # Compliant
37+
</pre>
38+
<h2>Resources</h2>
39+
<h3>Documentation</h3>
40+
<ul>
41+
<li> Python Documentation - <a href="https://docs.python.org/3/tutorial/datastructures.html#dictionaries">Dictionaries</a> </li>
42+
<li> Python Documentation - <a href="https://docs.python.org/3/reference/expressions.html#dictionary-displays">Dictionary displays</a> </li>
43+
</ul>
44+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"title": "Dictionary comprehension should not use a static key",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Critical",
11+
"ruleSpecification": "RSPEC-7506",
12+
"sqKey": "S7506",
13+
"scope": "All",
14+
"quickfix": "unknown",
15+
"code": {
16+
"impacts": {
17+
"RELIABILITY": "HIGH"
18+
},
19+
"attribute": "LOGICAL"
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
@@ -263,6 +263,7 @@
263263
"S7498",
264264
"S7499",
265265
"S7501",
266+
"S7506",
266267
"S7510",
267268
"S7511"
268269
]
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 DictionaryStaticKeyCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/dictionaryStaticKey.py", new DictionaryStaticKeyCheck());
27+
}
28+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import another_module
2+
3+
data = ["some", "Data", "example"]
4+
my_dict = {"key": value.upper() for value in data} # Noncompliant {{Don't use a static key in a dictionary comprehension.}}
5+
# ^^^^^
6+
7+
key_literal = "key"
8+
my_dict = {key_literal: value.upper() for value in data} # Noncompliant {{Don't use a static key in a dictionary comprehension.}}
9+
# ^^^^^^^^^^^
10+
11+
another_key_literal = key_literal
12+
my_dict = {another_key_literal: value.upper() for value in data} # Noncompliant {{Don't use a static key in a dictionary comprehension.}}
13+
# ^^^^^^^^^^^^^^^^^^^
14+
15+
f_string_key = f"{value}_str"
16+
another_f_string_key = f_string_key
17+
my_dict = {another_f_string_key: value.upper() for value in data} # Compliant
18+
19+
my_dict = {value: value.upper() for value in data} # Compliant
20+
21+
key_from_another_module = another_module.key
22+
my_dict = {key_from_another_module: value.upper() for value in data} # Compliant
23+
24+
my_dict = {f"{value}_str": value.upper() for value in data} # Compliant
25+
26+
my_dict = {f"{key_literal}_str": value.upper() for value in data} # FN : interpolated string is constant

0 commit comments

Comments
 (0)