From c7824be884875ddd7c21db1b32c11164f64fd103 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Wed, 30 Nov 2022 13:45:13 +0100 Subject: [PATCH 01/18] =?UTF-8?q?=E2=9C=A8=20Added=20new=20Error=20to=20Ty?= =?UTF-8?q?pedDict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See: [#4617](https://github.com/python/mypy/issues/4617) This allows the following code to trigger the error `typeddict-unknown-key` ```python A = T.TypedDict("A", {"x": int}) def f(x: A) -> None: ... f({"x": 1, "y": "foo"}) ``` The user can then safely ignore this specific error at their disgression. --- mypy/errorcodes.py | 3 +++ mypy/messages.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index 1c15407a955b..234b9bce40f7 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 85fa30512534..300ee85317ea 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1633,7 +1633,7 @@ def unexpected_typeddict_keys( format_key_list(extra, short=True), format_type(typ) ), context, - code=codes.TYPEDDICT_ITEM, + code=codes.TYPPEDICT_UNKNOWN_KEY, ) return found = format_key_list(actual_keys, short=True) From f19a7f764a9a56249dde0179f43f13484d57c6b6 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Wed, 30 Nov 2022 13:55:43 +0100 Subject: [PATCH 02/18] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20'missing=20keys'?= =?UTF-8?q?=20not=20being=20reported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/messages.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 300ee85317ea..71d28af18296 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1612,30 +1612,25 @@ 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] + self.fail( + "Missing {} for TypedDict {}".format( + format_key_list(missing, short=True), format_type(typ) + ), + context, + code=codes.TYPEDDICT_ITEM, + ) + extra = [key for key in actual_keys if key not in expected_set] + if extra: self.fail( - "Missing {} for TypedDict {}".format( - format_key_list(missing, short=True), format_type(typ) + "Extra {} for TypedDict {}".format( + format_key_list(extra, short=True), format_type(typ) ), context, - code=codes.TYPEDDICT_ITEM, + code=codes.TYPPEDICT_UNKNOWN_KEY, ) 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.TYPPEDICT_UNKNOWN_KEY, - ) - return found = format_key_list(actual_keys, short=True) if not expected_keys: self.fail(f"Unexpected TypedDict {found}", context) From 2a4220c30bf24cff163db128a559abeff1888dd6 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Wed, 30 Nov 2022 14:42:44 +0100 Subject: [PATCH 03/18] =?UTF-8?q?=F0=9F=90=9B=20Early=20return=20in=20case?= =?UTF-8?q?=20there's=20missing=20or=20extra=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/messages.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 71d28af18296..731884bb836e 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1614,13 +1614,14 @@ def unexpected_typeddict_keys( # Generate simpler messages for some common special cases. # Use list comprehension instead of set operations to preserve order. missing = [key for key in expected_keys if key not in actual_set] - self.fail( - "Missing {} for TypedDict {}".format( - format_key_list(missing, short=True), format_type(typ) - ), - context, - code=codes.TYPEDDICT_ITEM, - ) + if missing: + self.fail( + "Missing {} for TypedDict {}".format( + format_key_list(missing, short=True), format_type(typ) + ), + context, + code=codes.TYPEDDICT_ITEM, + ) extra = [key for key in actual_keys if key not in expected_set] if extra: self.fail( @@ -1630,6 +1631,8 @@ def unexpected_typeddict_keys( context, code=codes.TYPPEDICT_UNKNOWN_KEY, ) + if missing or extra: + # No need to check for further errors return found = format_key_list(actual_keys, short=True) if not expected_keys: From fcf4909c916706734d208c13d8b8264fe03b110b Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Wed, 30 Nov 2022 14:43:05 +0100 Subject: [PATCH 04/18] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=94=AC=20Fixed?= =?UTF-8?q?=20simple=20error-code=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now return two instead of one --- test-data/unit/check-errorcodes.test | 2 +- test-data/unit/check-typeddict.test | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index 798c52629a35..80672fc10c7f 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -455,7 +455,7 @@ 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] diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index fbef6157087c..dff46e111573 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -2020,7 +2020,7 @@ 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] From deb6543f23afc8d6ce5b7a682c94fa13a5c095c0 Mon Sep 17 00:00:00 2001 From: JoaquimEsteves Date: Fri, 2 Dec 2022 12:14:58 +0100 Subject: [PATCH 05/18] Update test-data/unit/check-typeddict.test Co-authored-by: Ivan Levkivskyi --- test-data/unit/check-typeddict.test | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index dff46e111573..6e09f0b1065d 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -2020,7 +2020,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: Missing key "num" for TypedDict "Value" # 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] From 222869da43b98e609fa5497fa39af9dba4c1765d Mon Sep 17 00:00:00 2001 From: JoaquimEsteves Date: Fri, 2 Dec 2022 12:16:28 +0100 Subject: [PATCH 06/18] Update test-data/unit/check-errorcodes.test Co-authored-by: Ivan Levkivskyi --- test-data/unit/check-errorcodes.test | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index 80672fc10c7f..f090f2b9436c 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -455,7 +455,8 @@ 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: Missing key "x" for TypedDict "D" [typeddict-item] # E: Extra key "y" for TypedDict "D" [typeddict-unknown-key] +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] From 70c0a18096dc796cc7c3e617ad81fec2df304e9c Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Mon, 5 Dec 2022 11:09:15 +0100 Subject: [PATCH 07/18] =?UTF-8?q?=E2=9C=A8=20We=20now=20typecheck=20despit?= =?UTF-8?q?e=20having=20an=20extra-key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🧑‍🔬 Added test to check this behaviour --- mypy/checkexpr.py | 13 +++++++++---- test-data/unit/check-errorcodes.test | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index b97c78cba2fc..e1c76a9fdc9b 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -789,17 +789,22 @@ 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() + found_set = set(actual_keys) + if not (callee.required_keys <= found_set <= set(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 found_set ] - 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 > found_set: + # found_set is not a sub-set of the required_keys + # This means we're dealing with something weird we can't + # properly type + return AnyType(TypeOfAny.from_error) orig_callee = get_proper_type(orig_callee) if isinstance(orig_callee, CallableType): diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index f090f2b9436c..74a47978c12f 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -459,6 +459,9 @@ 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['x'] = 'x' # E: Value of "x" has incompatible type "str"; expected "int" [typeddict-item] From eda0c8b436b877074475b9f332f374867b526948 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Mon, 5 Dec 2022 11:20:51 +0100 Subject: [PATCH 08/18] =?UTF-8?q?=F0=9F=A7=B9=20Setting=20an=20extra=20val?= =?UTF-8?q?ue=20on=20a=20TypedDict=20now=20has=20the=20correct=20error=20c?= =?UTF-8?q?ode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/messages.py | 2 +- test-data/unit/check-errorcodes.test | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 731884bb836e..d71a47cda6af 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1666,7 +1666,7 @@ def typeddict_key_not_found( self.fail( f'TypedDict {format_type(typ)} has no key "{item_name}"', context, - code=codes.TYPEDDICT_ITEM, + code=codes.TYPPEDICT_UNKNOWN_KEY, ) matches = best_matches(item_name, typ.items.keys()) if matches: diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index 74a47978c12f..4fe9f587642d 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -463,9 +463,9 @@ d: D = {'x': '', 'y': 1} # E: Extra key "y" for TypedDict "D" [typeddict-unkno # 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] +a['y'] # E: TypedDict "D" has no key "y" [typeddict-unknown-key] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -476,7 +476,7 @@ 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,typeddict-item] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] From 25f3dbcc50eb9c3c2f75433c614e6068e5087550 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Mon, 5 Dec 2022 12:44:14 +0100 Subject: [PATCH 09/18] =?UTF-8?q?=E2=9C=A8=20We=20can=20now=20set=20an=20i?= =?UTF-8?q?tem=20and=20get=20the=20correct=20error=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/checkexpr.py | 6 ++++-- mypy/checkmember.py | 4 +++- mypy/messages.py | 12 ++++++++++-- test-data/unit/check-errorcodes.test | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index e1c76a9fdc9b..8b9baa86c49d 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3740,7 +3740,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: @@ -3769,7 +3771,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 554b49d3eda2..211e64a2d478 100644 --- a/mypy/checkmember.py +++ b/mypy/checkmember.py @@ -1068,7 +1068,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/messages.py b/mypy/messages.py index d71a47cda6af..2c5dd6b6b828 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1653,8 +1653,16 @@ 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: + """ + Handles error messages. + + 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( @@ -1666,7 +1674,7 @@ def typeddict_key_not_found( self.fail( f'TypedDict {format_type(typ)} has no key "{item_name}"', context, - code=codes.TYPPEDICT_UNKNOWN_KEY, + code=codes.TYPPEDICT_UNKNOWN_KEY if setitem else codes.TYPEDDICT_ITEM, ) matches = best_matches(item_name, typ.items.keys()) if matches: diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index 4fe9f587642d..e381b59f86b7 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -465,7 +465,7 @@ d: D = {'x': '', 'y': 1} # E: Extra key "y" for TypedDict "D" [typeddict-unkno 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-unknown-key] +a['y'] # E: TypedDict "D" has no key "y" [typeddict-item] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] From c7c687f1768b8c41d3f360fffd3ecc271bbb486d Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Mon, 5 Dec 2022 13:03:22 +0100 Subject: [PATCH 10/18] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=94=AC=20Redid?= =?UTF-8?q?=20the=20`IgnotErrorCodeTypedDictNoteIgnore`=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-data/unit/check-errorcodes.test | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index e381b59f86b7..60bc6d743e82 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -476,7 +476,10 @@ class A(TypedDict): two_commonparts: int a: A = {'one_commonpart': 1, 'two_commonparts': 2} -a['other_commonpart'] = 3 # type: ignore[typeddict-unknown-key,typeddict-item] +a['other_commonpart'] = 3 # type: ignore[typeddict-unknown-key] \ + # N: Did you mean "one_commonpart" or "two_commonparts"? \ + # N: Error code "typeddict-item" not covered by "type: ignore" comment +not_exist = a['not_exist'] # type: ignore[typeddict-item] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] From a275c86286055d746234ce92e02662e2c760c730 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Thu, 8 Dec 2022 12:02:21 +0100 Subject: [PATCH 11/18] =?UTF-8?q?=F0=9F=90=88=E2=80=8D=E2=AC=9B=20Black?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/messages.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 2c5dd6b6b828..92323d3a6f30 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1656,12 +1656,12 @@ def typeddict_key_not_found( self, typ: TypedDictType, item_name: str, context: Context, setitem: bool = False ) -> None: """ - Handles error messages. + Handles error messages. - 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. + 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( From f7a7f4f84ac06e5dadc7d6ab05094cd2ca9cd782 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Thu, 8 Dec 2022 13:49:58 +0100 Subject: [PATCH 12/18] =?UTF-8?q?=F0=9F=90=9B=20Added=20Review=20Suggestio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't need to convert dict_keys/items to set. Also fixed the comment saying literally the opposite of what was happening --- mypy/checkexpr.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 8b9baa86c49d..d1ebd81f1c57 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -790,20 +790,19 @@ def check_typeddict_call_with_kwargs( orig_callee: Type | None, ) -> Type: actual_keys = kwargs.keys() - found_set = set(actual_keys) - if not (callee.required_keys <= found_set <= set(callee.items.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 found_set + if key in callee.required_keys or key in actual_keys ] self.msg.unexpected_typeddict_keys( callee, expected_keys=expected_keys, actual_keys=list(actual_keys), context=context ) - if callee.required_keys > found_set: - # found_set is not a sub-set of the required_keys - # This means we're dealing with something weird we can't - # properly type + 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) From 06a386603a54ef7b630c4a0ced1216573630551c Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Thu, 8 Dec 2022 13:53:28 +0100 Subject: [PATCH 13/18] =?UTF-8?q?=F0=9F=93=9D=20Fixed=20docstring=20not=20?= =?UTF-8?q?being=20in=20the=20correct=20format.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/messages.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 92323d3a6f30..3275c4f95f7b 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1655,13 +1655,12 @@ 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, setitem: bool = False ) -> None: - """ - Handles error messages. + """Handles 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. + 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( From 780e10d2d530a938246661c28348bc107f7939b0 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Thu, 8 Dec 2022 13:53:54 +0100 Subject: [PATCH 14/18] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20ignoring=20unknown?= =?UTF-8?q?-key=20not=20silencing=20the=20'Did=20you=20mean'=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy/messages.py | 7 +++---- test-data/unit/check-errorcodes.test | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 3275c4f95f7b..c7cea8d42189 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1670,17 +1670,16 @@ 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.TYPPEDICT_UNKNOWN_KEY if setitem else 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()) if matches: self.note( "Did you mean {}?".format(pretty_seq(matches[:3], "or")), context, - code=codes.TYPEDDICT_ITEM, + 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 60bc6d743e82..c3481280be70 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -476,9 +476,7 @@ class A(TypedDict): two_commonparts: int a: A = {'one_commonpart': 1, 'two_commonparts': 2} -a['other_commonpart'] = 3 # type: ignore[typeddict-unknown-key] \ - # N: Did you mean "one_commonpart" or "two_commonparts"? \ - # N: Error code "typeddict-item" not covered by "type: ignore" comment +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] From fb98524c75caf25502cbc41a3320c58237f272e7 Mon Sep 17 00:00:00 2001 From: JoaquimEsteves Date: Mon, 23 Jan 2023 14:14:48 +0100 Subject: [PATCH 15/18] Review Feedback - Better Docstrings Co-authored-by: Ivan Levkivskyi --- mypy/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/messages.py b/mypy/messages.py index c7cea8d42189..55b377b17c6e 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1655,7 +1655,7 @@ 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, setitem: bool = False ) -> None: - """Handles error messages for TypedDicts that have unknown keys. + """Handle error messages for TypedDicts that have unknown keys. Note, that we differentiate in between reading a value and setting a value. From 708d3228b2c6da5a329faba0957ffe6aba1aa11e Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Mon, 23 Jan 2023 14:45:43 +0100 Subject: [PATCH 16/18] =?UTF-8?q?=F0=9F=93=9D=20Added=20`unknown-key`=20to?= =?UTF-8?q?=20the=20`error=5Fcode=5Flist`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/source/error_code_list.rst | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index 264badc03107..dbdd191ec02d 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -430,6 +430,65 @@ 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 "non-existant" for TypedDict "Point" [typeddict-unknown-key] + a['non-existant'] = 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 "non-existant" [typeddict-item] + _ = a['non-existant'] + Check that type of target is known [has-type] --------------------------------------------- From e4b82b6b54a57c659428c9d6eb1297f930de6730 Mon Sep 17 00:00:00 2001 From: Joaquim Esteves Date: Mon, 23 Jan 2023 15:10:32 +0100 Subject: [PATCH 17/18] =?UTF-8?q?=F0=9F=90=9B=20Black=20and=20sphinx=20bug?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/source/error_code_list.rst | 2 +- mypy/messages.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index dbdd191ec02d..190ccf1c5c17 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -432,7 +432,7 @@ Example: 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. diff --git a/mypy/messages.py b/mypy/messages.py index 55b377b17c6e..a41e20e87ddf 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1677,9 +1677,7 @@ def typeddict_key_not_found( matches = best_matches(item_name, typ.items.keys()) if matches: self.note( - "Did you mean {}?".format(pretty_seq(matches[:3], "or")), - context, - code=err_code, + "Did you mean {}?".format(pretty_seq(matches, "or")), context, code=err_code ) def typeddict_context_ambiguous(self, types: list[TypedDictType], context: Context) -> None: From 957ba17201b41e9ed83b248503c19cda437d47fc Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 25 Jan 2023 16:26:01 +0000 Subject: [PATCH 18/18] Apply suggestions from code review (docs) --- docs/source/error_code_list.rst | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index c048bb1e8367..674ad08c4d09 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -435,40 +435,31 @@ 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: +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}) + add_x_coordinates(a, {"x": 1, "y": 4, "z": 5}) Setting an unknown value on a ``TypedDict`` will also generate this error: @@ -476,8 +467,8 @@ Setting an unknown value on a ``TypedDict`` will also generate this error: .. code-block:: python a: Point = {"x": 1, "y": 2} - # Error: Extra key "non-existant" for TypedDict "Point" [typeddict-unknown-key] - a['non-existant'] = 3 + # Error: Extra key "z" for TypedDict "Point" [typeddict-unknown-key] + a["z"] = 3 Whereas reading an unknown value will generate the more generic/serious @@ -486,8 +477,8 @@ Whereas reading an unknown value will generate the more generic/serious .. code-block:: python a: Point = {"x": 1, "y": 2} - # Error: TypedDict "Point" has no key "non-existant" [typeddict-item] - _ = a['non-existant'] + # Error: TypedDict "Point" has no key "z" [typeddict-item] + _ = a["z"] Check that type of target is known [has-type]