Skip to content

Commit e317068

Browse files
marc-jasper-sonarsourcesonartech
authored andcommitted
SONARPY-4016 Fix S8511 FP on built-in containers with ABC bases (e.g. dict/MutableMapping) (#1066)
GitOrigin-RevId: db4db4ef4f05d00eda8061cde8d8e9ef536e08d7
1 parent 21653e8 commit e317068

5 files changed

Lines changed: 227 additions & 35 deletions

File tree

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,14 @@ private static List<Expression> collectPositionalBases(ArgList argList) {
8686
/**
8787
* Detects an MRO conflict: either via C3 when every base is fully resolved, or otherwise only
8888
* when {@link #findAncestorConflictIndex} finds an earlier base that is a strict superclass of a
89-
* later base in our type graph (same structural issue as {@code class C(A, B)} with {@code B}
90-
* subclassing {@code A}).
89+
* later base. Both paths use a runtime-faithful view of built-in containers — see
90+
* {@link ClassType#wouldHaveValidMro(List)}.
9191
*/
9292
private static boolean hasMroConflict(List<ClassType> types, int conflictIndex) {
93-
return isFullyResolved(types)
94-
? !ClassType.wouldHaveValidMro(types)
95-
: (conflictIndex >= 0);
93+
if (isFullyResolved(types)) {
94+
return !ClassType.wouldHaveValidMro(types);
95+
}
96+
return conflictIndex >= 0;
9697
}
9798

9899
/**
@@ -110,7 +111,10 @@ private static boolean isFullyResolved(List<ClassType> types) {
110111

111112
/**
112113
* Returns the index {@code i} of the first base class that is an ancestor of some later base
113-
* class at index {@code j > i}, or {@code -1} if no such pair exists.
114+
* class at index {@code j > i}, or {@code -1} if no such pair exists. Paths from any later base
115+
* that pass through a virtual-ABC-subclassing builtin are cut off, so typeshed-only ABC edges
116+
* (e.g. {@code dict → MutableMapping}) are not treated as real ancestry — see
117+
* {@link #isOrExtendsClassAtRuntime(ClassType, ClassType)}.
114118
*/
115119
private static int findAncestorConflictIndex(List<ClassType> types) {
116120
for (int i = 0; i < types.size() - 1; i++) {
@@ -128,19 +132,20 @@ private static boolean laterBaseExtendsEarlier(List<ClassType> types, int i) {
128132
}
129133
for (int j = i + 1; j < types.size(); j++) {
130134
ClassType typeJ = types.get(j);
131-
if (typeJ != null && isOrExtendsClass(typeJ, typeI)) {
135+
if (typeJ != null && isOrExtendsClassAtRuntime(typeJ, typeI)) {
132136
return true;
133137
}
134138
}
135139
return false;
136140
}
137141

138142
/**
139-
* Returns {@code true} if {@code candidate} is or extends {@code ancestor} (i.e., {@code ancestor}
140-
* appears anywhere in {@code candidate}'s type hierarchy). Uses reference equality since
141-
* {@link ClassType} instances are canonical singletons within a single analysis.
143+
* Returns {@code true} if {@code ancestor} appears in {@code candidate}'s runtime type hierarchy.
144+
* Traversal stops at virtual-ABC-subclassing builtins so their typeshed-only ABC parents are not
145+
* reachable; see {@link ClassType#wouldHaveValidMro(List)}. Uses reference equality
146+
* since {@link ClassType} instances are canonical within a single analysis.
142147
*/
143-
private static boolean isOrExtendsClass(ClassType candidate, ClassType ancestor) {
148+
private static boolean isOrExtendsClassAtRuntime(ClassType candidate, ClassType ancestor) {
144149
Set<PythonType> visited = new HashSet<>();
145150
Deque<PythonType> queue = new ArrayDeque<>();
146151
queue.add(candidate);
@@ -152,7 +157,7 @@ private static boolean isOrExtendsClass(ClassType candidate, ClassType ancestor)
152157
if (current == ancestor) {
153158
return true;
154159
}
155-
if (current instanceof ClassType ct) {
160+
if (current instanceof ClassType ct && !ct.isVirtualAbcSubclassingBuiltin()) {
156161
enqueueDirectSuperclasses(ct, queue);
157162
}
158163
}

python-checks/src/test/resources/checks/multipleInheritanceMROConflict.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,98 @@ class CompliantTwoUnresolvedPeers(ExternalMixin, ExternalOther):
6868
class ConflictMid(Base, MidWithUnresolvedMixin): # Noncompliant {{Reorder or remove base classes to fix this MRO conflict.}}
6969
# ^^^^^^^^^^^ ^^^^< {{This base class is an ancestor of another listed base class appearing after it.}}
7070
pass
71+
72+
73+
# --- typeshed lies about built-in containers inheriting from collections.abc ABCs ---
74+
# At runtime these built-ins only inherit from `object` and are merely virtual-subclass-registered
75+
# against the corresponding ABCs (via abc.ABCMeta.register). Python's MRO computation succeeds, so
76+
# the rule must NOT flag these.
77+
from collections.abc import (
78+
MutableMapping,
79+
Mapping,
80+
MutableSequence,
81+
Sequence,
82+
MutableSet,
83+
Set as AbstractSet,
84+
)
85+
86+
87+
class CompliantDictMixin(MutableMapping, dict):
88+
pass
89+
90+
91+
class CompliantMappingDict(Mapping, dict):
92+
pass
93+
94+
95+
class CompliantListMixin(MutableSequence, list):
96+
pass
97+
98+
99+
class CompliantTupleMixin(Sequence, tuple):
100+
pass
101+
102+
103+
class CompliantSetMixin(MutableSet, set):
104+
pass
105+
106+
107+
class CompliantFrozenSetMixin(AbstractSet, frozenset):
108+
pass
109+
110+
111+
class CompliantBytearrayMixin(MutableSequence, bytearray):
112+
pass
113+
114+
115+
# Smoke tests for str/bytes — typeshed lists extra ABCs (Sequence, Hashable, …) that don't appear
116+
# in their runtime MRO.
117+
class CompliantStrMixin(Sequence, str):
118+
pass
119+
120+
121+
class CompliantBytesMixin(Sequence, bytes):
122+
pass
123+
124+
125+
# collections.deque has the same typeshed-vs-runtime mismatch as the builtin containers:
126+
# typeshed declares `class deque(MutableSequence[_T])` but at runtime deque.__bases__ == (object,),
127+
# with MutableSequence registered as a virtual subclass via abc.ABCMeta.register().
128+
from collections import deque
129+
130+
131+
class CompliantDequeMixin(MutableSequence, deque):
132+
pass
133+
134+
135+
# Real conflict that involves a built-in container must still be flagged: dict before its own
136+
# user subclass is a genuine "earlier-is-ancestor-of-later" situation that Python rejects.
137+
class _UserDictSubclass(dict):
138+
pass
139+
140+
141+
class StillBrokenWithUserDict(dict, _UserDictSubclass): # Noncompliant {{Reorder or remove base classes to fix this MRO conflict.}}
142+
# ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^< {{This base class is an ancestor of another listed base class appearing after it.}}
143+
pass
144+
145+
146+
# The reverse order is valid Python (dict comes after its subclass) and must not be flagged.
147+
class CompliantUserDictThenDict(_UserDictSubclass, dict):
148+
pass
149+
150+
151+
# Compliant: ABC paired with a user-defined dict subclass. typeshed (falsely) lists MutableMapping
152+
# as ancestor of dict; the rule must not infer the same ancestry through `_UserDictSubclass`.
153+
# Fully-resolved bases — exercises the C3 path (wouldHaveValidMro).
154+
class CompliantAbcThenUserDict(MutableMapping, _UserDictSubclass):
155+
pass
156+
157+
158+
# Same scenario but with an extra unresolved external mixin to force the heuristic path
159+
# (findAncestorConflictIndex / isOrExtendsClassAtRuntime).
160+
class _UserDictWithUnresolvedMixin(dict, ExternalMixin):
161+
pass
162+
163+
164+
class CompliantAbcThenUserDictUnresolved(MutableMapping, _UserDictWithUnresolvedMixin):
165+
pass

python-frontend/src/main/java/org/sonar/plugins/python/api/types/v2/ClassType.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,12 @@ public Optional<List<ClassType>> mro() {
206206
}
207207

208208
/**
209-
* Returns whether C3 linearization would succeed for a hypothetical class whose direct bases are
210-
* exactly {@code bases} in order.
209+
* Returns whether C3 linearization would succeed at runtime for a hypothetical class whose direct
210+
* bases are exactly {@code bases} in order. Uses Python's runtime view of built-in containers:
211+
* typeshed-only inheritance edges from {@code dict}/{@code list}/{@code set}/… to their
212+
* {@code collections.abc} / {@code typing} ABCs (which at runtime are
213+
* {@code abc.ABCMeta.register()} virtual subclasses, not real bases) are ignored. This way, valid
214+
* Python code such as {@code class N(MutableMapping, dict): pass} is not flagged.
211215
*
212216
* <p>Precondition: every element of {@code bases} is non-null and fully resolved (no unresolved
213217
* hierarchy), matching what {@link #mro()} requires for each base.
@@ -219,6 +223,15 @@ public static boolean wouldHaveValidMro(List<ClassType> bases) {
219223
return ClassTypeMroUtils.wouldHaveValidMro(bases);
220224
}
221225

226+
/**
227+
* Returns {@code true} for built-in containers whose typeshed declaration includes fictional
228+
* ABC inheritance that does not exist at runtime (e.g. {@code dict}, {@code list}, {@code set}).
229+
* See {@link #wouldHaveValidMro(List)}.
230+
*/
231+
public boolean isVirtualAbcSubclassingBuiltin() {
232+
return ClassTypeMroUtils.isVirtualAbcSubclassingBuiltin(this);
233+
}
234+
222235
@Override
223236
public TriBool hasMember(String memberName) {
224237
// a ClassType is an object of class type, it has the same members as those present on any type

python-frontend/src/main/java/org/sonar/plugins/python/api/types/v2/ClassTypeMroUtils.java

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,36 @@
2727

2828
/**
2929
* C3 linearization (Method Resolution Order) for {@link ClassType}.
30+
*
31+
* <p>Built-in containers listed in {@link #VIRTUAL_ABC_SUBCLASSING_BUILTIN_FQNS} have typeshed
32+
* declarations that lie about their bases (e.g. {@code class dict(MutableMapping, Generic)}),
33+
* but at runtime they only inherit from {@code object} and the ABC relationship is a
34+
* virtual-subclass registration via {@code abc.ABCMeta.register()}. {@link #compute(ClassType)}
35+
* preserves the typeshed view (used by {@link ClassType#mro()}), while
36+
* {@link #wouldHaveValidMro(List)} honours the runtime view (used to detect real MRO conflicts).
3037
*/
3138
final class ClassTypeMroUtils {
3239

40+
private static final Set<String> VIRTUAL_ABC_SUBCLASSING_BUILTIN_FQNS = Set.of(
41+
"dict", "list", "tuple", "set", "frozenset", "str", "bytes", "bytearray", "collections.deque"
42+
);
43+
3344
private ClassTypeMroUtils() {
3445
}
3546

36-
/**
37-
* Computes the C3 MRO for {@code cls}, using a fresh memoisation cache.
38-
*/
3947
static Optional<List<ClassType>> compute(ClassType cls) {
40-
return compute(cls, new HashMap<>(), new HashSet<>());
48+
return compute(cls, new HashMap<>(), new HashSet<>(), false);
4149
}
4250

4351
/**
44-
* Returns whether C3 linearization would succeed for a hypothetical class whose direct bases are
45-
* exactly {@code bases} in order — same behaviour as {@link ClassType#wouldHaveValidMro(List)}.
52+
* Returns whether C3 would succeed at runtime for a hypothetical class whose direct bases are
53+
* exactly {@code bases} in order. Backs {@link ClassType#wouldHaveValidMro(List)}.
4654
*/
4755
static boolean wouldHaveValidMro(List<ClassType> bases) {
56+
Map<ClassType, Optional<List<ClassType>>> cache = new HashMap<>();
4857
List<List<ClassType>> lists = new ArrayList<>();
4958
for (ClassType base : bases) {
50-
Optional<List<ClassType>> mro = base.mro();
59+
Optional<List<ClassType>> mro = compute(base, cache, new HashSet<>(), true);
5160
if (mro.isEmpty()) {
5261
return true;
5362
}
@@ -57,18 +66,34 @@ static boolean wouldHaveValidMro(List<ClassType> bases) {
5766
return c3Merge(lists) != null;
5867
}
5968

69+
static boolean isVirtualAbcSubclassingBuiltin(ClassType cls) {
70+
String fqn = cls.fullyQualifiedName();
71+
return fqn != null && VIRTUAL_ABC_SUBCLASSING_BUILTIN_FQNS.contains(fqn);
72+
}
73+
6074
/**
61-
* Recursively computes the C3 MRO for {@code cls}, memoising results in {@code cache}.
62-
* {@code visiting} guards against cycles.
75+
* Recursively computes the C3 MRO for {@code cls}. {@code cache} memoises results across the
76+
* traversal (callers may pass a fresh map); {@code visiting} guards against inheritance cycles.
6377
*/
6478
private static Optional<List<ClassType>> compute(
6579
ClassType cls,
6680
Map<ClassType, Optional<List<ClassType>>> cache,
67-
Set<ClassType> visiting
81+
Set<ClassType> visiting,
82+
boolean stripVirtualAbcInheritance
6883
) {
6984
if (cache.containsKey(cls)) {
7085
return cache.get(cls);
7186
}
87+
if (stripVirtualAbcInheritance && isVirtualAbcSubclassingBuiltin(cls)) {
88+
// Runtime view: ignore the typeshed-declared parents but still keep {@code object} as the tail,
89+
// so C3 rejects orderings like {@code class X(object, dict)} (object must come after dict).
90+
// {@code object} is, by construction, the last element of {@code cls}'s non-stripped MRO.
91+
Optional<List<ClassType>> fullMro = compute(cls, new HashMap<>(), new HashSet<>(), false);
92+
List<ClassType> mro = fullMro.map(m -> List.of(cls, m.get(m.size() - 1))).orElseGet(() -> List.of(cls));
93+
Optional<List<ClassType>> result = Optional.of(mro);
94+
cache.put(cls, result);
95+
return result;
96+
}
7297
if (!visiting.add(cls)) {
7398
// Cycle detected — treat as failure.
7499
return Optional.empty();
@@ -84,10 +109,9 @@ private static Optional<List<ClassType>> compute(
84109

85110
List<List<ClassType>> lists = new ArrayList<>();
86111
for (ClassType parent : parentTypes) {
87-
Optional<List<ClassType>> parentMro = compute(parent, cache, visiting);
112+
Optional<List<ClassType>> parentMro = compute(parent, cache, visiting, stripVirtualAbcInheritance);
88113
if (parentMro.isEmpty()) {
89-
// A parent itself has an invalid MRO — Python would have raised TypeError
90-
// when that parent was defined, so this class is not the root cause.
114+
// A parent's MRO is invalid — Python would have raised TypeError there, not here.
91115
visiting.remove(cls);
92116
cache.put(cls, Optional.empty());
93117
return Optional.empty();
@@ -110,10 +134,7 @@ private static Optional<List<ClassType>> compute(
110134
return result;
111135
}
112136

113-
/**
114-
* Performs the C3 merge of the given lists. Returns the merged sequence, or {@code null} if no
115-
* valid merge exists (i.e. the MRO has a conflict).
116-
*/
137+
/** Returns the C3 merge of {@code lists}, or {@code null} if no valid merge exists. */
117138
@CheckForNull
118139
private static List<ClassType> c3Merge(List<List<ClassType>> lists) {
119140
List<ClassType> result = new ArrayList<>();
@@ -131,10 +152,7 @@ private static List<ClassType> c3Merge(List<List<ClassType>> lists) {
131152
}
132153
}
133154

134-
/**
135-
* Returns the first list head that does not appear as a non-head entry in any list, or {@code null}
136-
* if no such head exists (merge failure).
137-
*/
155+
/** Returns the first list head that doesn't appear as a non-head in any list, or {@code null}. */
138156
@CheckForNull
139157
private static ClassType findFirstValidMergeHead(List<List<ClassType>> lists) {
140158
for (List<ClassType> list : lists) {

python-frontend/src/test/java/org/sonar/plugins/python/api/types/v2/ClassTypeTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,67 @@ void wouldHaveValidMro_returns_true_for_diamond_inheritance_bases() {
967967
assertThat(ClassType.wouldHaveValidMro(List.of(b, c))).isTrue();
968968
}
969969

970+
@Test
971+
void wouldHaveValidMro_ignores_typeshed_virtual_abc_inheritance_for_dict() {
972+
// typeshed declares `class dict(MutableMapping[_KT, _VT], Generic[_KT, _VT])`, but at runtime
973+
// dict only inherits from `object` and the MutableMapping relationship is a virtual-subclass
974+
// registration via `abc.ABCMeta.register()`. C3 succeeds at runtime for class N(MutableMapping, dict).
975+
List<ClassType> types = classTypes(
976+
"from collections.abc import MutableMapping",
977+
"class Holder(MutableMapping, dict): ..."
978+
);
979+
ClassType holder = types.get(0);
980+
assertThat(holder.superClasses()).hasSize(2);
981+
var bases = holder.superClasses().stream()
982+
.map(sw -> (ClassType) sw.type())
983+
.toList();
984+
assertThat(ClassType.wouldHaveValidMro(bases)).isTrue();
985+
}
986+
987+
@Test
988+
void wouldHaveValidMro_handles_user_subclass_of_builtin_container() {
989+
// class MyDict(dict): ...; class X(MutableMapping, MyDict): ... is valid at runtime because
990+
// MyDict's runtime MRO is [MyDict, dict, object] (no MutableMapping) — the typeshed link from
991+
// dict to MutableMapping is fictional.
992+
List<ClassType> types = classTypes(
993+
"from collections.abc import MutableMapping",
994+
"class MyDict(dict): ...",
995+
"class X(MutableMapping, MyDict): ..."
996+
);
997+
ClassType x = types.get(1);
998+
var bases = x.superClasses().stream()
999+
.map(sw -> (ClassType) sw.type())
1000+
.toList();
1001+
assertThat(ClassType.wouldHaveValidMro(bases)).isTrue();
1002+
}
1003+
1004+
@Test
1005+
void wouldHaveValidMro_still_detects_real_conflict_with_builtin_container() {
1006+
// class MyDict(dict): ...; class X(dict, MyDict): ... is rejected at runtime because dict
1007+
// appears before its own subclass.
1008+
List<ClassType> types = classTypes(
1009+
"class MyDict(dict): ...",
1010+
"class X(dict, MyDict): ..."
1011+
);
1012+
ClassType x = types.get(1);
1013+
var bases = x.superClasses().stream()
1014+
.map(sw -> (ClassType) sw.type())
1015+
.toList();
1016+
assertThat(ClassType.wouldHaveValidMro(bases)).isFalse();
1017+
}
1018+
1019+
@Test
1020+
void wouldHaveValidMro_rejects_object_before_builtin_container() {
1021+
// class X(object, dict): ... is rejected at runtime ("Cannot create a consistent MRO …") because
1022+
// dict.__mro__ ends with object, so object must come after dict. The runtime-view MRO must keep
1023+
// the object tail to catch this — otherwise stripping dict's typeshed parents to just [dict]
1024+
// would let the merge succeed (false negative).
1025+
var objectType = (ClassType) PROJECT_LEVEL_TYPE_TABLE.getType("object");
1026+
var dictType = (ClassType) PROJECT_LEVEL_TYPE_TABLE.getType("dict");
1027+
assertThat(ClassType.wouldHaveValidMro(List.of(objectType, dictType))).isFalse();
1028+
assertThat(ClassType.wouldHaveValidMro(List.of(dictType, objectType))).isTrue();
1029+
}
1030+
9701031
@Test
9711032
void mro_unresolved_hierarchy_returns_empty() {
9721033
// class Child(Unknown): ... <- unresolved parent

0 commit comments

Comments
 (0)