diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidCastInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidCastInspection.html index 8f45603dcd10..9213f119196e 100644 --- a/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidCastInspection.html +++ b/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidCastInspection.html @@ -33,9 +33,10 @@ cast(list[object], int_list) # ok
The inspection relies on static type information; when a type is unknown, no warning is reported.
-Use the Ignore variance of generic type arguments option to control whether generic type arguments are taken
- into account. When enabled (the default), parameterized types are compared by their base class only, so a cast such as
- cast(list[object], int_list) is allowed. Disable it to also report casts that differ only in their
+
Use the Ignore variance of generic type arguments option to control whether the variance of generic type
+ arguments is taken into account. When enabled (the default), the variance is ignored: as long as the corresponding
+ type arguments are in the same inheritance hierarchy, the cast is allowed, so cast(list[object], int_list)
+ is not reported even though list is invariant. Disable it to also report casts that differ only in their
(invariant) type arguments.
Use the Ignore TypedDict structure (treat a TypedDict as a dict) option to control whether a TypedDict
diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties
index 8d88121beb6b..9b75c337e8c3 100644
--- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties
+++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties
@@ -1506,7 +1506,8 @@ packaging.could.not.parse.relation=Could not parse relation from: {0}
# PyInvalidCastInspection
INSP.NAME.invalid.cast=Type cast between unrelated types
-INSP.invalid.cast.message=Cast of type ''{0}'' to type ''{1}'' may be a mistake because they are not in the same inheritance hierarchy. If this was intentional, cast the expression to ''{2}'' first.
+INSP.invalid.cast.message=Cast of type `{0}` to type `{1}` may be a mistake because they are not in the same inheritance hierarchy. If this was intentional, cast the expression to `{2}` first.
+INSP.invalid.cast.variance.message=Cast of type `{0}` to type `{1}` may be a mistake because they differ in invariant type arguments
INSP.invalid.cast.ignore.generic.variance=Ignore variance of generic type arguments
INSP.invalid.cast.ignore.typed.dict.structure=Ignore TypedDict structure (treat a TypedDict as a dict)
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidCastInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidCastInspection.kt
index b1ac9ac45236..85a5d9d66f35 100644
--- a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidCastInspection.kt
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidCastInspection.kt
@@ -1,6 +1,7 @@
package com.jetbrains.python.inspections
import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.codeInspection.options.OptPane
import com.intellij.modcommand.ModPsiUpdater
@@ -19,7 +20,6 @@ import com.jetbrains.python.psi.PyFunction
import com.jetbrains.python.psi.types.PyClassLikeType
import com.jetbrains.python.psi.types.PyClassType
import com.jetbrains.python.psi.types.PyClassTypeImpl
-import com.jetbrains.python.psi.types.PyCollectionType
import com.jetbrains.python.psi.types.PyType
import com.jetbrains.python.psi.types.PyTypeUtil.isOverlappingWith
import com.jetbrains.python.psi.types.PyTypedDictType
@@ -61,37 +61,35 @@ class PyInvalidCastInspection : PyInspection() {
if (targetType.isOverlappingWith(actualType, myTypeEvalContext)) return
- // Relax the overlap check according to the enabled options:
- // - ignoring generic variance erases generic type arguments, so e.g. casting 'list[int]' to 'list[object]'
- // is treated as overlapping;
- // - ignoring TypedDict structure treats a TypedDict as a plain 'dict', so e.g. a 'dict[str, object]' may be
- // cast to a TypedDict.
- if (ignoreGenericVariance || ignoreTypedDictStructure) {
- var relaxedTarget = targetType
- var relaxedActual = actualType
- if (ignoreGenericVariance) {
- relaxedTarget = relaxedTarget.eraseGenericParameters()
- relaxedActual = relaxedActual.eraseGenericParameters()
- }
- if (ignoreTypedDictStructure) {
- relaxedTarget = relaxedTarget.eraseTypedDictStructure()
- relaxedActual = relaxedActual.eraseTypedDictStructure()
- }
- if (relaxedTarget.isOverlappingWith(relaxedActual, myTypeEvalContext)) return
+ // Treat a TypedDict as a plain 'dict' when the corresponding option is enabled, so that e.g. a
+ // 'dict[str, object]' may be cast to a TypedDict and vice versa.
+ if (ignoreTypedDictStructure &&
+ targetType.eraseTypedDictStructure().isOverlappingWith(actualType.eraseTypedDictStructure(), myTypeEvalContext)) {
+ return
}
+
+ // The types become subtype-related once the variance of their generic arguments is ignored, so the mismatch
+ // is purely about variance (e.g. casting the invariant 'list[int]' to 'list[object]'). Such casts are only
+ // reported when the "ignore generic variance" option is disabled, and get a dedicated message.
+ val relatedIgnoringVariance = isOverlappingIgnoringVariance(targetType, actualType, myTypeEvalContext)
+ if (relatedIgnoringVariance && ignoreGenericVariance) return
+
val fromName = PythonDocumentationProvider.getTypeName(actualType, myTypeEvalContext)
val toName = PythonDocumentationProvider.getVerboseTypeName(targetType, myTypeEvalContext)
val suggestedName = computeSuggestedIntermediateTypeName(targetType, actualType, myTypeEvalContext)
+ val messageKey = if (relatedIgnoringVariance) "INSP.invalid.cast.variance.message" else "INSP.invalid.cast.message"
+
registerProblem(
callExpression,
- PyPsiBundle.message(
- "INSP.invalid.cast.message",
+ PyPsiBundle.problemMessage(
+ messageKey,
fromName,
toName,
suggestedName
),
+ ProblemHighlightType.GENERIC_ERROR_OR_WARNING,
AddIntermediateCastQuickFix(suggestedName)
)
}
@@ -118,14 +116,30 @@ private class AddIntermediateCastQuickFix(private val typeText: String) : PsiUpd
}
/**
- * Erases generic type arguments so that parameterized types are compared by their base class only,
- * making the overlap check insensitive to the variance of generic parameters
- * (e.g. `list[int]` becomes plain `list`). Union members are erased element-wise.
+ * Checks whether two types are overlapping while ignoring the variance of generic type arguments: two
+ * parameterized types of related base classes are considered related as long as their corresponding type arguments
+ * are themselves overlapping, regardless of the direction required by the declared variance
+ * (e.g. `list\[int]` and `list\[object]` are related, but `list\[str]` and `list\[int]` are not). This makes the check
+ * insensitive to whether a generic parameter is invariant, covariant, or contravariant. Union members are
+ * distributed over.
*/
-private fun PyType?.eraseGenericParameters(): PyType? = when (this) {
- is PyUnionType -> this.map { it.eraseGenericParameters() }
- is PyCollectionType -> PyClassTypeImpl(this.pyClass, this.isDefinition)
- else -> this
+private fun isOverlappingIgnoringVariance(t1: PyType?, t2: PyType?, context: TypeEvalContext): Boolean {
+ if (t1 is PyUnionType) return t1.members.any { isOverlappingIgnoringVariance(it, t2, context) }
+ if (t2 is PyUnionType) return t2.members.any { isOverlappingIgnoringVariance(t1, it, context) }
+
+ if (t1 is PyClassType && t2 is PyClassType && t1.isParameterized && t2.isParameterized) {
+ val base1 = PyClassTypeImpl(t1.pyClass, t1.isDefinition)
+ val base2 = PyClassTypeImpl(t2.pyClass, t2.isDefinition)
+ if (!base1.isOverlappingWith(base2, context)) return false
+
+ val args1 = t1.typeArguments
+ val args2 = t2.typeArguments
+ // Different arity means we can't align the arguments; the base-class relation is enough to consider them related.
+ if (args1.size != args2.size) return true
+ return args1.indices.all { isOverlappingIgnoringVariance(args1[it], args2[it], context) }
+ }
+
+ return t1.isOverlappingWith(t2, context)
}
/**
diff --git a/python/testSrc/com/jetbrains/python/inspections/PyInvalidCastInspectionTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyInvalidCastInspectionTest.kt
index 045a04b4f648..28cc9d7ce19f 100644
--- a/python/testSrc/com/jetbrains/python/inspections/PyInvalidCastInspectionTest.kt
+++ b/python/testSrc/com/jetbrains/python/inspections/PyInvalidCastInspectionTest.kt
@@ -1,6 +1,8 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.inspections
+import com.intellij.idea.TestFor
+import com.intellij.openapi.util.registry.Registry
import com.jetbrains.python.PyPsiBundle
import com.jetbrains.python.PythonFileType
import com.jetbrains.python.fixtures.PyInspectionTestCase
@@ -14,12 +16,12 @@ class PyInvalidCastInspectionTest : PyInspectionTestCase() {
from typing import cast