From ba9380d01dd79bb55dd42b50f8bdd855ce355a2f Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 1 Feb 2026 13:21:32 +0530 Subject: [PATCH 1/5] Fix: ensure unpacked **kwargs have string-compatible keys (#20706) --- mypy/checkexpr.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 8288b676b52e..8329760f6dc3 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5432,6 +5432,13 @@ def visit_dict_expr(self, e: DictExpr) -> Type: expected_types: list[Type] = [] for key, value in e.items: if key is None: + # This is a **expr unpacking. Check that the expression has string keys. + value_type = get_proper_type(self.accept(value)) + if not self.is_valid_keyword_var_arg(value_type): + is_mapping = is_subtype( + value_type, self.chk.named_type("_typeshed.SupportsKeysAndGetItem") + ) + self.msg.invalid_keyword_var_arg(value_type, is_mapping, value) args.append(value) expected_types.append( self.chk.named_generic_type("_typeshed.SupportsKeysAndGetItem", [kt, vt]) From 12db980393ad1d5d625e65c8d2186613dd5da3b2 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 1 Feb 2026 17:50:31 +0530 Subject: [PATCH 2/5] Refine: allow Any/Unbound types in dict unpacking to avoid false positives --- mypy/checkexpr.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 8329760f6dc3..0b7cf636399c 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5434,7 +5434,10 @@ def visit_dict_expr(self, e: DictExpr) -> Type: if key is None: # This is a **expr unpacking. Check that the expression has string keys. value_type = get_proper_type(self.accept(value)) - if not self.is_valid_keyword_var_arg(value_type): + # NEW: Allow Any or Unbound types to reduce false positives in external libraries + if isinstance(value_type, (AnyType, UnboundType)): + pass + elif not self.is_valid_keyword_var_arg(value_type): is_mapping = is_subtype( value_type, self.chk.named_type("_typeshed.SupportsKeysAndGetItem") ) From 05366f5b891ab52b54d88f35b23135929941d533 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:22:36 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checkexpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 0b7cf636399c..d8a861b8d59e 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5434,7 +5434,7 @@ def visit_dict_expr(self, e: DictExpr) -> Type: if key is None: # This is a **expr unpacking. Check that the expression has string keys. value_type = get_proper_type(self.accept(value)) - # NEW: Allow Any or Unbound types to reduce false positives in external libraries + # NEW: Allow Any or Unbound types to reduce false positives in external libraries if isinstance(value_type, (AnyType, UnboundType)): pass elif not self.is_valid_keyword_var_arg(value_type): From 55cbe2ec93f5e03d85a03f0be6a12769e5fb0257 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 2 Feb 2026 10:03:45 +0530 Subject: [PATCH 4/5] Refactor: Add from_dict_call flag to DictExpr to ensure keyword validation for dict() calls --- mypy/checkexpr.py | 21 +++++++++++---------- mypy/nodes.py | 11 +++++++++-- mypy/semanal.py | 3 ++- test-data/unit/check-expressions.test | 6 ++++++ 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index d8a861b8d59e..b0c445851ab8 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5432,16 +5432,17 @@ def visit_dict_expr(self, e: DictExpr) -> Type: expected_types: list[Type] = [] for key, value in e.items: if key is None: - # This is a **expr unpacking. Check that the expression has string keys. - value_type = get_proper_type(self.accept(value)) - # NEW: Allow Any or Unbound types to reduce false positives in external libraries - if isinstance(value_type, (AnyType, UnboundType)): - pass - elif not self.is_valid_keyword_var_arg(value_type): - is_mapping = is_subtype( - value_type, self.chk.named_type("_typeshed.SupportsKeysAndGetItem") - ) - self.msg.invalid_keyword_var_arg(value_type, is_mapping, value) + # This is a **expr unpacking. + # If this DictExpr came from a dict() call, we need to check that + # the expression has string keys (since dict() uses keyword args). + # For plain dict literals like {**mapping}, non-string keys are valid. + if e.from_dict_call: + value_type = get_proper_type(self.accept(value)) + if not self.is_valid_keyword_var_arg(value_type): + is_mapping = is_subtype( + value_type, self.chk.named_type("_typeshed.SupportsKeysAndGetItem") + ) + self.msg.invalid_keyword_var_arg(value_type, is_mapping, value) args.append(value) expected_types.append( self.chk.named_generic_type("_typeshed.SupportsKeysAndGetItem", [kt, vt]) diff --git a/mypy/nodes.py b/mypy/nodes.py index 4168b2e00f15..e901fcac52c8 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2672,15 +2672,22 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: class DictExpr(Expression): """Dictionary literal expression {key: value, ...}.""" - __slots__ = ("items",) + __slots__ = ("items", "from_dict_call") __match_args__ = ("items",) items: list[tuple[Expression | None, Expression]] + # True if this DictExpr was created from a dict() call (e.g., dict(a=1, **x)) + from_dict_call: bool - def __init__(self, items: list[tuple[Expression | None, Expression]]) -> None: + def __init__( + self, + items: list[tuple[Expression | None, Expression]], + from_dict_call: bool = False, + ) -> None: super().__init__() self.items = items + self.from_dict_call = from_dict_call def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_dict_expr(self) diff --git a/mypy/semanal.py b/mypy/semanal.py index f38a71cb16e3..8859bb9aa679 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6055,7 +6055,8 @@ def translate_dict_call(self, call: CallExpr) -> DictExpr | None: [ (StrExpr(key) if key is not None else None, value) for key, value in zip(call.arg_names, call.args) - ] + ], + from_dict_call=True, ) expr.set_line(call) expr.accept(self) diff --git a/test-data/unit/check-expressions.test b/test-data/unit/check-expressions.test index 1acda7079cc8..962c51486644 100644 --- a/test-data/unit/check-expressions.test +++ b/test-data/unit/check-expressions.test @@ -2582,3 +2582,9 @@ def last_known_value() -> None: x, y, z = xy # E: Unpacking a string is disallowed reveal_type(z) # N: Revealed type is "builtins.str" [builtins fixtures/primitives.pyi] + + +[case testDictUnpackNonStringKey] +def f() -> None: + dict(**{10: 20}) # E: Argument after ** must have string keys +[builtins fixtures/dict.pyi] \ No newline at end of file From 660c862e9f7db391e79aa77453ad8b28a83191c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 04:36:05 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/nodes.py | 4 +--- test-data/unit/check-expressions.test | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index e901fcac52c8..99458501aa1c 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2681,9 +2681,7 @@ class DictExpr(Expression): from_dict_call: bool def __init__( - self, - items: list[tuple[Expression | None, Expression]], - from_dict_call: bool = False, + self, items: list[tuple[Expression | None, Expression]], from_dict_call: bool = False ) -> None: super().__init__() self.items = items diff --git a/test-data/unit/check-expressions.test b/test-data/unit/check-expressions.test index 962c51486644..4df54cef168c 100644 --- a/test-data/unit/check-expressions.test +++ b/test-data/unit/check-expressions.test @@ -2587,4 +2587,4 @@ def last_known_value() -> None: [case testDictUnpackNonStringKey] def f() -> None: dict(**{10: 20}) # E: Argument after ** must have string keys -[builtins fixtures/dict.pyi] \ No newline at end of file +[builtins fixtures/dict.pyi]