Repository: python/mypy
Pull request: Add support for operators with union operands
This pull request resolves https://github.com/python/mypy/issues/2128 -- it modifies how we check operators to add support for operations like `Union[int, float] + Union[int, float]`.

This approach basically iterates over all possible variations of the left and right operands when they're unions and uses the union of the resulting inferred type as the type of the overall expression.

A few implementation notes: 

1. This PR starts by first destructuring only the left operand (and leaving the right operand alone) in a manner very similar to what's described here: https://github.com/python/mypy/issues/2128#issuecomment-398015973. If that triggers an error, it retries but also destructures the right operand.

    It unfortunately turned out that just attempting to do one or the other leads to failures in different edge cases -- destructuring just the left operand causes the testOperatorDoubleUnionInterwovenUnionAdd test case to fail; immediately jumping to destructuring both operands causes the testOperatorWithEmptyListAndSum test case to fail.

    This will almost certainly cause some sort of performance regression, but I haven't yet benchmarked how severe it is.

2. This PR also makes the error messages when the left operand is a union more explicit (and a little more verbose). I hope that's ok -- I don't think there's an easy way of getting around that/keeping the old error messages.

Problem statement:
Mypy fails on `Union[float, int] + Union[float, int]`
``` py
def add_value(a: Union[float, int], b: Union[float, int]) -> Union[float, int]:
    return a + b
```

Results in:

```
error: Unsupported operand types for + ("Union[float, int]" and "int")
```

Interestingly both

``` py
def add_value(a: Union[float, int], b: float) -> Union[float, int]:
    return a + b
```

and

``` py
def add_value(a: int, b: Union[float, int]) -> Union[float, int]:
    return a + b
```

work fine

Hints from issue discussion:
That's definitely problematic. I suspect it's because we don't try all combinations of all unions involved in an expression. We should probably do better, although overuse of this could cause performance regressions.

Now I believe that this is another incarnation of overload https://github.com/python/typing/issues/253 and therefore #1941 and the others mentioned there. I cannot base this claim though.

Regarding @gvanrossum's comment: "overuse of this could cause performance regressions"…

Uh… code that's wrong isn't especially useful no matter how fast it is.

If it's too slow for whatever criteria, then it would be good to make this case a warning rather than an error, as false positives break CI builds that are trying to use `mypy` for code quality checks, and this check is clearly incomplete/broken.
(or give up with a warning if the matrix is too large)
I've encountered this bug most recently in version 0.610. A fix would be much appreciated!
Here is my diagnosis, mypy first asks "What is the signature of `__add__` when accessed on `Union[int, float]`?" Looking at typeshed, this results in `Union[int -> int, float -> float]`, then mypy typechecks the call `Union[int -> int, float -> float](Union[int, float])`, and correctly rejects it. It then falls back to `__radd__` with obviously still no success. The flaw in this logic is that `__add__` to `__radd__` fallback happens _at runtime_, and to match this we need to allow fallback for some elements of a union. So the fix would be straightforward, first check `float.__add__(Union[int, float])`, be satisfied, then check `int.__add__(Union[int, float])` and fallback to `__radd__`. @gvanrossum I don't think this will have any measurable performance implications.

Off-topic: it is sad to see many issues that have straightforward fixes, but we just don't have time to implement them, sigh.
I have also just encountered this bug (or rather, an equivalent bug concerning `Union[int, complex] + Union[int, complex]`.
Assigning myself -- I'm planning on making some fixes to operators in the near future, so I might as well look at this one too.

Commit message: Fix lint errors

Patch to review:
diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py
index 743a77e74260..b50e221b79c3 100644
--- a/mypy/checkexpr.py
+++ b/mypy/checkexpr.py
@@ -1820,19 +1820,12 @@ def check_op_reversible(self,
                             left_expr: Expression,
                             right_type: Type,
                             right_expr: Expression,
-                            context: Context) -> Tuple[Type, Type]:
-        # Note: this kludge exists mostly to maintain compatibility with
-        # existing error messages. Apparently, if the left-hand-side is a
-        # union and we have a type mismatch, we print out a special,
-        # abbreviated error message. (See messages.unsupported_operand_types).
-        unions_present = isinstance(left_type, UnionType)
-
+                            context: Context,
+                            msg: MessageBuilder) -> Tuple[Type, Type]:
         def make_local_errors() -> MessageBuilder:
             """Creates a new MessageBuilder object."""
-            local_errors = self.msg.clean_copy()
+            local_errors = msg.clean_copy()
             local_errors.disable_count = 0
-            if unions_present:
-                local_errors.disable_type_names += 1
             return local_errors
 
         def lookup_operator(op_name: str, base_type: Type) -> Optional[Type]:
@@ -2009,9 +2002,9 @@ def lookup_definer(typ: Instance, attr_name: str) -> Optional[str]:
                 # TODO: Remove this extra case
                 return result
 
-        self.msg.add_errors(errors[0])
+        msg.add_errors(errors[0])
         if warn_about_uncalled_reverse_operator:
-            self.msg.reverse_operator_method_never_called(
+            msg.reverse_operator_method_never_called(
                 nodes.op_methods_to_symbols[op_name],
                 op_name,
                 right_type,
@@ -2025,8 +2018,8 @@ def lookup_definer(typ: Instance, attr_name: str) -> Optional[str]:
             result = error_any, error_any
             return result
 
-    def check_op(self, method: str, base_type: Type, arg: Expression,
-                 context: Context,
+    def check_op(self, method: str, base_type: Type,
+                 arg: Expression, context: Context,
                  allow_reverse: bool = False) -> Tuple[Type, Type]:
         """Type check a binary operation which maps to a method call.
 
@@ -2034,13 +2027,80 @@ def check_op(self, method: str, base_type: Type, arg: Expression,
         """
 
         if allow_reverse:
-            return self.check_op_reversible(
-                op_name=method,
-                left_type=base_type,
-                left_expr=TempNode(base_type),
-                right_type=self.accept(arg),
-                right_expr=arg,
-                context=context)
+            left_variants = [base_type]
+            if isinstance(base_type, UnionType):
+                left_variants = [item for item in base_type.relevant_items()]
+            right_type = self.accept(arg)
+
+            # Step 1: We first try leaving the right arguments alone and destructure
+            # just the left ones. (Mypy can sometimes perform some more precise inference
+            # if we leave the right operands a union -- see testOperatorWithEmptyListAndSum.
+            msg = self.msg.clean_copy()
+            msg.disable_count = 0
+            all_results = []
+            all_inferred = []
+
+            for left_possible_type in left_variants:
+                result, inferred = self.check_op_reversible(
+                    op_name=method,
+                    left_type=left_possible_type,
+                    left_expr=TempNode(left_possible_type),
+                    right_type=right_type,
+                    right_expr=arg,
+                    context=context,
+                    msg=msg)
+                all_results.append(result)
+                all_inferred.append(inferred)
+
+            if not msg.is_errors():
+                results_final = UnionType.make_simplified_union(all_results)
+                inferred_final = UnionType.make_simplified_union(all_inferred)
+                return results_final, inferred_final
+
+            # Step 2: If that fails, we try again but also destructure the right argument.
+            # This is also necessary to make certain edge cases work -- see
+            # testOperatorDoubleUnionInterwovenUnionAdd, for example.
+
+            # Note: We want to pass in the original 'arg' for 'left_expr' and 'right_expr'
+            # whenever possible so that plugins and similar things can introspect on the original
+            # node if possible.
+            #
+            # We don't do the same for the base expression because it could lead to weird
+            # type inference errors -- e.g. see 'testOperatorDoubleUnionSum'.
+            right_variants = [(right_type, arg)]
+            if isinstance(right_type, UnionType):
+                right_variants = [(item, TempNode(item)) for item in right_type.relevant_items()]
+
+            msg = self.msg.clean_copy()
+            msg.disable_count = 0
+            all_results = []
+            all_inferred = []
+
+            for left_possible_type in left_variants:
+                for right_possible_type, right_expr in right_variants:
+                    result, inferred = self.check_op_reversible(
+                        op_name=method,
+                        left_type=left_possible_type,
+                        left_expr=TempNode(left_possible_type),
+                        right_type=right_possible_type,
+                        right_expr=right_expr,
+                        context=context,
+                        msg=msg)
+                    all_results.append(result)
+                    all_inferred.append(inferred)
+
+            if msg.is_errors():
+                self.msg.add_errors(msg)
+                if len(left_variants) >= 2 and len(right_variants) >= 2:
+                    self.msg.warn_both_operands_are_from_unions(context)
+                elif len(left_variants) >= 2:
+                    self.msg.warn_operand_was_from_union("Left", base_type, context)
+                elif len(right_variants) >= 2:
+                    self.msg.warn_operand_was_from_union("Right", right_type, context)
+
+            results_final = UnionType.make_simplified_union(all_results)
+            inferred_final = UnionType.make_simplified_union(all_inferred)
+            return results_final, inferred_final
         else:
             return self.check_op_local_by_name(
                 method=method,
diff --git a/mypy/messages.py b/mypy/messages.py
index 1b277c68f25a..6b721d4c8abc 100644
--- a/mypy/messages.py
+++ b/mypy/messages.py
@@ -1019,6 +1019,12 @@ def reverse_operator_method_never_called(self,
             ),
             context=context)
 
+    def warn_both_operands_are_from_unions(self, context: Context) -> None:
+        self.note('Both left and right operands are unions', context)
+
+    def warn_operand_was_from_union(self, side: str, original: Type, context: Context) -> None:
+        self.note('{} operand is of type {}'.format(side, self.format(original)), context)
+
     def operator_method_signatures_overlap(
             self, reverse_class: TypeInfo, reverse_method: str, forward_class: Type,
             forward_method: str, context: Context) -> None:
diff --git a/test-data/unit/check-callable.test b/test-data/unit/check-callable.test
index 39e7bd77d60f..b7026207fd19 100644
--- a/test-data/unit/check-callable.test
+++ b/test-data/unit/check-callable.test
@@ -46,7 +46,8 @@ from typing import Callable, Union
 x = 5  # type: Union[int, Callable[[], str], Callable[[], int]]
 
 if callable(x):
-    y = x() + 2 # E: Unsupported operand types for + (likely involving Union)
+    y = x() + 2 # E: Unsupported operand types for + ("str" and "int") \
+                # N: Left operand is of type "Union[str, int]"
 else:
     z = x + 6
 
@@ -60,7 +61,8 @@ x = 5  # type: Union[int, str, Callable[[], str]]
 if callable(x):
     y = x() + 'test'
 else:
-    z = x + 6  # E: Unsupported operand types for + (likely involving Union)
+    z = x + 6  # E: Unsupported operand types for + ("str" and "int") \
+               # N: Left operand is of type "Union[int, str]"
 
 [builtins fixtures/callable.pyi]
 
@@ -153,7 +155,8 @@ x = 5  # type: Union[int, Callable[[], str]]
 if callable(x) and x() == 'test':
     x()
 else:
-    x + 5  # E: Unsupported left operand type for + (some union)
+    x + 5  # E: Unsupported left operand type for + ("Callable[[], str]") \
+           # N: Left operand is of type "Union[int, Callable[[], str]]"
 
 [builtins fixtures/callable.pyi]
 
diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test
index 54d43097ff30..d0c23c7eed71 100644
--- a/test-data/unit/check-classes.test
+++ b/test-data/unit/check-classes.test
@@ -2019,6 +2019,164 @@ class FractionChild(Fraction): pass
 
 class A(metaclass=Real): pass
 
+[case testOperatorDoubleUnionIntFloat]
+from typing import Union
+
+a: Union[int, float]
+b: int
+c: float
+
+reveal_type(a + a)  # E: Revealed type is 'builtins.float'
+reveal_type(a + b)  # E: Revealed type is 'builtins.float'
+reveal_type(b + a)  # E: Revealed type is 'builtins.float'
+reveal_type(a + c)  # E: Revealed type is 'builtins.float'
+reveal_type(c + a)  # E: Revealed type is 'builtins.float'
+[builtins fixtures/ops.pyi]
+
+[case testOperatorDoubleUnionStandardSubtyping]
+from typing import Union
+
+class Parent:
+    def __add__(self, x: Parent) -> Parent: pass
+    def __radd__(self, x: Parent) -> Parent: pass
+
+class Child(Parent):
+    def __add__(self, x: Parent) -> Child: pass
+    def __radd__(self, x: Parent) -> Child: pass
+
+a: Union[Parent, Child]
+b: Parent
+c: Child
+
+reveal_type(a + a)  # E: Revealed type is '__main__.Parent'
+reveal_type(a + b)  # E: Revealed type is '__main__.Parent'
+reveal_type(b + a)  # E: Revealed type is '__main__.Parent'
+reveal_type(a + c)  # E: Revealed type is '__main__.Child'
+reveal_type(c + a)  # E: Revealed type is '__main__.Child'
+
+[case testOperatorDoubleUnionNoRelationship1]
+from typing import Union
+
+class Foo:
+    def __add__(self, x: Foo) -> Foo: pass
+    def __radd__(self, x: Foo) -> Foo: pass
+
+class Bar:
+    def __add__(self, x: Bar) -> Bar: pass
+    def __radd__(self, x: Bar) -> Bar: pass
+
+a: Union[Foo, Bar]
+b: Foo
+c: Bar
+
+a + a  # E: Unsupported operand types for + ("Foo" and "Bar") \
+       # E: Unsupported operand types for + ("Bar" and "Foo") \
+       # N: Both left and right operands are unions
+
+a + b  # E: Unsupported operand types for + ("Bar" and "Foo") \
+       # N: Left operand is of type "Union[Foo, Bar]"
+
+b + a  # E: Unsupported operand types for + ("Foo" and "Bar") \
+       # N: Right operand is of type "Union[Foo, Bar]"
+
+a + c  # E: Unsupported operand types for + ("Foo" and "Bar") \
+       # N: Left operand is of type "Union[Foo, Bar]"
+
+c + a  # E: Unsupported operand types for + ("Bar" and "Foo") \
+       # N: Right operand is of type "Union[Foo, Bar]"
+
+[case testOperatorDoubleUnionNoRelationship2]
+from typing import Union
+
+class Foo:
+    def __add__(self, x: Foo) -> Foo: pass
+    def __radd__(self, x: Foo) -> Foo: pass
+
+class Bar:
+    def __add__(self, x: Union[Foo, Bar]) -> Bar: pass
+    def __radd__(self, x: Union[Foo, Bar]) -> Bar: pass
+
+a: Union[Foo, Bar]
+b: Foo
+c: Bar
+
+reveal_type(a + a)  # E: Revealed type is 'Union[__main__.Foo, __main__.Bar]'
+reveal_type(a + b)  # E: Revealed type is 'Union[__main__.Foo, __main__.Bar]'
+reveal_type(b + a)  # E: Revealed type is 'Union[__main__.Foo, __main__.Bar]'
+reveal_type(a + c)  # E: Revealed type is '__main__.Bar'
+reveal_type(c + a)  # E: Revealed type is '__main__.Bar'
+
+[case testOperatorDoubleUnionNaiveAdd]
+from typing import Union
+
+class A: pass
+class B: pass
+class C:
+    def __radd__(self, x: A) -> int: pass
+class D:
+    def __radd__(self, x: B) -> str: pass
+
+x: Union[A, B]
+y: Union[C, D]
+
+x + y  # E: Unsupported operand types for + ("A" and "D") \
+       # E: Unsupported operand types for + ("B" and "C") \
+       # N: Both left and right operands are unions
+
+[case testOperatorDoubleUnionInterwovenUnionAdd]
+from typing import Union
+
+class Out1: pass
+class Out2: pass
+class Out3: pass
+class Out4: pass
+
+class A:
+    def __add__(self, x: D) -> Out1: pass
+class B:
+    def __add__(self, x: C) -> Out2: pass
+class C:
+    def __radd__(self, x: A) -> Out3: pass
+class D:
+    def __radd__(self, x: B) -> Out4: pass
+
+x: Union[A, B]
+y: Union[C, D]
+
+reveal_type(x + y)    # E: Revealed type is 'Union[__main__.Out3, __main__.Out1, __main__.Out2, __main__.Out4]'
+reveal_type(A() + y)  # E: Revealed type is 'Union[__main__.Out3, __main__.Out1]'
+reveal_type(B() + y)  # E: Revealed type is 'Union[__main__.Out2, __main__.Out4]'
+reveal_type(x + C())  # E: Revealed type is 'Union[__main__.Out3, __main__.Out2]'
+reveal_type(x + D())  # E: Revealed type is 'Union[__main__.Out1, __main__.Out4]'
+
+[case testOperatorWithInference]
+from typing import TypeVar, Iterable, Union
+
+T = TypeVar('T')
+def sum(x: Iterable[T]) -> Union[T, int]: ...
+
+def len(x: Iterable[T]) -> int: ...
+
+x = [1.1, 2.2, 3.3]
+reveal_type(sum(x))  # E: Revealed type is 'builtins.float*'
+reveal_type(sum(x) / len(x))  # E: Revealed type is 'builtins.float'
+[builtins fixtures/floatdict.pyi]
+
+[case testOperatorWithEmptyListAndSum]
+from typing import TypeVar, Iterable, Union, overload
+
+T = TypeVar('T')
+S = TypeVar('S')
+@overload
+def sum(x: Iterable[T]) -> Union[T, int]: ...
+@overload
+def sum(x: Iterable[T], default: S) -> Union[T, S]: ...
+def sum(*args): pass
+
+x = ["a", "b", "c"]
+reveal_type(x + sum([x, x, x], []))  # E: Revealed type is 'builtins.list[builtins.str*]'
+[builtins fixtures/floatdict.pyi]
+
 [case testAbstractReverseOperatorMethod]
 import typing
 from abc import abstractmethod
diff --git a/test-data/unit/check-generics.test b/test-data/unit/check-generics.test
index 3e9437c6a09f..e6fcda7ae6e2 100644
--- a/test-data/unit/check-generics.test
+++ b/test-data/unit/check-generics.test
@@ -755,7 +755,8 @@ class Node(Generic[T]):
 UNode = Union[int, Node[T]]
 x = 1 # type: UNode[int]
 
-x + 1 # E: Unsupported left operand type for + (some union)
+x + 1 # E: Unsupported left operand type for + ("Node[int]") \
+      # N: Left operand is of type "Union[int, Node[int]]"
 if not isinstance(x, Node):
     x + 1
 
diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test
index 926e05d249da..4c8fa30abe4d 100644
--- a/test-data/unit/check-incremental.test
+++ b/test-data/unit/check-incremental.test
@@ -3515,7 +3515,8 @@ from typing import Optional
 def foo() -> Optional[int]: return 0
 [out1]
 [out2]
-main:3: error: Unsupported operand types for + ("int" and "Optional[int]")
+main:3: error: Unsupported operand types for + ("int" and "None")
+main:3: note: Right operand is of type "Optional[int]"
 
 [case testAttrsIncrementalSubclassingCached]
 from a import A
@@ -4082,7 +4083,8 @@ class Baz:
         return 1
 [out]
 [out2]
-tmp/a.py:3: error: Unsupported operand types for + ("int" and "Optional[int]")
+tmp/a.py:3: error: Unsupported operand types for + ("int" and "None")
+tmp/a.py:3: note: Right operand is of type "Optional[int]"
 
 [case testIncrementalMetaclassUpdate]
 import a
diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test
index a7e08ba9a22b..3e155f25c0b8 100644
--- a/test-data/unit/check-isinstance.test
+++ b/test-data/unit/check-isinstance.test
@@ -183,7 +183,8 @@ def bar() -> None:
     while bool():
         x + 'a'
         while bool():
-            x + 'a'         # E: Unsupported operand types for + (likely involving Union)
+            x + 'a'         # E: Unsupported operand types for + ("int" and "str") \
+                            # N: Left operand is of type "Union[int, str]"
             x = foo()
             if bool():
                 continue
@@ -409,12 +410,16 @@ def f(x: Union[List[int], List[str], int]) -> None:
 
         # type of a?
         reveal_type(x) # E: Revealed type is 'Union[builtins.list[builtins.int], builtins.list[builtins.str]]'
-        x + 1 # E: Unsupported operand types for + (likely involving Union)
+        x + 1 # E: Unsupported operand types for + ("List[int]" and "int") \
+              # E: Unsupported operand types for + ("List[str]" and "int") \
+              # N: Left operand is of type "Union[List[int], List[str]]"
     else:
         x[0] # E: Value of type "int" is not indexable
         x + 1
     x[0] # E: Value of type "Union[List[int], List[str], int]" is not indexable
-    x + 1 # E: Unsupported operand types for + (likely involving Union)
+    x + 1 # E: Unsupported operand types for + ("List[int]" and "int") \
+          # E: Unsupported operand types for + ("List[str]" and "int") \
+          # N: Left operand is of type "Union[List[int], List[str], int]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testUnionListIsinstance2]
@@ -445,7 +450,8 @@ x = foo()
 x = 1
 x = x + 1
 x = foo()
-x = x + 1                # E: Unsupported operand types for + (likely involving Union)
+x = x + 1                # E: Unsupported operand types for + ("str" and "int") \
+                         # N: Left operand is of type "Union[int, str]"
 if isinstance(x, str):
    x = x + 1             # E: Unsupported operand types for + ("str" and "int")
    x = 1
@@ -509,8 +515,10 @@ while bool():
     if isinstance(h.pet, Dog):
         if isinstance(h.pet.paws, str):
             x = h.pet.paws + 'a'
-        y = h.pet.paws + 1   # E: Unsupported operand types for + (likely involving Union)
-        z = h.pet.paws + 'a' # E: Unsupported operand types for + (likely involving Union)
+        y = h.pet.paws + 1   # E: Unsupported operand types for + ("str" and "int") \
+                             # N: Left operand is of type "Union[int, str]"
+        z = h.pet.paws + 'a' # E: Unsupported operand types for + ("int" and "str") \
+                             # N: Left operand is of type "Union[int, str]"
         if isinstance(h.pet.paws, str):
             x = h.pet.paws + 'a'
             break
@@ -652,7 +660,8 @@ def foo() -> None:
             break
     else:
         pass
-    y = x + 'asdad'    # E: Unsupported operand types for + (likely involving Union)
+    y = x + 'asdad'    # E: Unsupported operand types for + ("int" and "str") \
+                       # N: Left operand is of type "Union[int, str]"
 
 foo()
 [builtins fixtures/isinstancelist.pyi]
@@ -669,8 +678,13 @@ while bool():
         x + 'a'
     else:
         x + [1]
-    x + 'a'           # E: Unsupported operand types for + (likely involving Union)
-x + [1]               # E: Unsupported operand types for + (likely involving Union)
+    x + 'a'           # E: Unsupported operand types for + ("int" and "str") \
+                      # E: Unsupported operand types for + ("List[int]" and "str") \
+                      # N: Left operand is of type "Union[int, str, List[int]]"
+
+x + [1]               # E: Unsupported operand types for + ("int" and "List[int]") \
+                      # E: Unsupported operand types for + ("str" and "List[int]") \
+                      # N: Left operand is of type "Union[int, str, List[int]]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testIsInstanceThreeUnion2]
@@ -685,7 +699,9 @@ while bool():
         break
     x + [1]
     x + 'a'           # E: Unsupported operand types for + ("List[int]" and "str")
-x + [1]               # E: Unsupported operand types for + (likely involving Union)
+x + [1]               # E: Unsupported operand types for + ("int" and "List[int]") \
+                      # E: Unsupported operand types for + ("str" and "List[int]") \
+                      # N: Left operand is of type "Union[int, str, List[int]]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testIsInstanceThreeUnion3]
@@ -702,7 +718,9 @@ while bool():
         break
     x + [1]           # These lines aren't reached because x was an int
     x + 'a'
-x + [1]               # E: Unsupported operand types for + (likely involving Union)
+x + [1]               # E: Unsupported operand types for + ("int" and "List[int]") \
+                      # E: Unsupported operand types for + ("str" and "List[int]") \
+                      # N: Left operand is of type "Union[int, str, List[int]]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testRemovingTypeRepeatedly]
@@ -712,24 +730,28 @@ def foo() -> Union[int, str]: pass
 
 for i in [1, 2]:
     x = foo()
-    x + 'a'                 # E: Unsupported operand types for + (likely involving Union)
+    x + 'a'                 # E: Unsupported operand types for + ("int" and "str") \
+                            # N: Left operand is of type "Union[int, str]"
     if isinstance(x, int):
         break
     x + 'a'
 
     x = foo()
-    x + 'a'                 # E: Unsupported operand types for + (likely involving Union)
+    x + 'a'                 # E: Unsupported operand types for + ("int" and "str") \
+                            # N: Left operand is of type "Union[int, str]"
     if isinstance(x, int):
         break
     x + 'a'
 
     x = foo()
-    x + 'a'                 # E: Unsupported operand types for + (likely involving Union)
+    x + 'a'                 # E: Unsupported operand types for + ("int" and "str") \
+                            # N: Left operand is of type "Union[int, str]"
     if isinstance(x, int):
         break
     x + 'a'
 
-x + 'a'                    # E: Unsupported operand types for + (likely involving Union)
+x + 'a'                    # E: Unsupported operand types for + ("int" and "str") \
+                            # N: Left operand is of type "Union[int, str]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testModifyRepeatedly]
@@ -738,8 +760,10 @@ from typing import Union
 def foo() -> Union[int, str]: pass
 
 x = foo()
-x + 1     # E: Unsupported operand types for + (likely involving Union)
-x + 'a'   # E: Unsupported operand types for + (likely involving Union)
+x + 1     # E: Unsupported operand types for + ("str" and "int") \
+          # N: Left operand is of type "Union[int, str]"
+x + 'a'   # E: Unsupported operand types for + ("int" and "str") \
+          # N: Left operand is of type "Union[int, str]"
 
 x = 1
 x + 1
@@ -750,8 +774,10 @@ x + 1     # E: Unsupported operand types for + ("str" and "int")
 x + 'a'
 
 x = foo()
-x + 1     # E: Unsupported operand types for + (likely involving Union)
-x + 'a'   # E: Unsupported operand types for + (likely involving Union)
+x + 1     # E: Unsupported operand types for + ("str" and "int") \
+          # N: Left operand is of type "Union[int, str]"
+x + 'a'   # E: Unsupported operand types for + ("int" and "str") \
+          # N: Left operand is of type "Union[int, str]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testModifyLoop]
@@ -760,14 +786,16 @@ from typing import Union
 def foo() -> Union[int, str]: pass
 
 x = foo()
-x + 1      # E: Unsupported operand types for + (likely involving Union)
+x + 1      # E: Unsupported operand types for + ("str" and "int") \
+           # N: Left operand is of type "Union[int, str]"
 x = 'a'
 x + 1      # E: Unsupported operand types for + ("str" and "int")
 x = 1
 x + 1
 
 while bool():
-      x + 1    # E: Unsupported operand types for + (likely involving Union)
+      x + 1    # E: Unsupported operand types for + ("str" and "int") \
+               # N: Left operand is of type "Union[int, str]"
       x = 'a'
 [builtins fixtures/isinstancelist.pyi]
 
@@ -777,7 +805,8 @@ from typing import Union
 def foo() -> Union[int, str]: pass
 
 x = foo()
-x + 1      # E: Unsupported operand types for + (likely involving Union)
+x + 1      # E: Unsupported operand types for + ("str" and "int") \
+           # N: Left operand is of type "Union[int, str]"
 x = 'a'
 x + 1      # E: Unsupported operand types for + ("str" and "int")
 x = 1
@@ -786,7 +815,8 @@ x + 1
 for i in [1]:
       x = 'a'
 
-x + 1    # E: Unsupported operand types for + (likely involving Union)
+x + 1    # E: Unsupported operand types for + ("str" and "int") \
+         # N: Left operand is of type "Union[int, str]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testModifyLoop3]
@@ -803,7 +833,8 @@ while bool():
     break
 else:
     x + 1
-x + 1      # E: Unsupported operand types for + (likely involving Union)
+x + 1      # E: Unsupported operand types for + ("str" and "int") \
+           # N: Left operand is of type "Union[int, str]"
 x = 1
 for y in [1]:
     x + 1
@@ -811,7 +842,8 @@ for y in [1]:
     break
 else:
     x + 1
-x + 1      # E: Unsupported operand types for + (likely involving Union)
+x + 1      # E: Unsupported operand types for + ("str" and "int") \
+           # N: Left operand is of type "Union[int, str]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testModifyLoopWhile4]
@@ -833,12 +865,14 @@ else:
 x + 'a'
 x = 1
 while bool():
-    x + 1 # E: Unsupported operand types for + (likely involving Union)
+    x + 1 # E: Unsupported operand types for + ("str" and "int") \
+          # N: Left operand is of type "Union[int, str]"
     if bool():
         x = 'a'
         continue
 else:
-    x + 1 # E: Unsupported operand types for + (likely involving Union)
+    x + 1 # E: Unsupported operand types for + ("str" and "int") \
+          # N: Left operand is of type "Union[int, str]"
     x = 'a'
 x + 'a'
 [builtins fixtures/isinstancelist.pyi]
@@ -862,12 +896,14 @@ else:
 x + 'a'
 x = 1
 for y in [1]:
-    x + 1 # E: Unsupported operand types for + (likely involving Union)
+    x + 1 # E: Unsupported operand types for + ("str" and "int") \
+          # N: Left operand is of type "Union[int, str]"
     if bool():
         x = 'a'
         continue
 else:
-    x + 1 # E: Unsupported operand types for + (likely involving Union)
+    x + 1 # E: Unsupported operand types for + ("str" and "int") \
+          # N: Left operand is of type "Union[int, str]"
     x = 'a'
 x + 'a'
 [builtins fixtures/isinstancelist.pyi]
@@ -888,7 +924,8 @@ for y in [1]:
         break
 else:
     x + 1
-x + 1 # E: Unsupported operand types for + (likely involving Union)
+x + 1 # E: Unsupported operand types for + ("str" and "int") \
+      # N: Left operand is of type "Union[int, str]"
 x = 1
 while bool():
     while bool():
@@ -898,7 +935,8 @@ while bool():
         break
 else:
     x + 1
-x + 1 # E: Unsupported operand types for + (likely involving Union)
+x + 1 # E: Unsupported operand types for + ("str" and "int") \
+      # N: Left operand is of type "Union[int, str]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testModifyLoopLong]
@@ -910,8 +948,9 @@ def foo() -> Union[int, str, A]: pass
 
 def bar() -> None:
     x = foo()
-    x + 1  # E: Unsupported left operand type for + (some union) \
-           # E: Unsupported operand types for + (likely involving Union)
+    x + 1  # E: Unsupported operand types for + ("str" and "int") \
+           # E: Unsupported left operand type for + ("A") \
+           # N: Left operand is of type "Union[int, str, A]"
     if isinstance(x, A):
        x.a
     else:
@@ -1375,7 +1414,8 @@ from typing import Union
 
 def f(x: Union[int, str], typ: type) -> None:
     if isinstance(x, (typ, int)):
-        x + 1  # E: Unsupported operand types for + (likely involving Union)
+        x + 1  # E: Unsupported operand types for + ("str" and "int") \
+               # N: Left operand is of type "Union[int, str]"
         reveal_type(x)  # E: Revealed type is 'Union[builtins.int, builtins.str]'
     else:
         reveal_type(x)  # E: Revealed type is 'builtins.str'
diff --git a/test-data/unit/check-optional.test b/test-data/unit/check-optional.test
index 55345d11b996..9b1c7a694713 100644
--- a/test-data/unit/check-optional.test
+++ b/test-data/unit/check-optional.test
@@ -127,7 +127,8 @@ f(None)
 
 [case testInferOptionalFromDefaultNone]
 def f(x: int = None) -> None:
-  x + 1  # E: Unsupported left operand type for + (some union)
+  x + 1  # E: Unsupported left operand type for + ("None") \
+         # N: Left operand is of type "Optional[int]"
 f(None)
 [out]
 
@@ -140,7 +141,8 @@ def f(x: int = None) -> None:  # E: Incompatible default for argument "x" (defau
 [case testInferOptionalFromDefaultNoneComment]
 def f(x=None):
   # type: (int) -> None
-  x + 1  # E: Unsupported left operand type for + (some union)
+  x + 1  # E: Unsupported left operand type for + ("None") \
+         # N: Left operand is of type "Optional[int]"
 f(None)
 [out]
 
@@ -437,7 +439,8 @@ from typing import Optional
 x = None  # type: Optional[int]
 x + 1
 [out]
-tmp/a.py:3: error: Unsupported left operand type for + (some union)
+tmp/a.py:3: error: Unsupported left operand type for + ("None")
+tmp/a.py:3: note: Left operand is of type "Optional[str]"
 
 [case testNoneContextInference]
 from typing import Dict, List
diff --git a/test-data/unit/check-unions.test b/test-data/unit/check-unions.test
index 799dea119fc3..1cb274c2c4a8 100644
--- a/test-data/unit/check-unions.test
+++ b/test-data/unit/check-unions.test
@@ -98,7 +98,8 @@ i = y.foo()   # E: Incompatible types in assignment (expression has type "Union[
 from typing import Union, List
 x = None # type: Union[List[int], str]
 x[2]
-x[2] + 1 # E: Unsupported operand types for + (likely involving Union)
+x[2] + 1 # E: Unsupported operand types for + ("str" and "int") \
+         # N: Left operand is of type "Union[int, str]"
 [builtins fixtures/isinstancelist.pyi]
 
 [case testUnionAsOverloadArg]
@@ -289,8 +290,10 @@ reveal_type(u('', 1, 1))  # E: Revealed type is 'Union[builtins.int*, builtins.s
 from typing import Union
 class A: pass
 def f(x: Union[int, str, A]):
-    x + object() # E: Unsupported left operand type for + (some union) \
-        # E: Unsupported operand types for + (likely involving Union)
+    x + object() # E: Unsupported operand types for + ("int" and "object") \
+                 # E: Unsupported operand types for + ("str" and "object") \
+                 # E: Unsupported left operand type for + ("A") \
+                 # N: Left operand is of type "Union[int, str, A]"
 
 [case testNarrowingDownNamedTupleUnion]
 from typing import NamedTuple, Union
diff --git a/test-data/unit/fixtures/floatdict.pyi b/test-data/unit/fixtures/floatdict.pyi
index 54850d7f6e27..7db0c77b9058 100644
--- a/test-data/unit/fixtures/floatdict.pyi
+++ b/test-data/unit/fixtures/floatdict.pyi
@@ -55,9 +55,13 @@ class int:
     def __int__(self) -> int: ...
     def __mul__(self, x: int) -> int: ...
     def __rmul__(self, x: int) -> int: ...
+    def __truediv__(self, x: int) -> int: ...
+    def __rtruediv__(self, x: int) -> int: ...
 
 class float:
     def __float__(self) -> float: ...
     def __int__(self) -> int: ...
     def __mul__(self, x: float) -> float: ...
     def __rmul__(self, x: float) -> float: ...
+    def __truediv__(self, x: float) -> float: ...
+    def __rtruediv__(self, x: float) -> float: ...
diff --git a/test-data/unit/fixtures/ops.pyi b/test-data/unit/fixtures/ops.pyi
index 8f0eb8b2fcce..f6a85e2166b1 100644
--- a/test-data/unit/fixtures/ops.pyi
+++ b/test-data/unit/fixtures/ops.pyi
@@ -36,6 +36,7 @@ class unicode: pass
 
 class int:
     def __add__(self, x: 'int') -> 'int': pass
+    def __radd__(self, x: 'int') -> 'int': pass
     def __sub__(self, x: 'int') -> 'int': pass
     def __mul__(self, x: 'int') -> 'int': pass
     def __mod__(self, x: 'int') -> 'int': pass
@@ -50,7 +51,9 @@ class int:
     def __gt__(self, x: 'int') -> bool: pass
     def __ge__(self, x: 'int') -> bool: pass
 
-class float: pass
+class float:
+    def __add__(self, x: 'float') -> 'float': pass
+    def __radd__(self, x: 'float') -> 'float': pass
 
 class BaseException: pass
 


Code review comments:
diff_hunk: @@ -2025,22 +2018,89 @@ def lookup_definer(typ: Instance, attr_name: str) -> Optional[str]:
             result = error_any, error_any
             return result
 
-    def check_op(self, method: str, base_type: Type, arg: Expression,
-                 context: Context,
+    def check_op(self, method: str, base_type: Type,
+                 arg: Expression, context: Context,
                  allow_reverse: bool = False) -> Tuple[Type, Type]:
         """Type check a binary operation which maps to a method call.
 
         Return tuple (result type, inferred operator method type).
         """
 
         if allow_reverse:
-            return self.check_op_reversible(
-                op_name=method,
-                left_type=base_type,
-                left_expr=TempNode(base_type),
-                right_type=self.accept(arg),
-                right_expr=arg,
-                context=context)
+            left_variants = [base_type]
+            if isinstance(base_type, UnionType):
+                left_variants = [item for item in base_type.relevant_items()]
+            right_type = self.accept(arg)
+
+            # Step 1: We first try leaving the right arguments alone and destructure
+            # just the left ones. (Mypy can sometimes perform some more precise inference
+            # if we leave the right operands a union -- see testOperatorWithEmptyListAndSum.
+            msg = self.msg.clean_copy()
+            msg.disable_count = 0
+            all_results = []
+            all_inferred = []
+
+            for left_possible_type in left_variants:
+                result, inferred = self.check_op_reversible(
+                    op_name=method,
+                    left_type=left_possible_type,
+                    left_expr=TempNode(left_possible_type),
+                    right_type=right_type,
+                    right_expr=arg,
+                    context=context,
+                    msg=msg)
+                all_results.append(result)
+                all_inferred.append(inferred)
+
+            if not msg.is_errors():
+                results_final = UnionType.make_simplified_union(all_results)
+                inferred_final = UnionType.make_simplified_union(all_inferred)
+                return results_final, inferred_final
+
+            # Step 2: If that fails, we try again but also destructure the right argument.
+            # This is also necessary to make certain edge cases work -- see
+            # testOperatorDoubleUnionInterwovenUnionAdd, for example.
+
+            # Note: We want to pass in the original 'arg' for 'left_expr' and 'right_expr'
+            # whenever possible so that plugins and similar things can introspect on the original
+            # node if possible.
+            #
+            # We don't do the same for the base expression because it could lead to weird
+            # type inference errors -- e.g. see 'testOperatorDoubleUnionSum'.
+            right_variants = [(right_type, arg)]
+            if isinstance(right_type, UnionType):
+                right_variants = [(item, TempNode(item)) for item in right_type.relevant_items()]
+
+            msg = self.msg.clean_copy()
+            msg.disable_count = 0
+            all_results = []
+            all_inferred = []
+
+            for left_possible_type in left_variants:
+                for right_possible_type, right_expr in right_variants:
+                    result, inferred = self.check_op_reversible(
+                        op_name=method,
+                        left_type=left_possible_type,
+                        left_expr=TempNode(left_possible_type),
+                        right_type=right_possible_type,
+                        right_expr=right_expr,
+                        context=context,
+                        msg=msg)
+                    all_results.append(result)
+                    all_inferred.append(inferred)
+
+            if msg.is_errors():
+                self.msg.add_errors(msg)
+                if len(left_variants) >= 2 and len(right_variants) >= 2:
+                    self.msg.warn_both_operands_are_from_unions(context)
+                elif len(left_variants) >= 2:
+                    self.msg.warn_operand_was_from_union("Left", base_type, context)
+                elif len(right_variants) >= 2:
+                    self.msg.warn_operand_was_from_union("Right", right_type, context)
+
+            results_final = UnionType.make_simplified_union(all_results)
+            inferred_final = UnionType.make_simplified_union(all_inferred)
path: mypy/checkexpr.py
text: @user1:
I think this _may_ be technically not true (although I don't think it is ever used currently). See for example this comment:
```python
                    # Note that we use `union_overload_matches` instead of just returning
                    # a union of inferred callables because for example a call
                    # Union[int -> int, str -> str](Union[int, str]) is invalid and
                    # we don't want to introduce internal inconsistencies.
```

@author:
I switched over to using `union_overload_matches` btw (and renamed the method, since we're now using it for things other than overloads).