Skip to content

Commit ccbab7b

Browse files
SONARPY-1404 Rule S5642: "in" and "not in" operators should be used on objects supporting them (#1522)
1 parent 650f238 commit ccbab7b

File tree

8 files changed

+516
-0
lines changed

8 files changed

+516
-0
lines changed

its/ruling/src/test/java/org/sonar/python/it/RulingHelper.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ static List<String> bugRuleKeys() {
122122
"S5549",
123123
"S5607",
124124
"S5632",
125+
"S5642",
125126
"S5644",
126127
"S5707",
127128
"S5708",

python-checks/src/main/java/org/sonar/python/checks/CheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ public static Iterable<Class> getChecks() {
247247
LoopExecutingAtMostOnceCheck.class,
248248
MandatoryFunctionParameterTypeHintCheck.class,
249249
MandatoryFunctionReturnTypeHintCheck.class,
250+
MembershipTestSupportCheck.class,
250251
MethodNameCheck.class,
251252
MethodShouldBeStaticCheck.class,
252253
MissingDocstringCheck.class,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2023 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks;
21+
22+
import java.util.List;
23+
import java.util.stream.Collectors;
24+
import org.sonar.check.Rule;
25+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
26+
import org.sonar.plugins.python.api.SubscriptionContext;
27+
import org.sonar.plugins.python.api.symbols.AmbiguousSymbol;
28+
import org.sonar.plugins.python.api.symbols.Symbol;
29+
import org.sonar.plugins.python.api.symbols.Usage;
30+
import org.sonar.plugins.python.api.tree.AssignmentStatement;
31+
import org.sonar.plugins.python.api.tree.Expression;
32+
import org.sonar.plugins.python.api.tree.InExpression;
33+
import org.sonar.plugins.python.api.tree.Tree;
34+
import org.sonar.plugins.python.api.types.InferredType;
35+
import org.sonar.python.tree.TreeUtils;
36+
import org.sonar.python.types.InferredTypes;
37+
38+
@Rule(key = "S5642")
39+
public class MembershipTestSupportCheck extends PythonSubscriptionCheck {
40+
private static final String PRIMARY_MESSAGE = "Change the type of %s";
41+
private static final String PRIMARY_MESSAGE_MULTILINE = "Change the type for the target expression of `in`";
42+
private static final String KNOWN_TYPE_MESSAGE = "; type %s does not support membership protocol.";
43+
private static final String UNKNOWN_TYPE_MESSAGE = "; the type does not support the membership protocol.";
44+
private static final String SECONDARY_MESSAGE = "The result value of this expression does not support the membership protocol.";
45+
46+
// The ordering of membership protocol methods matters here (for extreme edge cases).
47+
//
48+
// For instance, a class that has __contains__ set to None but which defines __iter__ does not fulfill the membership protocol
49+
// A class that defines __contains__ and has __iter__ set to None does fulfill the membership protocol
50+
//
51+
// Hence, we check for these special methods in the same order as the python interpreter does at runtime.
52+
private static final List<String> MEMBERSHIP_PROTOCOL_ENABLING_METHODS = List.of("__contains__", "__iter__", "__getitem__");
53+
54+
@Override
55+
public void initialize(Context context) {
56+
context.registerSyntaxNodeConsumer(Tree.Kind.IN, ctx -> checkInExpression(ctx, (InExpression) ctx.syntaxNode()));
57+
}
58+
59+
private static void checkInExpression(SubscriptionContext ctx, InExpression inExpression) {
60+
Expression rhs = inExpression.rightOperand();
61+
InferredType rhsType = rhs.type();
62+
63+
if (canSupportMembershipProtocol(rhsType)) {
64+
return;
65+
}
66+
67+
addIssueOnInAndNotIn(ctx, inExpression, genPrimaryMessage(rhs, rhsType))
68+
.secondary(inExpression.rightOperand(), SECONDARY_MESSAGE);
69+
}
70+
71+
private static String genPrimaryMessage(Expression rhs, InferredType rhsType) {
72+
// Try to render rhs into the message, but only if it is not multiline to avoid generating messages that are too long.
73+
String inTarget = TreeUtils.treeToString(rhs, false);
74+
String message;
75+
if (inTarget == null) {
76+
message = PRIMARY_MESSAGE_MULTILINE;
77+
} else {
78+
message = String.format(PRIMARY_MESSAGE, inTarget);
79+
}
80+
81+
var typeName = InferredTypes.typeName(rhsType);
82+
if (typeName != null) {
83+
message += String.format(KNOWN_TYPE_MESSAGE, typeName);
84+
} else {
85+
message += UNKNOWN_TYPE_MESSAGE;
86+
}
87+
88+
return message;
89+
}
90+
91+
private static PreciseIssue addIssueOnInAndNotIn(SubscriptionContext ctx, InExpression inExpression, String message) {
92+
var notToken = inExpression.notToken();
93+
if (notToken == null) {
94+
return ctx.addIssue(inExpression.operator(), message);
95+
}
96+
97+
return ctx.addIssue(notToken, inExpression.operator(), message);
98+
}
99+
100+
private static boolean canSupportMembershipProtocol(InferredType type) {
101+
for (var methodName : MEMBERSHIP_PROTOCOL_ENABLING_METHODS) {
102+
switch (canMemberBeMethod(type, methodName)) {
103+
case NOT_A_METHOD:
104+
return false;
105+
case METHOD:
106+
case UNKNOWN:
107+
return true;
108+
default:
109+
}
110+
}
111+
112+
// all membership protocol methods are guaranteed not to be present
113+
return false;
114+
}
115+
116+
private enum MemberType {
117+
METHOD,
118+
NOT_A_METHOD,
119+
NOT_PRESENT,
120+
UNKNOWN
121+
}
122+
123+
private static boolean canBeMethodSymbol(Symbol symbol) {
124+
if (symbol.is(Symbol.Kind.FUNCTION)) {
125+
return true;
126+
}
127+
128+
// To avoid FPs, we accept OTHER unless we can show that it is a non-callable.
129+
//
130+
// This handles cases like __contains__ = other() or __contains__ = None.
131+
// Although it is unclear, whether such edge cases really appear in the wild
132+
if (symbol.is(Symbol.Kind.OTHER)) {
133+
var bindingUsages = symbol.usages().stream().filter(Usage::isBindingUsage).limit(2).collect(Collectors.toUnmodifiableList());
134+
if (bindingUsages.size() == 1) {
135+
var bindingUsage = bindingUsages.get(0);
136+
var assignment = TreeUtils.firstAncestorOfKind(bindingUsage.tree(), Tree.Kind.ASSIGNMENT_STMT);
137+
138+
return assignment == null || ((AssignmentStatement) assignment).assignedValue().type().canHaveMember("__call__");
139+
}
140+
141+
return true;
142+
}
143+
144+
if (symbol.is(Symbol.Kind.AMBIGUOUS)) {
145+
return ((AmbiguousSymbol) symbol).alternatives().stream().anyMatch(MembershipTestSupportCheck::canBeMethodSymbol);
146+
}
147+
148+
return false;
149+
}
150+
151+
private static MemberType canMemberBeMethod(InferredType type, String methodName) {
152+
var maybeMember = type.resolveMember(methodName);
153+
if (maybeMember.isPresent()) {
154+
var symbol = maybeMember.get();
155+
156+
if (canBeMethodSymbol(symbol)) {
157+
return MemberType.METHOD;
158+
}
159+
160+
return MemberType.NOT_A_METHOD;
161+
}
162+
163+
if (!type.canHaveMember(methodName)) {
164+
return MemberType.NOT_PRESENT;
165+
}
166+
167+
return MemberType.UNKNOWN;
168+
}
169+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<p>This rule raises an issue when operators <code>in</code> and <code>not in</code> are called with a right operand not supporting membership
2+
protocol.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>Operators <code>in</code> and <code>not in</code>, also called <a
5+
href="https://docs.python.org/3/reference/expressions.html#membership-test-operations">"membership test operators"</a>, require that the right operand
6+
supports the membership protocol.</p>
7+
<p>In order to support the membership protocol, a user-defined class should implement at least one of the following methods:
8+
<code>__contains__</code>, <code>__iter__</code>, <code>__getitem__</code>.</p>
9+
<p>If none of these methods is implemented, a <code>TypeError</code> will be raised when performing a membership test.</p>
10+
<h2>How to fix it</h2>
11+
<h2>Code examples</h2>
12+
<h3>Noncompliant code example</h3>
13+
<pre>
14+
myint = 42
15+
16+
if 42 in myint: # Noncompliant: integers don't support membership protocol
17+
...
18+
19+
class A:
20+
def __init__(self, values):
21+
self._values = values
22+
23+
if "mystring" in A(["mystring"]): # Noncompliant: class A doesn't support membership protocol
24+
...
25+
</pre>
26+
<h3>Compliant solution</h3>
27+
<pre>
28+
mylist = [42]
29+
30+
if 42 in mylist:
31+
...
32+
33+
class MyContains:
34+
def __init__(self, values):
35+
self._values = values
36+
37+
def __contains__(self, value):
38+
return value in self._values
39+
40+
if "mystring" in MyContains(["mystring"]):
41+
...
42+
43+
# OR
44+
45+
class MyIterable:
46+
def __init__(self, values):
47+
self._values = values
48+
49+
def __iter__(self):
50+
return iter(self._values)
51+
52+
if "mystring" in MyIterable(["mystring"]):
53+
...
54+
55+
# OR
56+
57+
class MyGetItem:
58+
def __init__(self, values):
59+
self._values = values
60+
61+
def __getitem__(self, key):
62+
return self._values[key]
63+
64+
if "mystring" in MyGetItem(["mystring"]):
65+
...
66+
</pre>
67+
<h2>Resources</h2>
68+
<h3>Documentation</h3>
69+
<ul>
70+
<li> Python Documentation - <a href="https://docs.python.org/3/reference/expressions.html#membership-test-operations">Membership test operations</a>
71+
</li>
72+
</ul>
73+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"title": "\"in\" and \"not in\" operators should be used on objects supporting them",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "20min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Blocker",
11+
"ruleSpecification": "RSPEC-5642",
12+
"sqKey": "S5642",
13+
"scope": "All",
14+
"quickfix": "unknown"
15+
}

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
@@ -110,6 +110,7 @@
110110
"S5603",
111111
"S5607",
112112
"S5632",
113+
"S5642",
113114
"S5644",
114115
"S5655",
115116
"S5659",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2023 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks;
21+
22+
import org.junit.Test;
23+
import org.sonar.python.checks.utils.PythonCheckVerifier;
24+
25+
public class MembershipTestSupportCheckTest {
26+
27+
@Test
28+
public void test() {
29+
PythonCheckVerifier.verify("src/test/resources/checks/membershipTestSupportCheck.py", new MembershipTestSupportCheck());
30+
}
31+
}

0 commit comments

Comments
 (0)