Skip to content

Commit ccb1525

Browse files
maksim-grebeniuk-sonarsourcesonartech
authored andcommitted
SONARPY-2996 Rule S7517: Use dict.items() to iterate over a dictionary (#306)
GitOrigin-RevId: 9da82d50ff31eb00406c9a5b564f0a42459b6082
1 parent c277861 commit ccb1525

File tree

7 files changed

+186
-1
lines changed

7 files changed

+186
-1
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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.sonar.check.Rule;
20+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
21+
import org.sonar.plugins.python.api.SubscriptionContext;
22+
import org.sonar.plugins.python.api.tree.ComprehensionFor;
23+
import org.sonar.plugins.python.api.tree.ForStatement;
24+
import org.sonar.plugins.python.api.tree.Tree;
25+
import org.sonar.plugins.python.api.tree.Tuple;
26+
import org.sonar.plugins.python.api.types.v2.TriBool;
27+
import org.sonar.python.types.v2.TypeCheckBuilder;
28+
29+
@Rule(key = "S7517")
30+
public class LoopOverDictKeyValuesCheck extends PythonSubscriptionCheck {
31+
public static final String DICT_FQN = "dict";
32+
public static final String MESSAGE = "Use items to iterate over key-value pairs";
33+
private TypeCheckBuilder dictTypeCheck;
34+
35+
36+
@Override
37+
public void initialize(Context context) {
38+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::initChecks);
39+
context.registerSyntaxNodeConsumer(Tree.Kind.FOR_STMT, this::checkForStatement);
40+
context.registerSyntaxNodeConsumer(Tree.Kind.COMP_FOR, this::checkComprehensionFor);
41+
}
42+
43+
private void initChecks(SubscriptionContext ctx) {
44+
dictTypeCheck = ctx.typeChecker().typeCheckBuilder().isInstanceOf(DICT_FQN);
45+
}
46+
47+
private void checkForStatement(SubscriptionContext ctx) {
48+
var forStatement = (ForStatement) ctx.syntaxNode();
49+
var expressions = forStatement.expressions();
50+
var testExpressions = forStatement.testExpressions();
51+
if (expressions.size() == 2
52+
&& testExpressions.size() == 1
53+
&& dictTypeCheck.check(testExpressions.get(0).typeV2()) == TriBool.TRUE) {
54+
ctx.addIssue(testExpressions.get(0), MESSAGE);
55+
}
56+
}
57+
58+
private void checkComprehensionFor(SubscriptionContext ctx) {
59+
var comprehensionFor = (ComprehensionFor) ctx.syntaxNode();
60+
if (comprehensionFor.loopExpression() instanceof Tuple tuple
61+
&& tuple.elements().size() == 2
62+
&& dictTypeCheck.check(comprehensionFor.iterable().typeV2()) == TriBool.TRUE) {
63+
ctx.addIssue(comprehensionFor.iterable(), MESSAGE);
64+
}
65+
}
66+
}

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
@@ -268,6 +268,7 @@ public Stream<Class<?>> getChecks() {
268268
LoggersConfigurationCheck.class,
269269
LongIntegerWithLowercaseSuffixUsageCheck.class,
270270
LoopExecutingAtMostOnceCheck.class,
271+
LoopOverDictKeyValuesCheck.class,
271272
MandatoryFunctionParameterTypeHintCheck.class,
272273
MandatoryFunctionReturnTypeHintCheck.class,
273274
MembershipTestSupportCheck.class,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<p>This rule raises an issue when a dictionary is iterated over without explicitly calling the <code>.items()</code> method.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>When iterating directly over a dictionary e.g., <code>for k, v in some_dict:</code> or <code>{k: v for k, v in some_dict}</code>, Python iterates
4+
over the dictionary’s keys by default. If the intention is to access both the key and the value, omitting <code>.items()</code> leads to unexpected
5+
behavior. In such cases, the <code>k</code> variable would receive the key, and the <code>v</code> variable would attempt to unpack the key itself,
6+
which can lead to errors or subtle bugs if the key is iterable, like a <code>string</code>. For example, if a key is a <code>string</code> like
7+
<code>"hi"</code>, <code>k</code> would be <code>'h'</code> and <code>v</code> would be <code>'i'</code>.</p>
8+
<h2>How to fix it</h2>
9+
<p>To fix this, simply append <code>.items()</code> to your dictionary when iterating.</p>
10+
<h3>Code examples</h3>
11+
<h4>Noncompliant code example</h4>
12+
<pre data-diff-id="1" data-diff-type="noncompliant">
13+
some_dict = { "k1": "v1", "k2": "v2"}
14+
15+
{k: v for k, v in some_dict} # Noncompliant: `v` will not receive the value, but the first character of the key
16+
</pre>
17+
<pre data-diff-id="2" data-diff-type="noncompliant">
18+
some_dict = { "k1": "v1", "k2": "v2"}
19+
20+
for k, v in some_dict: # Noncompliant: `v` will not receive the value, but the first character of the key
21+
...
22+
</pre>
23+
<h4>Compliant solution</h4>
24+
<pre data-diff-id="1" data-diff-type="compliant">
25+
some_dict = { "k1": "v1", "k2": "v2"}
26+
{k: v for k, v in some_dict.items()}
27+
</pre>
28+
<pre data-diff-id="2" data-diff-type="compliant">
29+
some_dict = { "k1": "v1", "k2": "v2"}
30+
for k, v in some_dict.items():
31+
...
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#looping-techniques">Looping Techniques</a> </li>
37+
</ul>
38+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"title": "Iteration over a dictionary key value pairs should be done with the items() method call",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "1min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Major",
11+
"ruleSpecification": "RSPEC-7517",
12+
"sqKey": "S7517",
13+
"scope": "All",
14+
"quickfix": "unknown",
15+
"code": {
16+
"impacts": {
17+
"MAINTAINABILITY": "MEDIUM"
18+
},
19+
"attribute": "CONVENTIONAL"
20+
}
21+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@
280280
"S7508",
281281
"S7510",
282282
"S7511",
283-
"S7512"
283+
"S7512",
284+
"S7517"
284285
]
285286
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 LoopOverDictKeyValuesCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/loopOverDictKeyValues.py", new LoopOverDictKeyValuesCheck());
27+
}
28+
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
2+
def case1():
3+
some_dict = { "hi": "hello"}
4+
for k, v in some_dict: # Noncompliant {{Use items to iterate over key-value pairs}}
5+
# ^^^^^^^^^
6+
...
7+
{k: v for k, v in some_dict} # Noncompliant {{Use items to iterate over key-value pairs}}
8+
# ^^^^^^^^^
9+
10+
def case2():
11+
some_dict = { "hi": "hello"}
12+
for k, v in some_dict.items():
13+
...
14+
{k: v for k, v in some_dict.items()}
15+
16+
def case3():
17+
not_a_dict = ["hi", "hello"]
18+
for k, v in not_a_dict:
19+
...
20+
{k: v for k, v in not_a_dict}
21+
22+
def case4():
23+
some_dict = { "hi": "hello"}
24+
for k in some_dict:
25+
...
26+
for k, v in some_dict, something_else:
27+
...
28+
{k: 1 for k in some_dict}
29+
{k: 1 for k, v, i in not_a_dict}

0 commit comments

Comments
 (0)