From ba9380d01dd79bb55dd42b50f8bdd855ce355a2f Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 1 Feb 2026 13:21:32 +0530 Subject: [PATCH 1/8] 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/8] 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/8] [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/8] 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 6e9f1e4ad5b9ed4f3eefcdff3eb9546bc830b946 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 2 Feb 2026 19:35:03 +0530 Subject: [PATCH 5/8] Refactor: utilize standard CallExpr validation by aborting translation in semanal.py --- mypy/checkexpr.py | 11 ----------- mypy/nodes.py | 11 ++--------- mypy/semanal.py | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index b0c445851ab8..8288b676b52e 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5432,17 +5432,6 @@ 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. - # 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 e901fcac52c8..4168b2e00f15 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2672,22 +2672,15 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: class DictExpr(Expression): """Dictionary literal expression {key: value, ...}.""" - __slots__ = ("items", "from_dict_call") + __slots__ = ("items",) __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]], - from_dict_call: bool = False, - ) -> None: + def __init__(self, items: list[tuple[Expression | None, Expression]]) -> 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 8859bb9aa679..5c443c03b7ac 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6051,12 +6051,23 @@ def translate_dict_call(self, call: CallExpr) -> DictExpr | None: for a in call.args: a.accept(self) return None + # Check if any **kwargs argument is a dict literal with non-string keys. + # In that case, don't translate so that normal function call type checking + # will catch the "keywords must be strings" error. + for kind, arg in zip(call.arg_kinds, call.args): + if kind == ARG_STAR2 and isinstance(arg, DictExpr): + # Check if all keys in the dict literal are strings + for key, _ in arg.items: + if key is not None and not isinstance(key, (StrExpr, BytesExpr)): + # Non-string key found, don't translate + for a in call.args: + a.accept(self) + return None expr = DictExpr( [ (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) From 2534663f732c26c351099485edb94174b1111887 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Tue, 3 Feb 2026 06:42:47 +0530 Subject: [PATCH 6/8] Refactor: Use DictExpr attribute to validate string keys, including bytes and nested unpacking --- mypy/checkexpr.py | 9 +++++++++ mypy/nodes.py | 4 +++- mypy/semanal.py | 5 +++-- test-data/unit/check-expressions.test | 2 ++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 8288b676b52e..50d123a4703a 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5436,6 +5436,15 @@ def visit_dict_expr(self, e: DictExpr) -> Type: expected_types.append( self.chk.named_generic_type("_typeshed.SupportsKeysAndGetItem", [kt, vt]) ) + # If this DictExpr came from a dict() call translation, validate that + # any unpacked dict has string keys (keywords must be strings) + if e.from_dict_call: + value_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) else: tup = TupleExpr([key, value]) if key.line >= 0: diff --git a/mypy/nodes.py b/mypy/nodes.py index 4168b2e00f15..58ec788a2142 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2672,15 +2672,17 @@ 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]] + from_dict_call: bool # True if this came from a dict(...) call translation def __init__(self, items: list[tuple[Expression | None, Expression]]) -> None: super().__init__() self.items = items + self.from_dict_call = False def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_dict_expr(self) diff --git a/mypy/semanal.py b/mypy/semanal.py index 5c443c03b7ac..8ff9f5df026d 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6056,9 +6056,9 @@ def translate_dict_call(self, call: CallExpr) -> DictExpr | None: # will catch the "keywords must be strings" error. for kind, arg in zip(call.arg_kinds, call.args): if kind == ARG_STAR2 and isinstance(arg, DictExpr): - # Check if all keys in the dict literal are strings + # Check if all keys in the dict literal are strings (not bytes!) for key, _ in arg.items: - if key is not None and not isinstance(key, (StrExpr, BytesExpr)): + if key is not None and not isinstance(key, StrExpr): # Non-string key found, don't translate for a in call.args: a.accept(self) @@ -6070,6 +6070,7 @@ def translate_dict_call(self, call: CallExpr) -> DictExpr | None: ] ) expr.set_line(call) + expr.from_dict_call = True expr.accept(self) return expr diff --git a/test-data/unit/check-expressions.test b/test-data/unit/check-expressions.test index 962c51486644..2f95c9b67fb5 100644 --- a/test-data/unit/check-expressions.test +++ b/test-data/unit/check-expressions.test @@ -2587,4 +2587,6 @@ def last_known_value() -> None: [case testDictUnpackNonStringKey] def f() -> None: dict(**{10: 20}) # E: Argument after ** must have string keys + dict(**{**{1: 1}}) # E: Argument after ** must have string keys + dict(**{b'a': 1}) # E: Argument after ** must have string keys [builtins fixtures/dict.pyi] \ No newline at end of file From 4f63e4bedb8471019336fcec3a9b5f938261bfb6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 01:14:58 +0000 Subject: [PATCH 7/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test-data/unit/check-expressions.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/check-expressions.test b/test-data/unit/check-expressions.test index 2f95c9b67fb5..31b778a72f12 100644 --- a/test-data/unit/check-expressions.test +++ b/test-data/unit/check-expressions.test @@ -2589,4 +2589,4 @@ def f() -> None: dict(**{10: 20}) # E: Argument after ** must have string keys dict(**{**{1: 1}}) # E: Argument after ** must have string keys dict(**{b'a': 1}) # E: Argument after ** must have string keys -[builtins fixtures/dict.pyi] \ No newline at end of file +[builtins fixtures/dict.pyi] From ff8b04131383a76196967d06fff64651be3c1b87 Mon Sep 17 00:00:00 2001 From: Vikash Kumar <163628932+Vikash-Kumar-23@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:58:37 +0530 Subject: [PATCH 8/8] Update mypy/semanal.py Co-authored-by: A5rocks --- mypy/semanal.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 8ff9f5df026d..97a89f7d5109 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6051,18 +6051,6 @@ def translate_dict_call(self, call: CallExpr) -> DictExpr | None: for a in call.args: a.accept(self) return None - # Check if any **kwargs argument is a dict literal with non-string keys. - # In that case, don't translate so that normal function call type checking - # will catch the "keywords must be strings" error. - for kind, arg in zip(call.arg_kinds, call.args): - if kind == ARG_STAR2 and isinstance(arg, DictExpr): - # Check if all keys in the dict literal are strings (not bytes!) - for key, _ in arg.items: - if key is not None and not isinstance(key, StrExpr): - # Non-string key found, don't translate - for a in call.args: - a.accept(self) - return None expr = DictExpr( [ (StrExpr(key) if key is not None else None, value)