Skip to content

Commit 1562462

Browse files
sonar-nigel[bot]Sonar Vibe Botjoke1196
authored andcommitted
SONARPY-3951 Rule S8521 Dictionary membership tests should not explicitly call ".keys()" (#993)
Co-authored-by: Sonar Vibe Bot <vibe-bot@sonarsource.com> Co-authored-by: joke1196 <david.kunzmann@sonarsource.com> GitOrigin-RevId: 9de21f27386465951d447001890d6ea1fc3eefc6
1 parent 47d6101 commit 1562462

File tree

6 files changed

+181
-0
lines changed

6 files changed

+181
-0
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 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.CallExpression;
23+
import org.sonar.plugins.python.api.tree.InExpression;
24+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
25+
import org.sonar.plugins.python.api.tree.Tree;
26+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
27+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
28+
29+
@Rule(key = "S8521")
30+
public class DictKeysMembershipTestCheck extends PythonSubscriptionCheck {
31+
32+
private static final String MESSAGE = "Remove this unnecessary \"keys()\" call.";
33+
private static final String KEYS_METHOD_NAME = "keys";
34+
private static final TypeMatcher DICT_OR_SUBCLASS_KEYS_MATCHER =
35+
TypeMatchers.isFunctionOwnerSatisfying(TypeMatchers.isOrExtendsType("builtins.dict"));
36+
37+
@Override
38+
public void initialize(Context context) {
39+
context.registerSyntaxNodeConsumer(Tree.Kind.IN, DictKeysMembershipTestCheck::checkInExpression);
40+
}
41+
42+
private static void checkInExpression(SubscriptionContext ctx) {
43+
InExpression inExpression = (InExpression) ctx.syntaxNode();
44+
if (!(inExpression.rightOperand() instanceof CallExpression callExpression)) {
45+
return;
46+
}
47+
if (!callExpression.arguments().isEmpty()) {
48+
return;
49+
}
50+
if (!isKeysCall(callExpression)) {
51+
return;
52+
}
53+
if (DICT_OR_SUBCLASS_KEYS_MATCHER.isTrueFor(callExpression.callee(), ctx)) {
54+
ctx.addIssue(callExpression, MESSAGE);
55+
}
56+
}
57+
58+
private static boolean isKeysCall(CallExpression callExpression) {
59+
return callExpression.callee() instanceof QualifiedExpression qualifiedExpression
60+
&& KEYS_METHOD_NAME.equals(qualifiedExpression.name().name());
61+
}
62+
}

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
@@ -187,6 +187,7 @@ public Stream<Class<?>> getChecks() {
187187
DedicatedAssertionCheck.class,
188188
DefaultFactoryArgumentCheck.class,
189189
DeprecatedNumpyTypesCheck.class,
190+
DictKeysMembershipTestCheck.class,
190191
DictionaryDuplicateKeyCheck.class,
191192
DictionaryStaticKeyCheck.class,
192193
DirectTypeComparisonCheck.class,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<p>This rule raises an issue when <code>.keys()</code> is called explicitly on a dictionary for membership testing.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>In Python, dictionaries support direct membership testing without needing to explicitly call the <code>.keys()</code> method. When you write
4+
<code>key in dict.keys()</code>, you are being unnecessarily verbose.</p>
5+
<p>The expression <code>key in dict</code> is the idiomatic Python way to check for key membership. It is:</p>
6+
<ul>
7+
<li><em>more readable</em>: the intent is clearer and the code is shorter</li>
8+
<li><em>slightly more efficient</em>: it avoids creating an intermediate dictionary view object</li>
9+
<li><em>more Pythonic</em>: it follows Python's principle of having one obvious way to do it</li>
10+
</ul>
11+
<p>The <code>.keys()</code> method exists primarily for cases where you need to perform set operations on dictionary keys or when you need an explicit
12+
view object. For simple membership testing, it is redundant because dictionaries are designed to support the <code>in</code> operator directly.</p>
13+
<h2>How to fix it</h2>
14+
<p>Remove the <code>.keys()</code> call and use direct membership testing on the dictionary.</p>
15+
<h3>Code examples</h3>
16+
<h4>Noncompliant code example</h4>
17+
<pre data-diff-id="1" data-diff-type="noncompliant">
18+
my_dict = {'a': 1, 'b': 2, 'c': 3}
19+
20+
if 'a' in my_dict.keys(): # Noncompliant
21+
print('Found')
22+
</pre>
23+
<h4>Compliant solution</h4>
24+
<pre data-diff-id="1" data-diff-type="compliant">
25+
my_dict = {'a': 1, 'b': 2, 'c': 3}
26+
27+
if 'a' in my_dict:
28+
print('Found')
29+
</pre>
30+
<h2>Resources</h2>
31+
<h3>Documentation</h3>
32+
<ul>
33+
<li>Python Documentation - <a href="https://docs.python.org/3/library/stdtypes.html#mapping-types-dict">Mapping Types — dict</a></li>
34+
<li>Python Documentation - <a href="https://docs.python.org/3/library/stdtypes.html#dictionary-view-objects">Dictionary view objects</a></li>
35+
</ul>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"title": "Dictionary membership tests should not explicitly call \".keys()\"",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "2min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Minor",
11+
"ruleSpecification": "RSPEC-8521",
12+
"sqKey": "S8521",
13+
"scope": "All",
14+
"quickfix": "unknown"
15+
}
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 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 org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class DictKeysMembershipTestCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/dictKeysMembershipTest.py", new DictKeysMembershipTestCheck());
27+
}
28+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
def noncompliant_basic():
2+
my_dict = {'a': 1, 'b': 2}
3+
if 'a' in my_dict.keys(): # Noncompliant {{Remove this unnecessary "keys()" call.}}
4+
# ^^^^^^^^^^^^^^
5+
print('Found')
6+
7+
8+
def noncompliant_not_in():
9+
my_dict = {'a': 1, 'b': 2}
10+
if 'a' not in my_dict.keys(): # Noncompliant {{Remove this unnecessary "keys()" call.}}
11+
# ^^^^^^^^^^^^^^
12+
print('Not found')
13+
14+
15+
def compliant_direct_membership():
16+
my_dict = {'a': 1, 'b': 2}
17+
if 'a' in my_dict:
18+
print('Found')
19+
20+
21+
def compliant_keys_with_argument():
22+
my_dict = {'a': 1}
23+
if 'a' in my_dict.keys('unexpected'):
24+
print('Not flagged')
25+
26+
27+
def compliant_values_call():
28+
my_dict = {'a': 1, 'b': 2}
29+
if 1 in my_dict.values():
30+
print('Found value')
31+
32+
33+
def compliant_custom_class():
34+
class CustomMapping:
35+
def keys(self):
36+
return ['x', 'y']
37+
38+
obj = CustomMapping()
39+
if 'x' in obj.keys():
40+
print('Found')

0 commit comments

Comments
 (0)