Skip to content

Commit 9bf9a6d

Browse files
SONARPY-761 Rule S5845: Assertions of dissimilar types should not be made (#1175)
1 parent 538e763 commit 9bf9a6d

File tree

13 files changed

+495
-25
lines changed

13 files changed

+495
-25
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
import org.sonar.python.checks.regex.SuperfluousCurlyBraceCheck;
7575
import org.sonar.python.checks.regex.UnquantifiedNonCapturingGroupCheck;
7676
import org.sonar.python.checks.regex.VerboseRegexCheck;
77+
import org.sonar.python.checks.tests.AssertOnDissimilarTypesCheck;
7778
import org.sonar.python.checks.tests.AssertOnTupleLiteralCheck;
7879
import org.sonar.python.checks.tests.DedicatedAssertionCheck;
7980
import org.sonar.python.checks.tests.ImplicitlySkippedTestCheck;
@@ -94,6 +95,7 @@ public static Iterable<Class> getChecks() {
9495
AnchorPrecedenceCheck.class,
9596
ArgumentNumberCheck.class,
9697
ArgumentTypeCheck.class,
98+
AssertOnDissimilarTypesCheck.class,
9799
AssertOnTupleLiteralCheck.class,
98100
BackslashInStringCheck.class,
99101
BackticksUsageCheck.class,

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,10 @@ public boolean canImplementEqOrNe(Expression expression) {
206206
@Override
207207
String builtinTypeCategory(InferredType inferredType) {
208208
// inferredType will always contain a declared type because of the check done inside 'areIdentityComparableOrNone'
209-
return BUILTINS_TYPE_CATEGORY.keySet().stream()
209+
Map<String, String> builtinsTypeCategory = InferredTypes.getBuiltinsTypeCategory();
210+
return builtinsTypeCategory.keySet().stream()
210211
.filter(typeName -> typeSymbols(inferredType).stream().map(Symbol::fullyQualifiedName).allMatch(typeName::equals))
211-
.map(BUILTINS_TYPE_CATEGORY::get).findFirst().orElse(null);
212+
.map(builtinsTypeCategory::get).findFirst().orElse(null);
212213
}
213214

214215
@Override

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

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,18 @@
2020
package org.sonar.python.checks;
2121

2222
import java.util.Arrays;
23-
import java.util.HashMap;
2423
import java.util.HashSet;
2524
import javax.annotation.CheckForNull;
2625
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2726
import org.sonar.plugins.python.api.SubscriptionContext;
2827
import org.sonar.plugins.python.api.tree.BinaryExpression;
2928
import org.sonar.plugins.python.api.tree.Expression;
3029
import org.sonar.plugins.python.api.tree.Tree;
31-
import org.sonar.plugins.python.api.types.BuiltinTypes;
3230
import org.sonar.plugins.python.api.types.InferredType;
3331

3432
public abstract class SillyEquality extends PythonSubscriptionCheck {
3533

3634
private static final HashSet<String> CONSIDERED_OPERATORS = new HashSet<>(Arrays.asList("==", "!="));
37-
protected static final HashMap<String, String> BUILTINS_TYPE_CATEGORY = new HashMap<>();
38-
39-
private static final String NUMBER = "number";
40-
41-
static {
42-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.STR, BuiltinTypes.STR);
43-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.INT, NUMBER);
44-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.FLOAT, NUMBER);
45-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.COMPLEX, NUMBER);
46-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.BOOL, NUMBER);
47-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.LIST, BuiltinTypes.LIST);
48-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.SET, BuiltinTypes.SET);
49-
BUILTINS_TYPE_CATEGORY.put("frozenset", BuiltinTypes.SET);
50-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.DICT, BuiltinTypes.DICT);
51-
BUILTINS_TYPE_CATEGORY.put(BuiltinTypes.TUPLE, BuiltinTypes.TUPLE);
52-
}
5335

5436
@Override
5537
public void initialize(Context context) {

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.sonar.check.Rule;
2424
import org.sonar.plugins.python.api.tree.Expression;
2525
import org.sonar.plugins.python.api.types.InferredType;
26+
import org.sonar.python.types.InferredTypes;
2627

2728
import static org.sonar.plugins.python.api.types.BuiltinTypes.NONE_TYPE;
2829

@@ -43,9 +44,7 @@ public boolean canImplementEqOrNe(Expression expression) {
4344
@CheckForNull
4445
@Override
4546
String builtinTypeCategory(InferredType inferredType) {
46-
return BUILTINS_TYPE_CATEGORY.keySet().stream()
47-
.filter(inferredType::canOnlyBe)
48-
.map(BUILTINS_TYPE_CATEGORY::get).findFirst().orElse(null);
47+
return InferredTypes.getBuiltinCategory(inferredType);
4948
}
5049

5150
@Override
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-2022 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.tests;
21+
22+
import java.util.Comparator;
23+
import java.util.List;
24+
import java.util.Optional;
25+
import java.util.Set;
26+
import org.sonar.check.Rule;
27+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
28+
import org.sonar.plugins.python.api.SubscriptionContext;
29+
import org.sonar.plugins.python.api.symbols.Symbol;
30+
import org.sonar.plugins.python.api.symbols.Usage;
31+
import org.sonar.plugins.python.api.tree.ArgList;
32+
import org.sonar.plugins.python.api.tree.Argument;
33+
import org.sonar.plugins.python.api.tree.AssignmentStatement;
34+
import org.sonar.plugins.python.api.tree.CallExpression;
35+
import org.sonar.plugins.python.api.tree.Expression;
36+
import org.sonar.plugins.python.api.tree.Name;
37+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
38+
import org.sonar.plugins.python.api.tree.RegularArgument;
39+
import org.sonar.plugins.python.api.tree.Tree;
40+
import org.sonar.plugins.python.api.types.InferredType;
41+
import org.sonar.python.tests.UnittestUtils;
42+
import org.sonar.python.tree.TreeUtils;
43+
import org.sonar.python.types.InferredTypes;
44+
45+
import static org.sonar.plugins.python.api.types.BuiltinTypes.NONE_TYPE;
46+
47+
@Rule(key = "S5845")
48+
public class AssertOnDissimilarTypesCheck extends PythonSubscriptionCheck {
49+
private static final String MESSAGE = "Change this assertion to not compare dissimilar types";
50+
private static final String MESSAGE_SECONDARY = "Last assignment of \"%s\"";
51+
private static final Set<String> assertToCheckEquality = Set.of("assertEqual", "assertNotEqual");
52+
private static final Set<String> assertToCheckIdentity = Set.of("assertIs", "assertIsNot");
53+
private static final String FIRST_ARG_KEYWORD = "first";
54+
private static final String SECOND_ARG_KEYWORD = "second";
55+
56+
@Override
57+
public void initialize(Context context) {
58+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, ctx -> {
59+
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
60+
if (!callExpression.callee().is(Tree.Kind.QUALIFIED_EXPR)) {
61+
return;
62+
}
63+
64+
QualifiedExpression qualifiedExpression = (QualifiedExpression) callExpression.callee();
65+
if ((!(qualifiedExpression.qualifier().is(Tree.Kind.NAME) && ((Name) qualifiedExpression.qualifier()).name().equals("self")))
66+
|| !UnittestUtils.isWithinUnittestTestCase(qualifiedExpression)) {
67+
return;
68+
}
69+
70+
checkArguments(ctx, callExpression, qualifiedExpression);
71+
});
72+
}
73+
74+
private static void checkArguments(SubscriptionContext ctx, CallExpression callExpression, QualifiedExpression qualifiedExpression) {
75+
boolean isAnAssertIdentity = assertToCheckIdentity.contains(qualifiedExpression.name().name());
76+
boolean isAnAssertEquality = assertToCheckEquality.contains(qualifiedExpression.name().name());
77+
78+
if (!isAnAssertEquality && !isAnAssertIdentity) {
79+
return;
80+
}
81+
82+
ArgList args = callExpression.argumentList();
83+
if (args == null) {
84+
return;
85+
}
86+
87+
List<Argument> arguments = args.arguments();
88+
RegularArgument firstArg = TreeUtils.nthArgumentOrKeyword(0, FIRST_ARG_KEYWORD, arguments);
89+
RegularArgument secondArg = TreeUtils.nthArgumentOrKeyword(1, SECOND_ARG_KEYWORD, arguments);
90+
if (firstArg == null || secondArg == null) {
91+
return;
92+
}
93+
94+
Expression left = firstArg.expression();
95+
Expression right = secondArg.expression();
96+
97+
if (canArgumentsBeIdentical(left, right)) {
98+
return;
99+
}
100+
101+
if (isAnAssertIdentity || !canArgumentsBeEqual(left, right)) {
102+
PreciseIssue issue = ctx.addIssue(args, message(left, right));
103+
getLastAssignment(left).ifPresent(assign -> issue.secondary(assign, String.format(MESSAGE_SECONDARY, ((Name)left).name())));
104+
getLastAssignment(right).ifPresent(assign -> issue.secondary(assign, String.format(MESSAGE_SECONDARY, ((Name)right).name())));
105+
}
106+
}
107+
108+
private static String message(Expression left, Expression right) {
109+
String leftTypeName = InferredTypes.typeName(left.type());
110+
String rightTypeName = InferredTypes.typeName(right.type());
111+
String message = MESSAGE;
112+
if (leftTypeName != null && rightTypeName != null) {
113+
message += " (" + leftTypeName + " and " + rightTypeName + ")";
114+
}
115+
message += ".";
116+
return message;
117+
}
118+
119+
private static Optional<AssignmentStatement> getLastAssignment(Expression expr) {
120+
if (!expr.is(Tree.Kind.NAME)) {
121+
return Optional.empty();
122+
}
123+
124+
Symbol symbol = ((Name) expr).symbol();
125+
if (symbol == null) {
126+
return Optional.empty();
127+
}
128+
129+
Usage lastAssignment = symbol.usages().stream()
130+
.sorted(Comparator.comparingInt(u -> u.tree().firstToken().line()))
131+
.takeWhile(usage -> usage != ((Name) expr).usage())
132+
.filter(usage -> usage.kind() == Usage.Kind.ASSIGNMENT_LHS)
133+
.reduce((first, second) -> second)
134+
.orElse(null);
135+
136+
return Optional.ofNullable(lastAssignment)
137+
.map(assignment -> (AssignmentStatement) TreeUtils.firstAncestorOfKind(assignment.tree(), Tree.Kind.ASSIGNMENT_STMT));
138+
}
139+
140+
private static boolean canArgumentsBeIdentical(Expression left, Expression right) {
141+
return left.type().isIdentityComparableWith(right.type()) || left.type().canOnlyBe(NONE_TYPE) || right.type().canOnlyBe(NONE_TYPE);
142+
}
143+
144+
private static boolean canArgumentsBeEqual(Expression left, Expression right) {
145+
String leftCategory = InferredTypes.getBuiltinCategory(left.type());
146+
String rightCategory = InferredTypes.getBuiltinCategory(right.type());
147+
boolean leftCanImplementEqOrNe = canImplementEqOrNe(left);
148+
boolean rightCanImplementEqOrNe = canImplementEqOrNe(right);
149+
150+
if ((leftCategory != null && leftCategory.equals(rightCategory))) {
151+
return true;
152+
}
153+
154+
return (leftCanImplementEqOrNe || rightCanImplementEqOrNe)
155+
&& (leftCategory == null || rightCategory == null)
156+
&& (leftCategory == null || rightCanImplementEqOrNe)
157+
&& (rightCategory == null || leftCanImplementEqOrNe);
158+
}
159+
160+
private static boolean canImplementEqOrNe(Expression expression) {
161+
InferredType type = expression.type();
162+
return type.canHaveMember("__eq__") || type.canHaveMember("__ne__");
163+
}
164+
165+
@Override
166+
public CheckScope scope() {
167+
return CheckScope.ALL;
168+
}
169+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<p>Calling <code>unittest</code> methods <code>assertEqual</code>, <code>assertNotEqual</code>, <code>assertIs</code> or <code>assertIsNot</code> on
2+
objects of incompatible types will always fail or always succeed.</p>
3+
<p>For methods <code>assertEqual</code> and <code>assertNotEqual</code>, arguments' types are incompatible if:</p>
4+
<pre>
5+
they are unrelated builtin types such as string and integer.
6+
</pre>
7+
<pre>
8+
they are instances of unrelated classes which do not implement ``++__eq__++`` or ``++__ne__++`` (if a class implements one of these methods it could compare to any other type it wants).
9+
</pre>
10+
<p>As for methods <code>assertIs</code> and <code>assertIsNot</code>, if arguments' types are different it is not possible for them to point to the
11+
same object, thus <code>assertIs</code> will always fail and <code>assertIsNot</code> will always succeed.</p>
12+
<h2>Noncompliant Code Example</h2>
13+
<pre>
14+
import unittest
15+
class A(): ...
16+
class MyTest(unittest.TestCase):
17+
def test_something(self):
18+
a = A()
19+
mydict = {"x": a}
20+
self.assertEqual(a, "x") # Noncompliant
21+
self.assertIs(a, "x") # Noncompliant
22+
</pre>
23+
<h2>Compliant Solution</h2>
24+
<pre>
25+
import unittest
26+
class A(): ...
27+
class MyTest(unittest.TestCase):
28+
def test_something(self):
29+
a = A()
30+
mydict = {"x": a}
31+
self.assertEqual(a, mydict["x"]) # OK
32+
self.assertIs(a, mydict["x"]) # OK
33+
</pre>
34+
<h2>See</h2>
35+
<ul>
36+
<li> {rule:python:S2159} Silly equality checks should not be made </li>
37+
<li> {rule:python:S3403} Identity operators should not be used with dissimilar types </li>
38+
</ul>
39+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"title": "Assertions comparing incompatible types should not be made",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"tests"
11+
],
12+
"defaultSeverity": "Critical",
13+
"ruleSpecification": "RSPEC-5845",
14+
"sqKey": "S5845",
15+
"scope": "Tests",
16+
"quickfix": "unknown"
17+
}

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
@@ -141,6 +141,7 @@
141141
"S5828",
142142
"S5842",
143143
"S5843",
144+
"S5845",
144145
"S5850",
145146
"S5855",
146147
"S5857",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2022 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.tests;
21+
22+
import org.junit.Test;
23+
import org.sonar.plugins.python.api.PythonCheck;
24+
import org.sonar.python.checks.utils.PythonCheckVerifier;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
28+
public class AssertOnDissimilarTypesCheckTest {
29+
30+
@Test
31+
public void test() {
32+
PythonCheckVerifier.verify("src/test/resources/checks/tests/assertOnDissimilarTypes.py", new AssertOnDissimilarTypesCheck());
33+
}
34+
35+
@Test
36+
public void testAnotherLibrary() {
37+
PythonCheckVerifier.verify("src/test/resources/checks/tests/assertOnDissimilarTypesAnotherLibrary.py", new AssertOnDissimilarTypesCheck());
38+
}
39+
40+
@Test
41+
public void test_scope() {
42+
assertThat(new AssertOnDissimilarTypesCheck().scope()).isEqualTo(PythonCheck.CheckScope.ALL);
43+
}
44+
}

0 commit comments

Comments
 (0)