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
This commit is contained in:
Nikita Paniukhin
2026-08-18 17:55:48 +00:00
committed by intellij-monorepo-bot
parent 464c62d5a6
commit 0632f905f6
3 changed files with 98 additions and 27 deletions
@@ -78,6 +78,10 @@ inline fun <reified T : PsiElement> PsiElement.parentOfType(withSelf: Boolean =
return PsiTreeUtil.getParentOfType(this, T::class.java, !withSelf)
}
inline fun <reified T : PsiElement> PsiElement.childOfType(withSelf: Boolean = false): T? {
return PsiTreeUtil.findChildOfType(this, T::class.java, !withSelf)
}
@Deprecated("Use parentOfTypes()", ReplaceWith("parentOfTypes(*classes)"))
fun <T : PsiElement> PsiElement.parentOfType(vararg classes: KClass<out T>): T? {
return parentOfTypes(*classes)
@@ -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<PyExpression?>(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<PyExpression>())
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<Pair<PsiElement, String>>()
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?,
@@ -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