From 0632f905f6953fe1c32bb503c1dd2eb720246327 Mon Sep 17 00:00:00 2001 From: Nikita Paniukhin Date: Wed, 12 Aug 2026 19:28:52 +0200 Subject: [PATCH] PY-90614 python: report `Unpack[TypedDict]` kwargs mismatch once per call A call's `**kwargs` argument was checked separately against each overload candidate, so the same warning was reported multiple times. Also see through parentheses around a `**`-unpacked dict literal (`**({...})`) when checking it against a `TypedDict`. GitOrigin-RevId: 1df029e928a8a20c854e2e4c11ce0301a62996b4 --- .../src/com/intellij/psi/util/psiTreeUtil.kt | 4 ++ .../inspections/PyTypeCheckerInspection.kt | 67 +++++++++++-------- .../python/types/PyTypedDictTypeTest.kt | 54 +++++++++++++++ 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/platform/core-api/src/com/intellij/psi/util/psiTreeUtil.kt b/platform/core-api/src/com/intellij/psi/util/psiTreeUtil.kt index a5cd6ab0450c..7013d6786625 100644 --- a/platform/core-api/src/com/intellij/psi/util/psiTreeUtil.kt +++ b/platform/core-api/src/com/intellij/psi/util/psiTreeUtil.kt @@ -78,6 +78,10 @@ inline fun PsiElement.parentOfType(withSelf: Boolean = return PsiTreeUtil.getParentOfType(this, T::class.java, !withSelf) } +inline fun PsiElement.childOfType(withSelf: Boolean = false): T? { + return PsiTreeUtil.findChildOfType(this, T::class.java, !withSelf) +} + @Deprecated("Use parentOfTypes()", ReplaceWith("parentOfTypes(*classes)")) fun PsiElement.parentOfType(vararg classes: KClass): T? { return parentOfTypes(*classes) diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyTypeCheckerInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyTypeCheckerInspection.kt index 7f07b70b394e..3f75edab8c2f 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyTypeCheckerInspection.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyTypeCheckerInspection.kt @@ -11,6 +11,7 @@ import com.intellij.openapi.util.Ref import com.intellij.psi.PsiElement import com.intellij.psi.PsiElementVisitor import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.childOfType import com.jetbrains.python.PyNames import com.jetbrains.python.PyPsiBundle import com.jetbrains.python.ast.PyAstFunction @@ -125,8 +126,6 @@ import com.jetbrains.python.psi.types.isNoneType import com.jetbrains.python.psi.types.isObject import com.jetbrains.python.pyi.PyiUtil.isOverload import org.jetbrains.annotations.PropertyKey -import java.util.Objects -import java.util.function.Supplier import kotlin.math.min open class PyTypeCheckerInspection : PyInspection() { @@ -146,6 +145,8 @@ open class PyTypeCheckerInspection : PyInspection() { open class Visitor(holder: ProblemsHolder, context: TypeEvalContext) : PyInspectionVisitor(holder, context) { override val holder = super.holder!! + private val typedDictProblemReporter = TypedDictProblemReporter() + // TODO: Visit decorators with arguments override fun visitPyCallExpression(node: PyCallExpression) { checkCallSite(node) @@ -707,9 +708,9 @@ open class PyTypeCheckerInspection : PyInspection() { private fun reportTypedDictProblems(expectedType: PyTypedDictType, expression: PyExpression) { val result = TypeCheckingResult() checkExpression(expectedType, expression, myTypeEvalContext, result) - result.valueTypeErrors.forEach { error: ValueTypeError? -> - val actualExpression = error!!.actualExpression ?: return@forEach - PyTypeCheckerProblemReporter.report( + result.valueTypeErrors.forEach { error -> + val actualExpression = error.actualExpression ?: return@forEach + typedDictProblemReporter.report( holder, PyTypeCheckerSuppressionCode.BAD_TYPED_DICT, actualExpression, @@ -717,19 +718,19 @@ open class PyTypeCheckerInspection : PyInspection() { effectiveHighlightType(ProblemHighlightType.GENERIC_ERROR_OR_WARNING) ) } - result.extraKeys.forEach { error: ExtraKeyError? -> - PyTypeCheckerProblemReporter.report( + result.extraKeys.forEach { error -> + typedDictProblemReporter.report( holder, PyTypeCheckerSuppressionCode.BAD_TYPED_DICT_KEY, - Objects.requireNonNullElse(error!!.actualExpression, expression), + error.actualExpression ?: expression, PyPsiBundle.problemMessage("INSP.type.checker.typed.dict.extra.key", error.key, error.expectedTypedDictName) ) } - result.missingKeys.forEach { error: MissingKeysError? -> - PyTypeCheckerProblemReporter.report( + result.missingKeys.forEach { error -> + typedDictProblemReporter.report( holder, PyTypeCheckerSuppressionCode.BAD_TYPED_DICT, - if (error!!.actualExpression != null) error.actualExpression else expression, + error.actualExpression ?: expression, PyPsiBundle.problemMessage( "INSP.type.checker.typed.dict.missing.keys", error.expectedTypedDictName, error.missingKeys.size, @@ -743,11 +744,6 @@ open class PyTypeCheckerInspection : PyInspection() { expectedType: PyUnpackedTypedDictType, expression: PyExpression, ) { - var expression: PyExpression? = expression - if (expression is PyStarArgument) { - expression = PsiTreeUtil.findChildOfType(expression, PyExpression::class.java) - } - if (expression == null) return val argumentType = myTypeEvalContext.getType(expression) val typedDictType = expectedType.typedDictType if (isDictExpression(expression, myTypeEvalContext)) { @@ -755,7 +751,7 @@ open class PyTypeCheckerInspection : PyInspection() { return } if (!match(typedDictType, argumentType, myTypeEvalContext)) { - PyTypeCheckerProblemReporter.report( + typedDictProblemReporter.report( holder, PyTypeCheckerSuppressionCode.BAD_ARGUMENT_TYPE, expression, @@ -1425,23 +1421,24 @@ open class PyTypeCheckerInspection : PyInspection() { argument: PyExpression?, substitutions: GenericSubstitutions, ): Boolean { - var argument = argument - argument = peelArgument(argument) + val peeledArgument = peelArgument(argument) + val expression = when (argument) { + is PyStarArgument -> peelArgument(argument.childOfType()) + else -> peeledArgument + } - if (argument != null) { - if (isDictExpression(argument, myTypeEvalContext) && - parameterType is PyTypedDictType - ) { - reportTypedDictProblems(parameterType, argument) + if (expression != null) { + if (isDictExpression(expression, myTypeEvalContext) && parameterType is PyTypedDictType) { + reportTypedDictProblems(parameterType, expression) return true } - else if (parameterType is PyUnpackedTypedDictType) { - reportUnpackedTypedDictProblems(parameterType, argument) + if (parameterType is PyUnpackedTypedDictType) { + reportUnpackedTypedDictProblems(parameterType, expression) return true } } - return matchesExpectedType(parameterType, argumentType, argument, substitutions) + return matchesExpectedType(parameterType, argumentType, peeledArgument, substitutions) && !matchingProtocolDefinitions(parameterType, argumentType, myTypeEvalContext) } @@ -1516,6 +1513,22 @@ open class PyTypeCheckerInspection : PyInspection() { } } + internal class TypedDictProblemReporter { + private val reportedKeys = mutableSetOf>() + + fun report( + holder: ProblemsHolder, + code: PyTypeCheckerSuppressionCode, + element: PsiElement, + message: PyInspectionMessages.ProblemMessage, + type: ProblemHighlightType = ProblemHighlightType.GENERIC_ERROR_OR_WARNING, + ) { + if (reportedKeys.add(element to message.description)) { + PyTypeCheckerProblemReporter.report(holder, code, element, message, type) + } + } + } + internal class AnalyzeCalleeResults( val callableType: PyCallableType, val callable: PyCallable?, diff --git a/python/testSrc/com/jetbrains/python/types/PyTypedDictTypeTest.kt b/python/testSrc/com/jetbrains/python/types/PyTypedDictTypeTest.kt index 1fe0e21d097f..f386565b940a 100644 --- a/python/testSrc/com/jetbrains/python/types/PyTypedDictTypeTest.kt +++ b/python/testSrc/com/jetbrains/python/types/PyTypedDictTypeTest.kt @@ -914,6 +914,60 @@ class PyTypedDictTypeTest : PyCodeInsightTestCase() { foo(1, "hello", name=42) # ^^^^^^^ WARNING Expected type 'str', got 'Literal[42]' instead """) + + @Test + @TestFor(issues = ["PY-90614"]) + fun `dict variable mismatch with Unpack TypedDict kwargs warned once across overloads`() = test(""" + from typing import TypedDict, Unpack, overload + + class EmptyKwargs(TypedDict): + pass + + @overload + def foo(*, a: int, **kwargs: Unpack[EmptyKwargs]) -> None: ... + @overload + def foo(*, b: str, **kwargs: Unpack[EmptyKwargs]) -> None: ... + def foo(**kwargs) -> None: ... + + dict_var = {} + foo(**dict_var) + # ^^^^^^^^ WARNING Expected type 'EmptyKwargs', got 'dict[Unknown, Unknown]' instead + """) + + @Test + @TestFor(issues = ["PY-90614"]) + fun `unpacked parenthesized dict literal keys are checked`() = test(""" + from typing import TypedDict, Unpack + + class FieldKwargs(TypedDict): + description: str + + def foo(**kwargs: Unpack[FieldKwargs]) -> None: ... + + foo(**((({"description": "foo"})))) + foo(**((({"description": "foo", "unknown": "foo"})))) + # ^^^^^^^^^^^^^^^^ WARNING Extra key 'unknown' for TypedDict 'FieldKwargs' + """) + + @Test + @TestFor(issues = ["PY-91511"]) + fun `unpacked dict literal keys checked against the overload with matching parameter`() = test(""" + from typing import TypedDict, Unpack, overload + + class EmptyKwargs(TypedDict): + pass + + @overload + def foo(*, description: str) -> None: ... + @overload + def foo(**kwargs: Unpack[EmptyKwargs]) -> None: ... + def foo(**kwargs) -> None: ... + + foo(**{"unknown": "foo"}) + # ^^^^^^^^^^^^^^^^ WARNING Extra key 'unknown' for TypedDict 'EmptyKwargs' + foo(**{"description": "foo"}) + # ^^^^^^^^^^^^^^^^^^^^ WARNING Extra key 'description' for TypedDict 'EmptyKwargs' FIXME + """) } @Test