Skip to content

Commit 8401c22

Browse files
sonar-nigel[bot]Sonar Vibe Botjoke1196
authored andcommitted
SONARPY-3941 Rule S8509 Classes should not inherit from the same base class multiple times (#979)
Co-authored-by: Sonar Vibe Bot <vibe-bot@sonarsource.com> Co-authored-by: joke1196 <david.kunzmann@sonarsource.com> GitOrigin-RevId: a0640cc32e6b22f696b79fbd4b6cf710354ef909
1 parent 5c10cd4 commit 8401c22

File tree

7 files changed

+301
-0
lines changed

7 files changed

+301
-0
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 java.util.ArrayList;
20+
import java.util.LinkedHashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
import javax.annotation.CheckForNull;
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.tree.ArgList;
28+
import org.sonar.plugins.python.api.tree.Argument;
29+
import org.sonar.plugins.python.api.tree.ClassDef;
30+
import org.sonar.plugins.python.api.tree.Expression;
31+
import org.sonar.plugins.python.api.tree.RegularArgument;
32+
import org.sonar.plugins.python.api.tree.Tree;
33+
import org.sonar.plugins.python.api.types.v2.FullyQualifiedNameHelper;
34+
35+
@Rule(key = "S8509")
36+
public class DuplicateBaseClassCheck extends PythonSubscriptionCheck {
37+
38+
private static final String MESSAGE = "Remove this duplicate base class.";
39+
private static final String SECONDARY_MESSAGE = "Already listed here.";
40+
41+
@Override
42+
public void initialize(Context context) {
43+
context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, ctx -> checkClassDef((ClassDef) ctx.syntaxNode(), ctx));
44+
}
45+
46+
private static void checkClassDef(ClassDef classDef, SubscriptionContext ctx) {
47+
ArgList args = classDef.args();
48+
if (args == null) {
49+
return;
50+
}
51+
var duplicates = gatherDuplicates(args);
52+
raiseOnDuplicates(duplicates, ctx);
53+
}
54+
55+
private static Map<String, List<RegularArgument>> gatherDuplicates(ArgList args) {
56+
Map<String, List<RegularArgument>> groups = new LinkedHashMap<>();
57+
for (Argument argument : args.arguments()) {
58+
if (argument instanceof RegularArgument regularArgument) {
59+
if (regularArgument.keywordArgument() != null) {
60+
continue;
61+
}
62+
String key = expressionKey(regularArgument.expression());
63+
if (key != null) {
64+
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(regularArgument);
65+
}
66+
}
67+
}
68+
return groups;
69+
}
70+
71+
private static void raiseOnDuplicates(Map<String, List<RegularArgument>> duplicates, SubscriptionContext ctx) {
72+
for (List<RegularArgument> group : duplicates.values()) {
73+
if (group.size() > 1) {
74+
RegularArgument first = group.get(0);
75+
PreciseIssue issue = ctx.addIssue(first.expression(), MESSAGE);
76+
for (int i = 1; i < group.size(); i++) {
77+
issue.secondary(group.get(i).expression(), SECONDARY_MESSAGE);
78+
}
79+
}
80+
}
81+
}
82+
83+
@CheckForNull
84+
private static String expressionKey(Expression expression) {
85+
return FullyQualifiedNameHelper.getFullyQualifiedName(expression.typeV2()).orElse(null);
86+
}
87+
}

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
@@ -200,6 +200,7 @@ public Stream<Class<?>> getChecks() {
200200
DjangoRenderContextCheck.class,
201201
DoublePrefixOperatorCheck.class,
202202
DuplicateArgumentCheck.class,
203+
DuplicateBaseClassCheck.class,
203204
DuplicatedMethodFieldNamesCheck.class,
204205
DuplicatedMethodImplementationCheck.class,
205206
DuplicatesInCharacterClassCheck.class,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<p>This rule raises an issue when the same class appears more than once in the inheritance list of a class definition.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>In Python, a class definition can inherit from one or more base classes. When the same base class appears multiple times in the inheritance list,
4+
it serves no purpose and is always a programming error. Python's Method Resolution Order (MRO) ensures that each class appears only once in the
5+
inheritance chain, even if it is listed multiple times.</p>
6+
<p>This typically happens due to copy-paste errors, misunderstanding of inheritance, or refactoring mistakes.</p>
7+
<h2>How to fix it</h2>
8+
<p>Remove the duplicate base class from the inheritance list, keeping only one occurrence.</p>
9+
<h3>Code examples</h3>
10+
<h4>Noncompliant code example</h4>
11+
<pre data-diff-id="1" data-diff-type="noncompliant">
12+
class MyWidget(QWidget, Serializable, QWidget): # Noncompliant
13+
pass
14+
</pre>
15+
<h4>Compliant solution</h4>
16+
<pre data-diff-id="1" data-diff-type="compliant">
17+
class MyWidget(QWidget, Serializable):
18+
pass
19+
</pre>
20+
<h2>Resources</h2>
21+
<h3>Documentation</h3>
22+
<ul>
23+
<li>Python Documentation - <a href="https://docs.python.org/3/tutorial/classes.html#multiple-inheritance">Multiple Inheritance</a></li>
24+
<li>Python Documentation - <a href="https://docs.python.org/3/glossary.html#term-method-resolution-order">Method Resolution Order</a></li>
25+
</ul>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "Classes should not inherit from the same base class multiple times",
3+
"type": "CODE_SMELL",
4+
"code": {
5+
"impacts": {
6+
"MAINTAINABILITY": "MEDIUM"
7+
},
8+
"attribute": "CLEAR"
9+
},
10+
"status": "ready",
11+
"remediation": {
12+
"func": "Constant\/Issue",
13+
"constantCost": "5min"
14+
},
15+
"tags": [
16+
"confusing",
17+
"suspicious"
18+
],
19+
"defaultSeverity": "Major",
20+
"ruleSpecification": "RSPEC-8509",
21+
"sqKey": "S8509",
22+
"scope": "All",
23+
"quickfix": "unknown"
24+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 DuplicateBaseClassCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/duplicateBaseClass.py", new DuplicateBaseClassCheck());
27+
}
28+
29+
@Test
30+
void test_no_issue() {
31+
PythonCheckVerifier.verifyNoIssue("src/test/resources/checks/duplicateBaseClassCompliant.py", new DuplicateBaseClassCheck());
32+
}
33+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from os import PathLike
2+
import my_class
3+
from my_class import A, B, C, X, mixin
4+
import a
5+
import mod
6+
# Examples with issues:
7+
8+
# Duplicate base with a resolved fully-qualified symbol (exercises the FQN path in expressionKey)
9+
class ResolvedSymbolDuplicate(PathLike, PathLike): # Noncompliant {{Remove this duplicate base class.}}
10+
# ^^^^^^^^ ^^^^^^^^< {{Already listed here.}}
11+
pass
12+
13+
class SimpleEndDuplicate(A, B, A): # Noncompliant {{Remove this duplicate base class.}}
14+
# ^ ^< {{Already listed here.}}
15+
pass
16+
17+
class MidAndEndDuplicate(A, B, my_class.B): # Noncompliant {{Remove this duplicate base class.}}
18+
# ^ ^^^^^^^^^^< {{Already listed here.}}
19+
pass
20+
21+
class QualifiedDuplicate(mod.Base, Other, mod.Base): # Noncompliant {{Remove this duplicate base class.}}
22+
# ^^^^^^^^ ^^^^^^^^< {{Already listed here.}}
23+
pass
24+
25+
# One issue with two secondaries (2nd and 3rd are duplicates of 1st)
26+
class TripleDuplicate(A, A, A): # Noncompliant {{Remove this duplicate base class.}}
27+
# ^ ^< {{Already listed here.}}
28+
# ^@-1< {{Already listed here.}}
29+
pass
30+
31+
# metaclass keyword argument is skipped, duplicate among positional bases still flagged
32+
class WithMetaclassDuplicate(A, B, A, metaclass=Meta): # Noncompliant
33+
pass
34+
35+
# Two separate groups of duplicates (2 issues: one for A, one for B)
36+
class TwoPairsDuplicates(A, B, A, B): # Noncompliant 2
37+
pass
38+
39+
class ManyBasesDuplicate(A, B, C, D, B): # Noncompliant
40+
pass
41+
42+
class DeepQualifiedDuplicate(a.b.C, D, a.b.C): # Noncompliant
43+
pass
44+
45+
# One issue with two secondaries (2nd and 3rd are duplicates of 1st)
46+
class ThreeTimeDuplicate(mixin, mixin, mixin): # Noncompliant
47+
# ^^^^^ ^^^^^< ^^^^^<
48+
pass
49+
50+
# Star-unpacking is skipped; duplicate among remaining RegularArguments is still detected
51+
class StarAndDuplicate(*extra, A, A): # Noncompliant
52+
pass
53+
54+
# Name with a symbol but no fully-qualified name (parameter symbol has no FQN)
55+
def make_class(other):
56+
class Inner(other, other): # FN
57+
pass
58+
59+
# Compliant examples:
60+
61+
class NoBaseClass:
62+
pass
63+
64+
class SingleBase(A):
65+
pass
66+
67+
class MultipleUnique(A, B, C):
68+
pass
69+
70+
class MultipleUniqueQualified(mod.A, mod.B, mod.C):
71+
pass
72+
73+
class OnlyMetaclass(metaclass=Meta):
74+
pass
75+
76+
class UniqueBasesWithMetaclass(A, B, metaclass=Meta):
77+
pass
78+
79+
# Subscript expressions yield a null key, so duplicates are not detected
80+
class GenericSubscript(Generic[T]):
81+
pass
82+
83+
class TwoIdenticalSubscripts(Generic[T], Generic[T]):
84+
pass
85+
86+
class SubscriptAndName(Generic[T], Base):
87+
pass
88+
89+
# Call expressions yield a null key, so duplicates are not detected
90+
class TwoCallBases(make_base(), make_base()):
91+
pass
92+
93+
# Qualified name whose qualifier is a call result — null key, no issue
94+
class CallQualifiedBase(obj.method().Attr, obj.method().Attr):
95+
pass
96+
97+
# Deep qualified expression where an intermediate qualifier is a call result — null key via recursive path
98+
class DeepCallQualifiedBase(a.b().c.D, a.b().c.D):
99+
pass
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class NoBaseClass:
2+
pass
3+
4+
class SingleBase(A):
5+
pass
6+
7+
class MultipleUnique(A, B, C):
8+
pass
9+
10+
class MultipleUniqueQualified(mod.A, mod.B, mod.C):
11+
pass
12+
13+
class OnlyMetaclass(metaclass=Meta):
14+
pass
15+
16+
class UniqueBasesWithMetaclass(A, B, metaclass=Meta):
17+
pass
18+
19+
class GenericSubscript(Generic[T]):
20+
pass
21+
22+
class TwoIdenticalSubscripts(Generic[T], Generic[T]):
23+
pass
24+
25+
class SubscriptAndName(Generic[T], Base):
26+
pass
27+
28+
class TwoCallBases(make_base(), make_base()):
29+
pass
30+
31+
class CallQualifiedBase(obj.method().Attr, obj.method().Attr):
32+
pass

0 commit comments

Comments
 (0)