From 339b2323c47a4670246d915978c502a67bd1c0e4 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 11 Oct 2023 21:54:17 +0300 Subject: [PATCH 01/14] Add `|=` and `|` operators support for `TypedDict` --- mypy/checker.py | 18 +++++-- test-data/unit/check-typeddict.test | 53 ++++++++++++++++++++ test-data/unit/fixtures/typing-typeddict.pyi | 12 +++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index e1b65a95ae98..67a46aae4547 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7450,13 +7450,23 @@ def infer_operator_assignment_method(typ: Type, operator: str) -> tuple[bool, st For example, if operator is '+', return (True, '__iadd__') or (False, '__add__') depending on which method is supported by the type. """ + def find_method(inst: Instance, method: str) -> str | None: + if operator in operators.ops_with_inplace_method: + inplace_method = "__i" + method[2:] + if inst.type.has_readable_member(inplace_method): + return inplace_method + return None + typ = get_proper_type(typ) method = operators.op_methods[operator] + existing_method = None if isinstance(typ, Instance): - if operator in operators.ops_with_inplace_method: - inplace_method = "__i" + method[2:] - if typ.type.has_readable_member(inplace_method): - return True, inplace_method + existing_method = find_method(typ, method) + elif isinstance(typ, TypedDictType): + existing_method = find_method(typ.fallback, method) + + if existing_method is not None: + return True, existing_method return False, method diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 7ee9ef0b708b..cc729cc75b5a 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3236,3 +3236,56 @@ def foo(x: int) -> Foo: ... f: Foo = {**foo("no")} # E: Argument 1 to "foo" has incompatible type "str"; expected "int" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] + + +[case testTypedDictWith__or__method] +from mypy_extensions import TypedDict + +class Foo(TypedDict): + key: int + +foo1: Foo = {'key': 1} +foo2: Foo = {'key': 2} + +reveal_type(foo1 | foo2) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" +reveal_type(foo1 | {'key': 1}) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" +reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type(foo1 | {}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictWith__or__method_error] +from mypy_extensions import TypedDict + +class Foo(TypedDict): + key: int + +foo: Foo = {'key': 1} + +foo | 1 +[out] +main:8: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" +main:8: note: Possible overload variants: +main:8: note: def __or__(self, Foo, /) -> Foo +main:8: note: def __or__(self, Dict[str, Any], /) -> Dict[str, object] +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +# TODO: add `__ror__` method check, after `__ror__` definition is fixed. + +[case testTypedDictWith__ior__method] +from mypy_extensions import TypedDict + +class Foo(TypedDict): + key: int + +foo: Foo = {'key': 1} +foo |= {'key': 2} + +foo |= {} # E: Missing key "key" for TypedDict "Foo" +foo |= {'key': 'a', 'b': 'a'} # E: Extra key "b" for TypedDict "Foo" \ + # E: Incompatible types (expression has type "str", TypedDict item "key" has type "int") +foo |= {'b': 2} # E: Missing key "key" for TypedDict "Foo" \ + # E: Extra key "b" for TypedDict "Foo" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index 24a2f1328981..bc5ac874da22 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -71,3 +71,15 @@ class _TypedDict(Mapping[str, object]): def pop(self, k: NoReturn, default: T = ...) -> object: ... def update(self: T, __m: T) -> None: ... def __delitem__(self, k: NoReturn) -> None: ... + # It is a bit of a lie: it is only supported since 3.9 in runtime + @overload + def __or__(self, __value: Self) -> Self: ... + @overload + def __or__(self, __value: dict[str, Any]) -> dict[str, object]: ... + # TODO: re-enable after `__ror__` definition is fixed + # @overload + # def __ror__(self, __value: Self) -> Self: ... + # @overload + # def __ror__(self, __value: dict[str, Any]) -> dict[str, object]: ... + # supposedly incompatible definitions of __or__ and __ior__ + def __ior__(self, __value: Self) -> Self: ... # type: ignore[misc] From 29cc9b9add7275c3d2ba28ecc090971121790b40 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Oct 2023 18:58:12 +0000 Subject: [PATCH 02/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy/checker.py b/mypy/checker.py index 67a46aae4547..133184d62aee 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7450,6 +7450,7 @@ def infer_operator_assignment_method(typ: Type, operator: str) -> tuple[bool, st For example, if operator is '+', return (True, '__iadd__') or (False, '__add__') depending on which method is supported by the type. """ + def find_method(inst: Instance, method: str) -> str | None: if operator in operators.ops_with_inplace_method: inplace_method = "__i" + method[2:] From ac3de1c7aa3c6a46a6bfda449fd018d3c1d261da Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 11 Oct 2023 22:27:46 +0300 Subject: [PATCH 03/14] Fix tests --- test-data/unit/check-typeddict.test | 6 +++--- test-data/unit/fixtures/typing-typeddict.pyi | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index cc729cc75b5a..fa82f5ee5273 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3249,8 +3249,8 @@ foo2: Foo = {'key': 2} reveal_type(foo1 | foo2) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type(foo1 | {'key': 1}) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" -reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" -reveal_type(foo1 | {}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "typing.Mapping[builtins.str, builtins.object]" +reveal_type(foo1 | {}) # N: Revealed type is "typing.Mapping[builtins.str, builtins.object]" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -3267,7 +3267,7 @@ foo | 1 main:8: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" main:8: note: Possible overload variants: main:8: note: def __or__(self, Foo, /) -> Foo -main:8: note: def __or__(self, Dict[str, Any], /) -> Dict[str, object] +main:8: note: def __or__(self, Mapping[str, object], /) -> Mapping[str, object] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index bc5ac874da22..4b05d9a65a4c 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -71,11 +71,13 @@ class _TypedDict(Mapping[str, object]): def pop(self, k: NoReturn, default: T = ...) -> object: ... def update(self: T, __m: T) -> None: ... def __delitem__(self, k: NoReturn) -> None: ... - # It is a bit of a lie: it is only supported since 3.9 in runtime + # It is a bit of a lie: + # 1. it is only supported since 3.9 in runtime + # 2. `__or__` users `dict[str, Any]`, not `Mapping[str, object]` @overload def __or__(self, __value: Self) -> Self: ... @overload - def __or__(self, __value: dict[str, Any]) -> dict[str, object]: ... + def __or__(self, __value: Mapping[str, object]) -> Mapping[str, object]: ... # TODO: re-enable after `__ror__` definition is fixed # @overload # def __ror__(self, __value: Self) -> Self: ... From a3c904b76e7f778a7d8edb103e7c607156e97e74 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 12 Oct 2023 09:04:39 +0300 Subject: [PATCH 04/14] Address review --- mypy/checker.py | 20 ++++++++++---------- test-data/unit/fixtures/typing-typeddict.pyi | 3 ++- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 133184d62aee..6d3c6721137a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7450,27 +7450,27 @@ def infer_operator_assignment_method(typ: Type, operator: str) -> tuple[bool, st For example, if operator is '+', return (True, '__iadd__') or (False, '__add__') depending on which method is supported by the type. """ - - def find_method(inst: Instance, method: str) -> str | None: - if operator in operators.ops_with_inplace_method: - inplace_method = "__i" + method[2:] - if inst.type.has_readable_member(inplace_method): - return inplace_method - return None - typ = get_proper_type(typ) method = operators.op_methods[operator] existing_method = None if isinstance(typ, Instance): - existing_method = find_method(typ, method) + existing_method = _find_inplace_method(typ, method) elif isinstance(typ, TypedDictType): - existing_method = find_method(typ.fallback, method) + existing_method = _find_inplace_method(typ.fallback, method) if existing_method is not None: return True, existing_method return False, method +def _find_inplace_method(inst: Instance, method: str) -> str | None: + if operator in operators.ops_with_inplace_method: + inplace_method = "__i" + method[2:] + if inst.type.has_readable_member(inplace_method): + return inplace_method + return None + + def is_valid_inferred_type(typ: Type, is_lvalue_final: bool = False) -> bool: """Is an inferred type valid and needs no further refinement? diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index 4b05d9a65a4c..60bc59cab541 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -73,12 +73,13 @@ class _TypedDict(Mapping[str, object]): def __delitem__(self, k: NoReturn) -> None: ... # It is a bit of a lie: # 1. it is only supported since 3.9 in runtime - # 2. `__or__` users `dict[str, Any]`, not `Mapping[str, object]` + # 2. `__or__` uses `dict[str, Any]`, not `Mapping[str, object]` @overload def __or__(self, __value: Self) -> Self: ... @overload def __or__(self, __value: Mapping[str, object]) -> Mapping[str, object]: ... # TODO: re-enable after `__ror__` definition is fixed + # https://github.com/python/typeshed/issues/10678 # @overload # def __ror__(self, __value: Self) -> Self: ... # @overload From aa06b4488f303488156c9d015e290c7113c0f68d Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 12 Oct 2023 09:40:47 +0300 Subject: [PATCH 05/14] Fix typo --- mypy/checker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 6d3c6721137a..7c7fbd70eda8 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7454,16 +7454,16 @@ def infer_operator_assignment_method(typ: Type, operator: str) -> tuple[bool, st method = operators.op_methods[operator] existing_method = None if isinstance(typ, Instance): - existing_method = _find_inplace_method(typ, method) + existing_method = _find_inplace_method(typ, method, operator) elif isinstance(typ, TypedDictType): - existing_method = _find_inplace_method(typ.fallback, method) + existing_method = _find_inplace_method(typ.fallback, method, operator) if existing_method is not None: return True, existing_method return False, method -def _find_inplace_method(inst: Instance, method: str) -> str | None: +def _find_inplace_method(inst: Instance, method: str, operator: str) -> str | None: if operator in operators.ops_with_inplace_method: inplace_method = "__i" + method[2:] if inst.type.has_readable_member(inplace_method): From d1f4b78eee28d3321d1e6a93890da41e096616fd Mon Sep 17 00:00:00 2001 From: sobolevn Date: Fri, 13 Oct 2023 12:55:09 +0300 Subject: [PATCH 06/14] Support `__ror__` and improve tests --- mypy/checkexpr.py | 21 +++++- test-data/unit/check-typeddict.test | 28 ++++++-- .../unit/fixtures/typing-typeddict-iror.pyi | 66 +++++++++++++++++++ 3 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 test-data/unit/fixtures/typing-typeddict-iror.pyi diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index fd155ff87379..c74f5f0a1308 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3318,7 +3318,7 @@ def visit_ellipsis(self, e: EllipsisExpr) -> Type: """Type check '...'.""" return self.named_type("builtins.ellipsis") - def visit_op_expr(self, e: OpExpr) -> Type: + def visit_op_expr(self, e: OpExpr, *, allow_reverse: bool = True) -> Type: """Type check a binary operator expression.""" if e.analyzed: # It's actually a type expression X | Y. @@ -3366,6 +3366,21 @@ def visit_op_expr(self, e: OpExpr) -> Type: return proper_left_type.copy_modified( items=proper_left_type.items + [UnpackType(mapped)] ) + if is_named_instance(proper_left_type, "builtins.dict") and e.op == "|": + # This is a special case for `dict | TypedDict`. + # Before this change this operation was not allowed ude to typing limitations, + # however, it does perfect sense from runtime's point of view. + # So, what we do now? + # 1. Find `dict | TypedDict` case + # 2. Switch `dict.__or__` to `TypedDict.__or__` (the same from typing's perspective) + # 3. Do not allow `dict.__ror__` to be executed, since this is a special case + # This can later be removed if `typeshed` can do this without special casing. + # https://github.com/python/mypy/pull/16249 + proper_right_type = get_proper_type(self.accept(e.right)) + if isinstance(proper_right_type, TypedDictType): + reverse_op = OpExpr(e.op, e.right, e.left) + reverse_op.set_line(e) + return self.visit_op_expr(reverse_op, allow_reverse=False) if TYPE_VAR_TUPLE in self.chk.options.enable_incomplete_feature: # Handle tuple[X, ...] + tuple[Y, Z] = tuple[*tuple[X, ...], Y, Z]. if ( @@ -3385,7 +3400,9 @@ def visit_op_expr(self, e: OpExpr) -> Type: if e.op in operators.op_methods: method = operators.op_methods[e.op] - result, method_type = self.check_op(method, left_type, e.right, e, allow_reverse=True) + result, method_type = self.check_op( + method, left_type, e.right, e, allow_reverse=allow_reverse + ) e.method_type = method_type return result else: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index fa82f5ee5273..f08b1abd5940 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3249,10 +3249,10 @@ foo2: Foo = {'key': 2} reveal_type(foo1 | foo2) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type(foo1 | {'key': 1}) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" -reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "typing.Mapping[builtins.str, builtins.object]" -reveal_type(foo1 | {}) # N: Revealed type is "typing.Mapping[builtins.str, builtins.object]" +reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type(foo1 | {}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" [builtins fixtures/dict.pyi] -[typing fixtures/typing-typeddict.pyi] +[typing fixtures/typing-typeddict-iror.pyi] [case testTypedDictWith__or__method_error] from mypy_extensions import TypedDict @@ -3267,11 +3267,25 @@ foo | 1 main:8: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" main:8: note: Possible overload variants: main:8: note: def __or__(self, Foo, /) -> Foo -main:8: note: def __or__(self, Mapping[str, object], /) -> Mapping[str, object] +main:8: note: def __or__(self, Dict[str, object], /) -> Dict[str, object] [builtins fixtures/dict.pyi] -[typing fixtures/typing-typeddict.pyi] +[typing fixtures/typing-typeddict-iror.pyi] + +[case testTypedDictWith__ror__method] +from mypy_extensions import TypedDict + +class Foo(TypedDict): + key: int + +foo: Foo = {'key': 1} -# TODO: add `__ror__` method check, after `__ror__` definition is fixed. +reveal_type({'key': 1} | foo) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" +reveal_type({'key': 'a'} | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type({} | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" + +1 | foo # E: Unsupported left operand type for | ("int") +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict-iror.pyi] [case testTypedDictWith__ior__method] from mypy_extensions import TypedDict @@ -3288,4 +3302,4 @@ foo |= {'key': 'a', 'b': 'a'} # E: Extra key "b" for TypedDict "Foo" \ foo |= {'b': 2} # E: Missing key "key" for TypedDict "Foo" \ # E: Extra key "b" for TypedDict "Foo" [builtins fixtures/dict.pyi] -[typing fixtures/typing-typeddict.pyi] +[typing fixtures/typing-typeddict-iror.pyi] diff --git a/test-data/unit/fixtures/typing-typeddict-iror.pyi b/test-data/unit/fixtures/typing-typeddict-iror.pyi new file mode 100644 index 000000000000..935f099d8eed --- /dev/null +++ b/test-data/unit/fixtures/typing-typeddict-iror.pyi @@ -0,0 +1,66 @@ +# Test stub for typing module that includes TypedDict `|` operator. +# It only covers `__or__`, `__ror__`, and `__ior__`. +# +# We cannot define these methods in `typing-typeddict.pyi`, +# because they need `dict` with two type args, +# and not all tests using `[typing typing-typeddict.pyi]` have the proper +# `dict` stub. +# +# Keep in sync with `typeshed`'s definition. +from abc import ABCMeta + +cast = 0 +assert_type = 0 +overload = 0 +Any = 0 +Union = 0 +Optional = 0 +TypeVar = 0 +Generic = 0 +Protocol = 0 +Tuple = 0 +Callable = 0 +NamedTuple = 0 +Final = 0 +Literal = 0 +TypedDict = 0 +NoReturn = 0 +Required = 0 +NotRequired = 0 +Self = 0 + +T = TypeVar('T') +T_co = TypeVar('T_co', covariant=True) +V = TypeVar('V') + +# Note: definitions below are different from typeshed, variances are declared +# to silence the protocol variance checks. Maybe it is better to use type: ignore? + +class Sized(Protocol): + def __len__(self) -> int: pass + +class Iterable(Protocol[T_co]): + def __iter__(self) -> 'Iterator[T_co]': pass + +class Iterator(Iterable[T_co], Protocol): + def __next__(self) -> T_co: pass + +class Sequence(Iterable[T_co]): + # misc is for explicit Any. + def __getitem__(self, n: Any) -> T_co: pass # type: ignore[misc] + +class Mapping(Iterable[T], Generic[T, T_co], metaclass=ABCMeta): + pass + +# Fallback type for all typed dicts (does not exist at runtime). +class _TypedDict(Mapping[str, object]): + @overload + def __or__(self, __value: Self) -> Self: ... + @overload + def __or__(self, __value: dict[str, object]) -> dict[str, object]: ... + @overload + def __ror__(self, __value: Self) -> Self: ... + @overload + def __ror__(self, __value: dict[str, Any]) -> dict[str, object]: ... + # supposedly incompatible definitions of __or__ and __ior__ + def __ior__(self, __value: Self) -> Self: ... # type: ignore[misc] From 6b1d4be7612d5eb01b7cc0079290d15be44eeb86 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Fri, 13 Oct 2023 13:51:58 +0300 Subject: [PATCH 07/14] Simplify tests more --- mypy/checkexpr.py | 38 ++++++++++++-------- test-data/unit/fixtures/dict.pyi | 19 +++++++++- test-data/unit/fixtures/typing-full.pyi | 1 + test-data/unit/fixtures/typing-typeddict.pyi | 15 -------- 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index c74f5f0a1308..a6fef11d656b 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3366,21 +3366,29 @@ def visit_op_expr(self, e: OpExpr, *, allow_reverse: bool = True) -> Type: return proper_left_type.copy_modified( items=proper_left_type.items + [UnpackType(mapped)] ) - if is_named_instance(proper_left_type, "builtins.dict") and e.op == "|": - # This is a special case for `dict | TypedDict`. - # Before this change this operation was not allowed ude to typing limitations, - # however, it does perfect sense from runtime's point of view. - # So, what we do now? - # 1. Find `dict | TypedDict` case - # 2. Switch `dict.__or__` to `TypedDict.__or__` (the same from typing's perspective) - # 3. Do not allow `dict.__ror__` to be executed, since this is a special case - # This can later be removed if `typeshed` can do this without special casing. - # https://github.com/python/mypy/pull/16249 - proper_right_type = get_proper_type(self.accept(e.right)) - if isinstance(proper_right_type, TypedDictType): - reverse_op = OpExpr(e.op, e.right, e.left) - reverse_op.set_line(e) - return self.visit_op_expr(reverse_op, allow_reverse=False) + + if e.op == "|": + if is_named_instance(proper_left_type, "builtins.dict"): + # This is a special case for `dict | TypedDict`. + # Before this change this operation was not allowed due to typing limitations, + # however, it makes perfect sense from the runtime's point of view. + # So, what do we do now? + # 1. Find `dict | TypedDict` case + # 2. Switch `dict.__or__` to `TypedDict.__or__` (the same from typing's perspective) + # 3. Do not allow `dict.__ror__` to be executed, since this is a special case + # This can later be removed if `typeshed` can do this without special casing. + # https://github.com/python/mypy/pull/16249 + proper_right_type = get_proper_type(self.accept(e.right)) + if isinstance(proper_right_type, TypedDictType): + reverse_op = OpExpr(e.op, e.right, e.left) + reverse_op.set_line(e) + return self.visit_op_expr(reverse_op, allow_reverse=False) + if isinstance(proper_left_type, TypedDictType): + # This is the reverse case: `TypedDict | dict`, + # simply do not allow the reverse checking: + # do not call `__dict__.__ror__`. + allow_reverse = False + if TYPE_VAR_TUPLE in self.chk.options.enable_incomplete_feature: # Handle tuple[X, ...] + tuple[Y, Z] = tuple[*tuple[X, ...], Y, Z]. if ( diff --git a/test-data/unit/fixtures/dict.pyi b/test-data/unit/fixtures/dict.pyi index 19d175ff79ab..7c0c8767f7d7 100644 --- a/test-data/unit/fixtures/dict.pyi +++ b/test-data/unit/fixtures/dict.pyi @@ -3,10 +3,12 @@ from _typeshed import SupportsKeysAndGetItem import _typeshed from typing import ( - TypeVar, Generic, Iterable, Iterator, Mapping, Tuple, overload, Optional, Union, Sequence + TypeVar, Generic, Iterable, Iterator, Mapping, Tuple, overload, Optional, Union, Sequence, + Self, ) T = TypeVar('T') +T2 = TypeVar('T2') KT = TypeVar('KT') VT = TypeVar('VT') @@ -34,6 +36,21 @@ class dict(Mapping[KT, VT]): def get(self, k: KT, default: Union[VT, T]) -> Union[VT, T]: pass def __len__(self) -> int: ... + # This was actually added in 3.9: + @overload + def __or__(self, __value: dict[KT, VT]) -> dict[KT, VT]: ... + @overload + def __or__(self, __value: dict[T, T2]) -> dict[Union[KT, T], Union[VT, T2]]: ... + @overload + def __ror__(self, __value: dict[KT, VT]) -> dict[KT, VT]: ... + @overload + def __ror__(self, __value: dict[T, T2]) -> dict[Union[KT, T], Union[VT, T2]]: ... + # dict.__ior__ should be kept roughly in line with MutableMapping.update() + @overload # type: ignore[misc] + def __ior__(self, __value: _typeshed.SupportsKeysAndGetItem[KT, VT]) -> Self: ... + @overload + def __ior__(self, __value: Iterable[Tuple[KT, VT]]) -> Self: ... + class int: # for convenience def __add__(self, x: Union[int, complex]) -> int: pass def __radd__(self, x: int) -> int: pass diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 417ae6baf491..aad69a280274 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -30,6 +30,7 @@ Literal = 0 TypedDict = 0 NoReturn = 0 NewType = 0 +Self = 0 T = TypeVar('T') T_co = TypeVar('T_co', covariant=True) diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index 60bc59cab541..24a2f1328981 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -71,18 +71,3 @@ class _TypedDict(Mapping[str, object]): def pop(self, k: NoReturn, default: T = ...) -> object: ... def update(self: T, __m: T) -> None: ... def __delitem__(self, k: NoReturn) -> None: ... - # It is a bit of a lie: - # 1. it is only supported since 3.9 in runtime - # 2. `__or__` uses `dict[str, Any]`, not `Mapping[str, object]` - @overload - def __or__(self, __value: Self) -> Self: ... - @overload - def __or__(self, __value: Mapping[str, object]) -> Mapping[str, object]: ... - # TODO: re-enable after `__ror__` definition is fixed - # https://github.com/python/typeshed/issues/10678 - # @overload - # def __ror__(self, __value: Self) -> Self: ... - # @overload - # def __ror__(self, __value: dict[str, Any]) -> dict[str, object]: ... - # supposedly incompatible definitions of __or__ and __ior__ - def __ior__(self, __value: Self) -> Self: ... # type: ignore[misc] From 6eb572896af40483f877353d589ce5cea051cb02 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Fri, 13 Oct 2023 17:41:30 +0300 Subject: [PATCH 08/14] Fix tests --- test-data/unit/fixtures/typing-async.pyi | 1 + test-data/unit/fixtures/typing-medium.pyi | 1 + 2 files changed, 2 insertions(+) diff --git a/test-data/unit/fixtures/typing-async.pyi b/test-data/unit/fixtures/typing-async.pyi index b207dd599c33..9897dfd0b270 100644 --- a/test-data/unit/fixtures/typing-async.pyi +++ b/test-data/unit/fixtures/typing-async.pyi @@ -24,6 +24,7 @@ ClassVar = 0 Final = 0 Literal = 0 NoReturn = 0 +Self = 0 T = TypeVar('T') T_co = TypeVar('T_co', covariant=True) diff --git a/test-data/unit/fixtures/typing-medium.pyi b/test-data/unit/fixtures/typing-medium.pyi index 03be1d0a664d..c19c5d5d96e2 100644 --- a/test-data/unit/fixtures/typing-medium.pyi +++ b/test-data/unit/fixtures/typing-medium.pyi @@ -28,6 +28,7 @@ NoReturn = 0 NewType = 0 TypeAlias = 0 LiteralString = 0 +Self = 0 T = TypeVar('T') T_co = TypeVar('T_co', covariant=True) From 6c1ae4a581d81f36cfaf7c404e94b76e52537375 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 15 Oct 2023 05:21:02 +0300 Subject: [PATCH 09/14] Check dict subtype --- mypy/checkexpr.py | 4 +++- test-data/unit/check-typeddict.test | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index a6fef11d656b..4b066513921a 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3387,7 +3387,9 @@ def visit_op_expr(self, e: OpExpr, *, allow_reverse: bool = True) -> Type: # This is the reverse case: `TypedDict | dict`, # simply do not allow the reverse checking: # do not call `__dict__.__ror__`. - allow_reverse = False + proper_right_type = get_proper_type(self.accept(e.right)) + if is_named_instance(proper_right_type, "builtins.dict"): + allow_reverse = False if TYPE_VAR_TUPLE in self.chk.options.enable_incomplete_feature: # Handle tuple[X, ...] + tuple[Y, Z] = tuple[*tuple[X, ...], Y, Z]. diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index f08b1abd5940..1f2aa0bd24ae 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3263,11 +3263,19 @@ class Foo(TypedDict): foo: Foo = {'key': 1} foo | 1 + +class SubDict(dict): ... + +foo | SubDict() [out] main:8: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" main:8: note: Possible overload variants: main:8: note: def __or__(self, Foo, /) -> Foo main:8: note: def __or__(self, Dict[str, object], /) -> Dict[str, object] +main:12: error: No overload variant of "__ror__" of "dict" matches argument type "Foo" +main:12: note: Possible overload variants: +main:12: note: def __ror__(self, Dict[Any, Any], /) -> Dict[Any, Any] +main:12: note: def [T, T2] __ror__(self, Dict[T, T2], /) -> Dict[Union[Any, T], Union[Any, T2]] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] From 73015a2e306579516f9ff2dfdbd26911fc343627 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 18 Oct 2023 13:09:07 +0300 Subject: [PATCH 10/14] Address review --- mypy/checkexpr.py | 45 ++++++++++++++----- test-data/unit/check-typeddict.test | 24 +++++++++- .../unit/fixtures/typing-typeddict-iror.pyi | 2 +- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 4b066513921a..3d5ba2d2a7d8 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -2,12 +2,13 @@ from __future__ import annotations +import enum import itertools import time from collections import defaultdict from contextlib import contextmanager from typing import Callable, ClassVar, Final, Iterable, Iterator, List, Optional, Sequence, cast -from typing_extensions import TypeAlias as _TypeAlias, overload +from typing_extensions import TypeAlias as _TypeAlias, assert_never, overload import mypy.checker import mypy.errorcodes as codes @@ -272,6 +273,15 @@ class Finished(Exception): """Raised if we can terminate overload argument check early (no match).""" +@enum.unique +class UseReverse(enum.IntEnum): + """Used in `visit_op_expr` to enable or disable reverse method checks.""" + + DEFAULT = 0 + ALWAYS = 1 + NEVER = 2 + + class ExpressionChecker(ExpressionVisitor[Type]): """Expression type checker. @@ -3318,7 +3328,7 @@ def visit_ellipsis(self, e: EllipsisExpr) -> Type: """Type check '...'.""" return self.named_type("builtins.ellipsis") - def visit_op_expr(self, e: OpExpr, *, allow_reverse: bool = True) -> Type: + def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = UseReverse.DEFAULT) -> Type: """Type check a binary operator expression.""" if e.analyzed: # It's actually a type expression X | Y. @@ -3374,22 +3384,18 @@ def visit_op_expr(self, e: OpExpr, *, allow_reverse: bool = True) -> Type: # however, it makes perfect sense from the runtime's point of view. # So, what do we do now? # 1. Find `dict | TypedDict` case - # 2. Switch `dict.__or__` to `TypedDict.__or__` (the same from typing's perspective) + # 2. Switch `dict.__or__` to `TypedDict.__or__` (the same from runtime perspective) # 3. Do not allow `dict.__ror__` to be executed, since this is a special case - # This can later be removed if `typeshed` can do this without special casing. - # https://github.com/python/mypy/pull/16249 proper_right_type = get_proper_type(self.accept(e.right)) if isinstance(proper_right_type, TypedDictType): - reverse_op = OpExpr(e.op, e.right, e.left) - reverse_op.set_line(e) - return self.visit_op_expr(reverse_op, allow_reverse=False) + use_reverse = UseReverse.ALWAYS if isinstance(proper_left_type, TypedDictType): # This is the reverse case: `TypedDict | dict`, # simply do not allow the reverse checking: # do not call `__dict__.__ror__`. proper_right_type = get_proper_type(self.accept(e.right)) if is_named_instance(proper_right_type, "builtins.dict"): - allow_reverse = False + use_reverse = UseReverse.NEVER if TYPE_VAR_TUPLE in self.chk.options.enable_incomplete_feature: # Handle tuple[X, ...] + tuple[Y, Z] = tuple[*tuple[X, ...], Y, Z]. @@ -3410,9 +3416,24 @@ def visit_op_expr(self, e: OpExpr, *, allow_reverse: bool = True) -> Type: if e.op in operators.op_methods: method = operators.op_methods[e.op] - result, method_type = self.check_op( - method, left_type, e.right, e, allow_reverse=allow_reverse - ) + if use_reverse is UseReverse.DEFAULT or use_reverse is UseReverse.NEVER: + result, method_type = self.check_op( + method, + base_type=left_type, + arg=e.right, + context=e, + allow_reverse=use_reverse is UseReverse.DEFAULT, + ) + elif use_reverse is UseReverse.ALWAYS: + result, method_type = self.check_op( + method, + base_type=self.accept(e.right), + arg=e.left, + context=e, + allow_reverse=False, + ) + else: + assert_never(use_reverse) e.method_type = method_type return result else: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 1f2aa0bd24ae..95f7b579c34d 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3239,6 +3239,7 @@ f: Foo = {**foo("no")} # E: Argument 1 to "foo" has incompatible type "str"; ex [case testTypedDictWith__or__method] +from typing import Dict from mypy_extensions import TypedDict class Foo(TypedDict): @@ -3251,6 +3252,12 @@ reveal_type(foo1 | foo2) # N: Revealed type is "TypedDict('__main__.Foo', {'key reveal_type(foo1 | {'key': 1}) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" reveal_type(foo1 | {}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" + +d1: Dict[str, int] +d2: Dict[int, str] + +reveal_type(foo1 | d1) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +foo1 | d2 # E: Unsupported operand types for | ("Foo" and "Dict[int, str]") [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] @@ -3271,7 +3278,7 @@ foo | SubDict() main:8: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" main:8: note: Possible overload variants: main:8: note: def __or__(self, Foo, /) -> Foo -main:8: note: def __or__(self, Dict[str, object], /) -> Dict[str, object] +main:8: note: def __or__(self, Dict[str, Any], /) -> Dict[str, object] main:12: error: No overload variant of "__ror__" of "dict" matches argument type "Foo" main:12: note: Possible overload variants: main:12: note: def __ror__(self, Dict[Any, Any], /) -> Dict[Any, Any] @@ -3280,6 +3287,7 @@ main:12: note: def [T, T2] __ror__(self, Dict[T, T2], /) -> Dict[Union[Any, [typing fixtures/typing-typeddict-iror.pyi] [case testTypedDictWith__ror__method] +from typing import Dict from mypy_extensions import TypedDict class Foo(TypedDict): @@ -3290,12 +3298,20 @@ foo: Foo = {'key': 1} reveal_type({'key': 1} | foo) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type({'key': 'a'} | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" reveal_type({} | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +{1: 'a'} | foo # E: Dict entry 0 has incompatible type "int": "str"; expected "str": "Any" + +d1: Dict[str, int] +d2: Dict[int, str] + +reveal_type(d1 | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +d2 | foo # E: Unsupported operand types for | ("Dict[int, str]" and "Foo") 1 | foo # E: Unsupported left operand type for | ("int") [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] [case testTypedDictWith__ior__method] +from typing import Dict from mypy_extensions import TypedDict class Foo(TypedDict): @@ -3309,5 +3325,11 @@ foo |= {'key': 'a', 'b': 'a'} # E: Extra key "b" for TypedDict "Foo" \ # E: Incompatible types (expression has type "str", TypedDict item "key" has type "int") foo |= {'b': 2} # E: Missing key "key" for TypedDict "Foo" \ # E: Extra key "b" for TypedDict "Foo" + +d1: Dict[str, int] +d2: Dict[int, str] + +foo |= d1 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[str, int]"; expected "Foo" +foo |= d2 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[int, str]"; expected "Foo" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] diff --git a/test-data/unit/fixtures/typing-typeddict-iror.pyi b/test-data/unit/fixtures/typing-typeddict-iror.pyi index 935f099d8eed..e452c8497109 100644 --- a/test-data/unit/fixtures/typing-typeddict-iror.pyi +++ b/test-data/unit/fixtures/typing-typeddict-iror.pyi @@ -57,7 +57,7 @@ class _TypedDict(Mapping[str, object]): @overload def __or__(self, __value: Self) -> Self: ... @overload - def __or__(self, __value: dict[str, object]) -> dict[str, object]: ... + def __or__(self, __value: dict[str, Any]) -> dict[str, object]: ... @overload def __ror__(self, __value: Self) -> Self: ... @overload From f8f4d62895993b875c620c0e05757634751ac846 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 18 Oct 2023 13:18:49 +0300 Subject: [PATCH 11/14] Fix mypyc --- mypy/checkexpr.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 3d5ba2d2a7d8..288cb4559dd6 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -274,13 +274,17 @@ class Finished(Exception): @enum.unique -class UseReverse(enum.IntEnum): +class UseReverse(enum.Enum): """Used in `visit_op_expr` to enable or disable reverse method checks.""" DEFAULT = 0 ALWAYS = 1 NEVER = 2 +USE_REVERSE_DEFAULT: Final = UseReverse.DEFAULT +USE_REVERSE_ALWAYS: Final = UseReverse.ALWAYS +USE_REVERSE_NEVER: Final = UseReverse.NEVER + class ExpressionChecker(ExpressionVisitor[Type]): """Expression type checker. @@ -3328,7 +3332,7 @@ def visit_ellipsis(self, e: EllipsisExpr) -> Type: """Type check '...'.""" return self.named_type("builtins.ellipsis") - def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = UseReverse.DEFAULT) -> Type: + def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = USE_REVERSE_DEFAULT) -> Type: """Type check a binary operator expression.""" if e.analyzed: # It's actually a type expression X | Y. @@ -3388,14 +3392,14 @@ def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = UseReverse.DEFAU # 3. Do not allow `dict.__ror__` to be executed, since this is a special case proper_right_type = get_proper_type(self.accept(e.right)) if isinstance(proper_right_type, TypedDictType): - use_reverse = UseReverse.ALWAYS + use_reverse = USE_REVERSE_ALWAYS if isinstance(proper_left_type, TypedDictType): # This is the reverse case: `TypedDict | dict`, # simply do not allow the reverse checking: # do not call `__dict__.__ror__`. proper_right_type = get_proper_type(self.accept(e.right)) if is_named_instance(proper_right_type, "builtins.dict"): - use_reverse = UseReverse.NEVER + use_reverse = USE_REVERSE_NEVER if TYPE_VAR_TUPLE in self.chk.options.enable_incomplete_feature: # Handle tuple[X, ...] + tuple[Y, Z] = tuple[*tuple[X, ...], Y, Z]. From 1bcdf93c681b5fc0f0f7005b484962116d2c1e06 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Oct 2023 10:19:33 +0000 Subject: [PATCH 12/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checkexpr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 288cb4559dd6..45cf97095ff5 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -281,6 +281,7 @@ class UseReverse(enum.Enum): ALWAYS = 1 NEVER = 2 + USE_REVERSE_DEFAULT: Final = UseReverse.DEFAULT USE_REVERSE_ALWAYS: Final = UseReverse.ALWAYS USE_REVERSE_NEVER: Final = UseReverse.NEVER From 37f6c670b0bf8110d6c63094e04e8a9a9b5ead74 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 18 Oct 2023 16:35:33 +0300 Subject: [PATCH 13/14] Fix CI --- mypy/checkexpr.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 45cf97095ff5..b2356eaac929 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3389,7 +3389,7 @@ def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = USE_REVERSE_DEFA # however, it makes perfect sense from the runtime's point of view. # So, what do we do now? # 1. Find `dict | TypedDict` case - # 2. Switch `dict.__or__` to `TypedDict.__or__` (the same from runtime perspective) + # 2. Switch `dict.__or__` to `TypedDict.__ror__` (the same from both runtime and typing perspective) # 3. Do not allow `dict.__ror__` to be executed, since this is a special case proper_right_type = get_proper_type(self.accept(e.right)) if isinstance(proper_right_type, TypedDictType): @@ -3431,7 +3431,16 @@ def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = USE_REVERSE_DEFA ) elif use_reverse is UseReverse.ALWAYS: result, method_type = self.check_op( - method, + # The reverse operator here gives better error messages: + # Given: + # d: dict[int, str] + # t: YourTD + # d | t + # Without the switch: + # - Unsupported operand types for | ("YourTD" and "dict[int, str]") + # With the switch: + # - Unsupported operand types for | ("dict[int, str]" and "YourTD") + operators.reverse_op_methods[method], base_type=self.accept(e.right), arg=e.left, context=e, From f4d05ce1bbe640390333ab8d152ca6a71d5a19ca Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 23 Oct 2023 12:23:42 +0300 Subject: [PATCH 14/14] Address review --- mypy/checkexpr.py | 15 +----- mypy/plugins/default.py | 22 ++++++-- test-data/unit/check-typeddict.test | 84 ++++++++++++++++++++++------- 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index b2356eaac929..4c2a407c461a 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3333,7 +3333,7 @@ def visit_ellipsis(self, e: EllipsisExpr) -> Type: """Type check '...'.""" return self.named_type("builtins.ellipsis") - def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = USE_REVERSE_DEFAULT) -> Type: + def visit_op_expr(self, e: OpExpr) -> Type: """Type check a binary operator expression.""" if e.analyzed: # It's actually a type expression X | Y. @@ -3382,15 +3382,12 @@ def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = USE_REVERSE_DEFA items=proper_left_type.items + [UnpackType(mapped)] ) + use_reverse: UseReverse = USE_REVERSE_DEFAULT if e.op == "|": if is_named_instance(proper_left_type, "builtins.dict"): # This is a special case for `dict | TypedDict`. - # Before this change this operation was not allowed due to typing limitations, - # however, it makes perfect sense from the runtime's point of view. - # So, what do we do now? # 1. Find `dict | TypedDict` case # 2. Switch `dict.__or__` to `TypedDict.__ror__` (the same from both runtime and typing perspective) - # 3. Do not allow `dict.__ror__` to be executed, since this is a special case proper_right_type = get_proper_type(self.accept(e.right)) if isinstance(proper_right_type, TypedDictType): use_reverse = USE_REVERSE_ALWAYS @@ -3432,14 +3429,6 @@ def visit_op_expr(self, e: OpExpr, *, use_reverse: UseReverse = USE_REVERSE_DEFA elif use_reverse is UseReverse.ALWAYS: result, method_type = self.check_op( # The reverse operator here gives better error messages: - # Given: - # d: dict[int, str] - # t: YourTD - # d | t - # Without the switch: - # - Unsupported operand types for | ("YourTD" and "dict[int, str]") - # With the switch: - # - Unsupported operand types for | ("dict[int, str]" and "YourTD") operators.reverse_op_methods[method], base_type=self.accept(e.right), arg=e.left, diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index b60fc3873c04..ddcc37f465fe 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -74,12 +74,21 @@ def get_method_signature_hook( return typed_dict_setdefault_signature_callback elif fullname in {n + ".pop" for n in TPDICT_FB_NAMES}: return typed_dict_pop_signature_callback - elif fullname in {n + ".update" for n in TPDICT_FB_NAMES}: - return typed_dict_update_signature_callback elif fullname == "_ctypes.Array.__setitem__": return ctypes.array_setitem_callback elif fullname == singledispatch.SINGLEDISPATCH_CALLABLE_CALL_METHOD: return singledispatch.call_singledispatch_function_callback + + typed_dict_updates = set() + for n in TPDICT_FB_NAMES: + typed_dict_updates.add(n + ".update") + typed_dict_updates.add(n + ".__or__") + typed_dict_updates.add(n + ".__ror__") + typed_dict_updates.add(n + ".__ior__") + + if fullname in typed_dict_updates: + return typed_dict_update_signature_callback + return None def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | None: @@ -401,11 +410,16 @@ def typed_dict_delitem_callback(ctx: MethodContext) -> Type: def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType: - """Try to infer a better signature type for TypedDict.update.""" + """Try to infer a better signature type for methods that update `TypedDict`. + + This includes: `TypedDict.update`, `TypedDict.__or__`, `TypedDict.__ror__`, + and `TypedDict.__ior__`. + """ signature = ctx.default_signature if isinstance(ctx.type, TypedDictType) and len(signature.arg_types) == 1: arg_type = get_proper_type(signature.arg_types[0]) - assert isinstance(arg_type, TypedDictType) + if not isinstance(arg_type, TypedDictType): + return signature arg_type = arg_type.as_anonymous() arg_type = arg_type.copy_modified(required_keys=set()) if ctx.args and ctx.args[0]: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 95f7b579c34d..0e1d800e0468 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3251,13 +3251,31 @@ foo2: Foo = {'key': 2} reveal_type(foo1 | foo2) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type(foo1 | {'key': 1}) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type(foo1 | {'key': 'a'}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" -reveal_type(foo1 | {}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type(foo1 | {}) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" d1: Dict[str, int] d2: Dict[int, str] reveal_type(foo1 | d1) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" foo1 | d2 # E: Unsupported operand types for | ("Foo" and "Dict[int, str]") + + +class Bar(TypedDict): + key: int + value: str + +bar: Bar +reveal_type(bar | {}) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type(bar | {'key': 1, 'value': 'v'}) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type(bar | {'key': 1}) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type(bar | {'value': 'v'}) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type(bar | {'key': 'a'}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type(bar | {'value': 1}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type(bar | {'key': 'a', 'value': 1}) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" + +reveal_type(bar | foo1) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type(bar | d1) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +bar | d2 # E: Unsupported operand types for | ("Bar" and "Dict[int, str]") [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] @@ -3268,21 +3286,19 @@ class Foo(TypedDict): key: int foo: Foo = {'key': 1} - foo | 1 class SubDict(dict): ... - foo | SubDict() [out] -main:8: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" -main:8: note: Possible overload variants: -main:8: note: def __or__(self, Foo, /) -> Foo -main:8: note: def __or__(self, Dict[str, Any], /) -> Dict[str, object] -main:12: error: No overload variant of "__ror__" of "dict" matches argument type "Foo" -main:12: note: Possible overload variants: -main:12: note: def __ror__(self, Dict[Any, Any], /) -> Dict[Any, Any] -main:12: note: def [T, T2] __ror__(self, Dict[T, T2], /) -> Dict[Union[Any, T], Union[Any, T2]] +main:7: error: No overload variant of "__or__" of "TypedDict" matches argument type "int" +main:7: note: Possible overload variants: +main:7: note: def __or__(self, TypedDict({'key'?: int}), /) -> Foo +main:7: note: def __or__(self, Dict[str, Any], /) -> Dict[str, object] +main:10: error: No overload variant of "__ror__" of "dict" matches argument type "Foo" +main:10: note: Possible overload variants: +main:10: note: def __ror__(self, Dict[Any, Any], /) -> Dict[Any, Any] +main:10: note: def [T, T2] __ror__(self, Dict[T, T2], /) -> Dict[Union[Any, T], Union[Any, T2]] [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] @@ -3297,7 +3313,7 @@ foo: Foo = {'key': 1} reveal_type({'key': 1} | foo) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" reveal_type({'key': 'a'} | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" -reveal_type({} | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type({} | foo) # N: Revealed type is "TypedDict('__main__.Foo', {'key': builtins.int})" {1: 'a'} | foo # E: Dict entry 0 has incompatible type "int": "str"; expected "str": "Any" d1: Dict[str, int] @@ -3305,8 +3321,24 @@ d2: Dict[int, str] reveal_type(d1 | foo) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" d2 | foo # E: Unsupported operand types for | ("Dict[int, str]" and "Foo") - 1 | foo # E: Unsupported left operand type for | ("int") + + +class Bar(TypedDict): + key: int + value: str + +bar: Bar +reveal_type({} | bar) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type({'key': 1, 'value': 'v'} | bar) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type({'key': 1} | bar) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type({'value': 'v'} | bar) # N: Revealed type is "TypedDict('__main__.Bar', {'key': builtins.int, 'value': builtins.str})" +reveal_type({'key': 'a'} | bar) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type({'value': 1} | bar) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +reveal_type({'key': 'a', 'value': 1} | bar) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" + +reveal_type(d1 | bar) # N: Revealed type is "builtins.dict[builtins.str, builtins.object]" +d2 | bar # E: Unsupported operand types for | ("Dict[int, str]" and "Bar") [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi] @@ -3320,16 +3352,30 @@ class Foo(TypedDict): foo: Foo = {'key': 1} foo |= {'key': 2} -foo |= {} # E: Missing key "key" for TypedDict "Foo" -foo |= {'key': 'a', 'b': 'a'} # E: Extra key "b" for TypedDict "Foo" \ +foo |= {} +foo |= {'key': 'a', 'b': 'a'} # E: Expected TypedDict key "key" but found keys ("key", "b") \ # E: Incompatible types (expression has type "str", TypedDict item "key" has type "int") -foo |= {'b': 2} # E: Missing key "key" for TypedDict "Foo" \ - # E: Extra key "b" for TypedDict "Foo" +foo |= {'b': 2} # E: Unexpected TypedDict key "b" d1: Dict[str, int] d2: Dict[int, str] -foo |= d1 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[str, int]"; expected "Foo" -foo |= d2 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[int, str]"; expected "Foo" +foo |= d1 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[str, int]"; expected "TypedDict({'key'?: int})" +foo |= d2 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[int, str]"; expected "TypedDict({'key'?: int})" + + +class Bar(TypedDict): + key: int + value: str + +bar: Bar +bar |= {} +bar |= {'key': 1, 'value': 'a'} +bar |= {'key': 'a', 'value': 'a', 'b': 'a'} # E: Expected TypedDict keys ("key", "value") but found keys ("key", "value", "b") \ + # E: Incompatible types (expression has type "str", TypedDict item "key" has type "int") + +bar |= foo +bar |= d1 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[str, int]"; expected "TypedDict({'key'?: int, 'value'?: str})" +bar |= d2 # E: Argument 1 to "__ior__" of "TypedDict" has incompatible type "Dict[int, str]"; expected "TypedDict({'key'?: int, 'value'?: str})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict-iror.pyi]