From 825cf42e60d66cfcb040e7a249e45b601115ad2b Mon Sep 17 00:00:00 2001 From: xh123637 <3324733895@qq.com> Date: Fri, 6 Nov 2026 14:25:20 +0800 Subject: [PATCH] Fix flipped operator in comparison error message When the left operand lacks the forward comparison method, the error was rendered from the reflected method name (e.g. `x >= 0` reported ` <= ` instead of ` >= `); report the operator as written, with operands in source order. Fixes #22316. --- mypy/checkexpr.py | 6 +++- mypy/messages.py | 46 +++++++++++++++++++++++++- test-data/unit/check-expressions.test | 22 +++++++++++- test-data/unit/check-incremental.test | 24 +++++++------- test-data/unit/check-plugin-attrs.test | 8 ++--- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index e0e10e8b890d..49219440e50a 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -4368,7 +4368,11 @@ def check_op_reversible( results = [] for name, method, obj, arg in variants: with self.msg.filter_errors(save_filtered_errors=True) as local_errors: - result = self.check_method_call(name, obj, method, [arg], [ARG_POS], context) + # Track the operator as written, so that errors from a reflected + # call (e.g. `x >= 0` checked as `int.__le__(x)`) report the + # source operator instead of the reflected one. See #22316. + with self.msg.operator_context(op_name): + result = self.check_method_call(name, obj, method, [arg], [ARG_POS], context) if local_errors.has_new_errors(): errors.append(local_errors.filtered_errors()) results.append(result) diff --git a/mypy/messages.py b/mypy/messages.py index 1ef5dea68e2b..e7b750519620 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -58,7 +58,7 @@ get_func_def, reverse_builtin_aliases, ) -from mypy.operators import op_methods, op_methods_to_symbols +from mypy.operators import op_methods, op_methods_to_symbols, reverse_op_methods from mypy.options import Options from mypy.subtypes import ( IS_CLASS_OR_STATIC, @@ -180,11 +180,17 @@ class MessageBuilder: # Hack to deduplicate error messages from union types _disable_type_names: list[bool] + # Stack of operator method names for binary operator checks currently + # in progress (see check_op_reversible). The top of the stack is the + # operator as written in the source code, e.g. "__ge__" for `x >= 0`. + _operator_context: list[str] + def __init__(self, errors: Errors, modules: dict[str, MypyFile]) -> None: self.errors = errors self.options = errors.options self.modules = modules self._disable_type_names = [] + self._operator_context = [] # # Helpers @@ -219,6 +225,22 @@ def disable_type_names(self) -> Iterator[None]: finally: self._disable_type_names.pop() + @contextmanager + def operator_context(self, op_name: str) -> Iterator[None]: + """Track the operator currently being checked, for error reporting. + + When a binary operation is checked via the reflected method on the + other operand (e.g. `x >= 0` checked as `int.__le__(x)` because the + left operand has no `__ge__`), errors about the call would otherwise + name the reflected operator. This context lets incompatible_argument() + report the operator as written in the source code instead. See #22316. + """ + self._operator_context.append(op_name) + try: + yield + finally: + self._operator_context.pop() + def are_type_names_disabled(self) -> bool: return len(self._disable_type_names) > 0 and self._disable_type_names[-1] @@ -643,6 +665,28 @@ def incompatible_argument( ) return self.fail(error_msg.value, context, code=error_msg.code) elif name.startswith('"__'): + # If this comparison is being checked via the reflected method + # on the other operand (e.g. `x >= 0` failing as `int.__le__(x)` + # because the left operand has no `__ge__`), report the operator + # as written in the source code, with operands in source order, + # instead of the reflected operator. See #22316. + if self._operator_context: + source_op = self._operator_context[-1] + if source_op in ("__lt__", "__le__", "__gt__", "__ge__"): + for method in (source_op, reverse_op_methods[source_op]): + if name.startswith(f'"{method}" of'): + op = op_methods_to_symbols[source_op] + if method == source_op: + return self.unsupported_operand_types( + op, base, arg_type, context, code=codes.OPERATOR + ) + else: + # Reflected call: the argument is the source + # left operand and the base is the source + # right operand. + return self.unsupported_operand_types( + op, arg_type, base, context, code=codes.OPERATOR + ) for method, op in op_methods_to_symbols.items(): for variant in method, "__r" + method[2:]: # FIX: do not rely on textual formatting diff --git a/test-data/unit/check-expressions.test b/test-data/unit/check-expressions.test index 1bd955b6b732..4ff6d0df6c59 100644 --- a/test-data/unit/check-expressions.test +++ b/test-data/unit/check-expressions.test @@ -723,7 +723,27 @@ if int(): s = A() < B() if int(): n = A() < B() # E: Incompatible types in assignment (expression has type "str", variable has type "int") - s = object() < B() # E: Unsupported operand types for > ("B" and "object") + s = object() < B() # E: Unsupported operand types for < ("object" and "B") + +[case testComparisonOperatorMessageWithNone] +# Regression test for #22316: the error for a comparison must name the +# operator as written, with operands in source order, instead of the +# reflected operator used to check it. +def f(x: int | None) -> bool: + return x >= 0 # E: Unsupported operand types for >= ("None" and "int") \ + # N: Left operand is of type "int | None" +def g(x: int | None) -> bool: + return x <= 0 # E: Unsupported operand types for <= ("None" and "int") \ + # N: Left operand is of type "int | None" +def h(x: int | None) -> bool: + return x > 0 # E: Unsupported operand types for > ("None" and "int") \ + # N: Left operand is of type "int | None" +def k(x: int | None) -> bool: + return x < 0 # E: Unsupported operand types for < ("None" and "int") \ + # N: Left operand is of type "int | None" +def ok(x: int, s: str) -> bool: + return x >= s # E: Unsupported operand types for >= ("int" and "str") +[builtins fixtures/ops.pyi] [case testReversibleComparisonWithExtraArgument] class C: diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index cf1369691f08..88c8b6b57170 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -3097,10 +3097,10 @@ A(1) >= 1 # E: Unsupported operand types for >= ("A" and "int") A(1) == 1 A(1) != 1 -1 < A(1) # E: Unsupported operand types for > ("A" and "int") -1 <= A(1) # E: Unsupported operand types for >= ("A" and "int") -1 > A(1) # E: Unsupported operand types for < ("A" and "int") -1 >= A(1) # E: Unsupported operand types for <= ("A" and "int") +1 < A(1) # E: Unsupported operand types for < ("int" and "A") +1 <= A(1) # E: Unsupported operand types for <= ("int" and "A") +1 > A(1) # E: Unsupported operand types for > ("int" and "A") +1 >= A(1) # E: Unsupported operand types for >= ("int" and "A") 1 == A(1) 1 != A(1) @@ -3123,10 +3123,10 @@ main:15: error: Unsupported operand types for < ("A" and "int") main:16: error: Unsupported operand types for <= ("A" and "int") main:17: error: Unsupported operand types for > ("A" and "int") main:18: error: Unsupported operand types for >= ("A" and "int") -main:22: error: Unsupported operand types for > ("A" and "int") -main:23: error: Unsupported operand types for >= ("A" and "int") -main:24: error: Unsupported operand types for < ("A" and "int") -main:25: error: Unsupported operand types for <= ("A" and "int") +main:22: error: Unsupported operand types for < ("int" and "A") +main:23: error: Unsupported operand types for <= ("int" and "A") +main:24: error: Unsupported operand types for > ("int" and "A") +main:25: error: Unsupported operand types for >= ("int" and "A") [case testAttrsIncrementalSubclassModified] from b import B @@ -4010,10 +4010,10 @@ tmp/b.py:18: error: Unsupported operand types for < ("A" and "int") tmp/b.py:19: error: Unsupported operand types for <= ("A" and "int") tmp/b.py:20: error: Unsupported operand types for > ("A" and "int") tmp/b.py:21: error: Unsupported operand types for >= ("A" and "int") -tmp/b.py:25: error: Unsupported operand types for > ("A" and "int") -tmp/b.py:26: error: Unsupported operand types for >= ("A" and "int") -tmp/b.py:27: error: Unsupported operand types for < ("A" and "int") -tmp/b.py:28: error: Unsupported operand types for <= ("A" and "int") +tmp/b.py:25: error: Unsupported operand types for < ("int" and "A") +tmp/b.py:26: error: Unsupported operand types for <= ("int" and "A") +tmp/b.py:27: error: Unsupported operand types for > ("int" and "A") +tmp/b.py:28: error: Unsupported operand types for >= ("int" and "A") [case testIncrementalDataclassesSubclassModified] from b import B diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index 7d453dcf84d0..b9baf6f7ad9d 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -204,10 +204,10 @@ A(1) >= 1 # E: Unsupported operand types for >= ("A" and "int") A(1) == 1 A(1) != 1 -1 < A(1) # E: Unsupported operand types for > ("A" and "int") -1 <= A(1) # E: Unsupported operand types for >= ("A" and "int") -1 > A(1) # E: Unsupported operand types for < ("A" and "int") -1 >= A(1) # E: Unsupported operand types for <= ("A" and "int") +1 < A(1) # E: Unsupported operand types for < ("int" and "A") +1 <= A(1) # E: Unsupported operand types for <= ("int" and "A") +1 > A(1) # E: Unsupported operand types for > ("int" and "A") +1 >= A(1) # E: Unsupported operand types for >= ("int" and "A") 1 == A(1) 1 != A(1) [builtins fixtures/plugin_attrs.pyi]