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 cast(int, "a") + cast(list[int], ["a"]) cast(int, object()) # ok cast(object, 1) # ok # variance of generic type arguments is ignored by default - cast(list[int], ["a"]) # ok lint = [1, 2, 3] cast(list[object], lint) # ok """.trimIndent() @@ -56,20 +58,25 @@ class PyInvalidCastInspectionTest : PyInspectionTestCase() { ) } - /** - * test that the variance of invariant generics is ignored by default - */ - fun `test generic variance ignored by default`() { - doTestByText( + @TestFor(issues = ["PY-83156"]) + fun `test generic variance check enabled`() { + Registry.get("python.subtypechecks.respect.variance").setValue(true, testRootDisposable) + + myFixture.configureByText( + PythonFileType.INSTANCE, """ from typing import cast, Sequence lint = [1, 2, 3] - cast(list[object], lint) # ok + cast(list[object], lint) cast(Sequence[object], lint) # ok """.trimIndent() ) + val inspection = PyInvalidCastInspection() + inspection.ignoreGenericVariance = false + myFixture.enableInspections(inspection) + myFixture.checkHighlighting(isWarning, isInfo, isWeakWarning) } /** diff --git a/python/testSrc/com/jetbrains/python/types/PyOverloadTypeTest.kt b/python/testSrc/com/jetbrains/python/types/PyOverloadTypeTest.kt index ce1ab54ff0c2..a7e5e41f710b 100644 --- a/python/testSrc/com/jetbrains/python/types/PyOverloadTypeTest.kt +++ b/python/testSrc/com/jetbrains/python/types/PyOverloadTypeTest.kt @@ -2,11 +2,9 @@ package com.jetbrains.python.types import com.intellij.idea.TestFor -import com.intellij.testFramework.TestLoggerFactory import com.intellij.testFramework.TestLoggerFactory.TestLoggerAssertionError import com.jetbrains.python.fixtures.PyCodeInsightTestCase import com.jetbrains.python.psi.LanguageLevel -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows