diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index 1a39bf8feb6c..674ad08c4d09 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -430,6 +430,56 @@ Example: # Error: Incompatible types (expression has type "float", # TypedDict item "x" has type "int") [typeddict-item] p: Point = {'x': 1.2, 'y': 4} + +Check TypedDict Keys [typeddict-unknown-key] +-------------------------------------------- + +When constructing a ``TypedDict`` object, mypy checks whether the definition +contains unknown keys. For convenience's sake, mypy will not generate an error +when a ``TypedDict`` has extra keys if it's passed to a function as an argument. +However, it will generate an error when these are created. Example: + +.. code-block:: python + + from typing_extensions import TypedDict + + class Point(TypedDict): + x: int + y: int + + class Point3D(Point): + z: int + + def add_x_coordinates(a: Point, b: Point) -> int: + return a["x"] + b["x"] + + a: Point = {"x": 1, "y": 4} + b: Point3D = {"x": 2, "y": 5, "z": 6} + + # OK + add_x_coordinates(a, b) + # Error: Extra key "z" for TypedDict "Point" [typeddict-unknown-key] + add_x_coordinates(a, {"x": 1, "y": 4, "z": 5}) + + +Setting an unknown value on a ``TypedDict`` will also generate this error: + +.. code-block:: python + + a: Point = {"x": 1, "y": 2} + # Error: Extra key "z" for TypedDict "Point" [typeddict-unknown-key] + a["z"] = 3 + + +Whereas reading an unknown value will generate the more generic/serious +``typeddict-item``: + +.. code-block:: python + + a: Point = {"x": 1, "y": 2} + # Error: TypedDict "Point" has no key "z" [typeddict-item] + _ = a["z"] + Check that type of target is known [has-type] --------------------------------------------- diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index c2cf226ef210..7fbd45b10e96 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -790,17 +790,21 @@ def check_typeddict_call_with_kwargs( context: Context, orig_callee: Type | None, ) -> Type: - if not (callee.required_keys <= set(kwargs.keys()) <= set(callee.items.keys())): + actual_keys = kwargs.keys() + if not (callee.required_keys <= actual_keys <= callee.items.keys()): expected_keys = [ key for key in callee.items.keys() - if key in callee.required_keys or key in kwargs.keys() + if key in callee.required_keys or key in actual_keys ] - actual_keys = kwargs.keys() self.msg.unexpected_typeddict_keys( callee, expected_keys=expected_keys, actual_keys=list(actual_keys), context=context ) - return AnyType(TypeOfAny.from_error) + if callee.required_keys > actual_keys: + # found_set is a sub-set of the required_keys + # This means we're missing some keys and as such, we can't + # properly type the object + return AnyType(TypeOfAny.from_error) orig_callee = get_proper_type(orig_callee) if isinstance(orig_callee, CallableType): @@ -3762,7 +3766,9 @@ def nonliteral_tuple_index_helper(self, left_type: TupleType, index: Expression) return self.chk.named_generic_type("builtins.tuple", [union]) return union - def visit_typeddict_index_expr(self, td_type: TypedDictType, index: Expression) -> Type: + def visit_typeddict_index_expr( + self, td_type: TypedDictType, index: Expression, setitem: bool = False + ) -> Type: if isinstance(index, StrExpr): key_names = [index.value] else: @@ -3791,7 +3797,7 @@ def visit_typeddict_index_expr(self, td_type: TypedDictType, index: Expression) for key_name in key_names: value_type = td_type.items.get(key_name) if value_type is None: - self.msg.typeddict_key_not_found(td_type, key_name, index) + self.msg.typeddict_key_not_found(td_type, key_name, index, setitem) return AnyType(TypeOfAny.from_error) else: value_types.append(value_type) diff --git a/mypy/checkmember.py b/mypy/checkmember.py index f90a4f706a87..a2c580e13446 100644 --- a/mypy/checkmember.py +++ b/mypy/checkmember.py @@ -1073,7 +1073,9 @@ def analyze_typeddict_access( if isinstance(mx.context, IndexExpr): # Since we can get this during `a['key'] = ...` # it is safe to assume that the context is `IndexExpr`. - item_type = mx.chk.expr_checker.visit_typeddict_index_expr(typ, mx.context.index) + item_type = mx.chk.expr_checker.visit_typeddict_index_expr( + typ, mx.context.index, setitem=True + ) else: # It can also be `a.__setitem__(...)` direct call. # In this case `item_type` can be `Any`, diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index 5696763ec9d1..ab49e70eaf20 100644 --- a/mypy/errorcodes.py +++ b/mypy/errorcodes.py @@ -67,6 +67,9 @@ def __str__(self) -> str: TYPEDDICT_ITEM: Final = ErrorCode( "typeddict-item", "Check items when constructing TypedDict", "General" ) +TYPPEDICT_UNKNOWN_KEY: Final = ErrorCode( + "typeddict-unknown-key", "Check unknown keys when constructing TypedDict", "General" +) HAS_TYPE: Final = ErrorCode( "has-type", "Check that type of reference can be determined", "General" ) diff --git a/mypy/messages.py b/mypy/messages.py index 94a97f696b6c..750dcdd42398 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1637,9 +1637,9 @@ def unexpected_typeddict_keys( expected_set = set(expected_keys) if not typ.is_anonymous(): # Generate simpler messages for some common special cases. - if actual_set < expected_set: - # Use list comprehension instead of set operations to preserve order. - missing = [key for key in expected_keys if key not in actual_set] + # Use list comprehension instead of set operations to preserve order. + missing = [key for key in expected_keys if key not in actual_set] + if missing: self.fail( "Missing {} for TypedDict {}".format( format_key_list(missing, short=True), format_type(typ) @@ -1647,20 +1647,18 @@ def unexpected_typeddict_keys( context, code=codes.TYPEDDICT_ITEM, ) + extra = [key for key in actual_keys if key not in expected_set] + if extra: + self.fail( + "Extra {} for TypedDict {}".format( + format_key_list(extra, short=True), format_type(typ) + ), + context, + code=codes.TYPPEDICT_UNKNOWN_KEY, + ) + if missing or extra: + # No need to check for further errors return - else: - extra = [key for key in actual_keys if key not in expected_set] - if extra: - # If there are both extra and missing keys, only report extra ones for - # simplicity. - self.fail( - "Extra {} for TypedDict {}".format( - format_key_list(extra, short=True), format_type(typ) - ), - context, - code=codes.TYPEDDICT_ITEM, - ) - return found = format_key_list(actual_keys, short=True) if not expected_keys: self.fail(f"Unexpected TypedDict {found}", context) @@ -1680,8 +1678,15 @@ def typeddict_key_must_be_string_literal(self, typ: TypedDictType, context: Cont ) def typeddict_key_not_found( - self, typ: TypedDictType, item_name: str, context: Context + self, typ: TypedDictType, item_name: str, context: Context, setitem: bool = False ) -> None: + """Handle error messages for TypedDicts that have unknown keys. + + Note, that we differentiate in between reading a value and setting a + value. + Setting a value on a TypedDict is an 'unknown-key' error, whereas + reading it is the more serious/general 'item' error. + """ if typ.is_anonymous(): self.fail( '"{}" is not a valid TypedDict key; expected one of {}'.format( @@ -1690,17 +1695,14 @@ def typeddict_key_not_found( context, ) else: + err_code = codes.TYPPEDICT_UNKNOWN_KEY if setitem else codes.TYPEDDICT_ITEM self.fail( - f'TypedDict {format_type(typ)} has no key "{item_name}"', - context, - code=codes.TYPEDDICT_ITEM, + f'TypedDict {format_type(typ)} has no key "{item_name}"', context, code=err_code ) matches = best_matches(item_name, typ.items.keys(), n=3) if matches: self.note( - "Did you mean {}?".format(pretty_seq(matches, "or")), - context, - code=codes.TYPEDDICT_ITEM, + "Did you mean {}?".format(pretty_seq(matches, "or")), context, code=err_code ) def typeddict_context_ambiguous(self, types: list[TypedDictType], context: Context) -> None: diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index 19ce56057ff5..8c6a446d101e 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -455,11 +455,15 @@ class E(TypedDict): y: int a: D = {'x': ''} # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int") [typeddict-item] -b: D = {'y': ''} # E: Extra key "y" for TypedDict "D" [typeddict-item] +b: D = {'y': ''} # E: Missing key "x" for TypedDict "D" [typeddict-item] \ + # E: Extra key "y" for TypedDict "D" [typeddict-unknown-key] c = D(x=0) if int() else E(x=0, y=0) c = {} # E: Expected TypedDict key "x" but found no keys [typeddict-item] +d: D = {'x': '', 'y': 1} # E: Extra key "y" for TypedDict "D" [typeddict-unknown-key] \ + # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int") [typeddict-item] -a['y'] = 1 # E: TypedDict "D" has no key "y" [typeddict-item] + +a['y'] = 1 # E: TypedDict "D" has no key "y" [typeddict-unknown-key] a['x'] = 'x' # E: Value of "x" has incompatible type "str"; expected "int" [typeddict-item] a['y'] # E: TypedDict "D" has no key "y" [typeddict-item] [builtins fixtures/dict.pyi] @@ -472,7 +476,8 @@ class A(TypedDict): two_commonparts: int a: A = {'one_commonpart': 1, 'two_commonparts': 2} -a['other_commonpart'] = 3 # type: ignore[typeddict-item] +a['other_commonpart'] = 3 # type: ignore[typeddict-unknown-key] +not_exist = a['not_exist'] # type: ignore[typeddict-item] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 70ff6a4a6759..1f200d168a55 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -2030,7 +2030,8 @@ v = {union: 2} # E: Expected TypedDict key to be string literal num2: Literal['num'] v = {num2: 2} bad2: Literal['bad'] -v = {bad2: 2} # E: Extra key "bad" for TypedDict "Value" +v = {bad2: 2} # E: Missing key "num" for TypedDict "Value" \ + # E: Extra key "bad" for TypedDict "Value" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi]