diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 8288b676b52e..b0c445851ab8 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5432,6 +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. + # 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..99458501aa1c 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2672,15 +2672,20 @@ 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..4df54cef168c 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]