From 92cda7bace5932997f5d87d5e56dc6f2163f10a5 Mon Sep 17 00:00:00 2001 From: PGray Date: Mon, 30 Mar 2026 12:54:29 +0100 Subject: [PATCH 1/2] Check element type comparability for tuple ordering comparisons When comparing tuples with ordering operators (<, >, <=, >=), mypy now verifies that the element types actually support the comparison operator. Previously, due to tuple's covariant TypeVar, the reverse operator could pass at the type level even when element types don't support the ordering comparison at runtime. For example, tuple[object, ...].__gt__ would accept any tuple argument via covariance, but object doesn't define __gt__. The fix adds element-level validation after the tuple-level check succeeds: if the element types can't be compared with the same operator, an 'Unsupported operand types' error is reported. Fixes #21042 --- mypy/checkexpr.py | 57 ++++++++++++++++++++++++++++++++ test-data/unit/check-tuples.test | 56 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 49fc1159856f7..bcd23f61bc794 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3706,6 +3706,17 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type: right_type = try_getting_literal(right_type) self.msg.dangerous_comparison(left_type, right_type, "equality", e) + # For ordering comparisons on tuples, verify that element types + # actually support the comparison. The tuple stubs use a covariant + # TypeVar which can allow the reverse operator to pass even when + # element types don't support the comparison at runtime. + if not w.has_new_errors() and operator in ("<", ">", "<=", ">="): + right_type = self.accept(right) + if not self.chk.can_skip_diagnostics: + self._check_tuple_element_comparison( + operator, left_type, right_type, e + ) + elif operator == "is" or operator == "is not": right_type = self.accept(right) # validate the right operand sub_result = self.bool_type() @@ -3876,6 +3887,52 @@ def dangerous_comparison( return False return not is_overlapping_types(left, right, ignore_promotions=False) + def _check_tuple_element_comparison( + self, + operator: str, + left_type: Type, + right_type: Type, + context: Context, + ) -> None: + """Check that tuple element types support an ordering comparison. + + Tuple comparisons are element-wise at runtime, but the typeshed stubs + use a covariant TypeVar which can allow comparisons to pass at the type + level even when element types don't support the operator. + """ + left_proper = get_proper_type(left_type) + right_proper = get_proper_type(right_type) + + left_elem = self._get_tuple_item_type(left_proper) + right_elem = self._get_tuple_item_type(right_proper) + + if left_elem is None or right_elem is None: + return + + # Skip check if either element type is Any + left_elem_proper = get_proper_type(left_elem) + right_elem_proper = get_proper_type(right_elem) + if isinstance(left_elem_proper, AnyType) or isinstance(right_elem_proper, AnyType): + return + + method = operators.op_methods[operator] + with self.msg.filter_errors() as w: + self.check_op( + method, + left_elem, + TempNode(right_elem, context=context), + context, + allow_reverse=True, + ) + if w.has_new_errors(): + self.msg.unsupported_operand_types(operator, left_type, right_type, context) + + def _get_tuple_item_type(self, typ: ProperType) -> Type | None: + """Get the element type of a homogeneous tuple type, or None if not applicable.""" + if isinstance(typ, Instance) and typ.type.fullname == "builtins.tuple": + return typ.args[0] if typ.args else None + return None + def check_method_call_by_name( self, method: str, diff --git a/test-data/unit/check-tuples.test b/test-data/unit/check-tuples.test index 9653d9d037ce6..d52dd7a4a6acd 100644 --- a/test-data/unit/check-tuples.test +++ b/test-data/unit/check-tuples.test @@ -1838,3 +1838,59 @@ from typing_extensions import Concatenate def c(t: tuple[Concatenate[int, ...]]) -> None: # E: Cannot use "[int, VarArg(Any), KwArg(Any)]" for tuple, only for ParamSpec reveal_type(t) # N: Revealed type is "tuple[Any]" [builtins fixtures/tuple.pyi] + +[case testTupleComparisonNonComparableElements] +from typing import Tuple +a: Tuple[object, ...] +b: Tuple[object, ...] +c = a < b # E: Unsupported operand types for < ("tuple[object, ...]" and "tuple[object, ...]") +d = a > b # E: Unsupported operand types for > ("tuple[object, ...]" and "tuple[object, ...]") +e = a <= b # E: Unsupported operand types for <= ("tuple[object, ...]" and "tuple[object, ...]") +f = a >= b # E: Unsupported operand types for >= ("tuple[object, ...]" and "tuple[object, ...]") +[builtins fixtures/ops.pyi] + +[case testTupleComparisonComparableElements] +from typing import Tuple +a: Tuple[int, ...] +b: Tuple[int, ...] +c = a < b +d = a > b +e = a <= b +f = a >= b +[builtins fixtures/ops.pyi] + +[case testTupleComparisonOnlyGT] +from typing import Tuple, Any + +class OnlyGT: + def __gt__(self, other: 'OnlyGT') -> bool: ... + +a: Tuple[OnlyGT, ...] +b: Tuple[object, ...] +c = a < b # E: Unsupported operand types for < ("tuple[OnlyGT, ...]" and "tuple[object, ...]") +[builtins fixtures/ops.pyi] + +[case testTupleComparisonOnlyGTValid] +from typing import Tuple, Any + +class OnlyGT: + def __gt__(self, other: 'OnlyGT') -> bool: ... + +a: Tuple[OnlyGT, ...] +b: Tuple[OnlyGT, ...] +c = a > b +[builtins fixtures/ops.pyi] + +[case testTupleComparisonCovariantReverse] +# Regression test for https://github.com/python/mypy/issues/21042 +from typing import Tuple, Any + +class OnlyGT: + def __gt__(self, other: Any) -> bool: ... + +a: Tuple[OnlyGT, ...] +b: Tuple[object, ...] +# Tuple-level reverse op (tuple[object, ...].__gt__) passes due to covariance, +# but element types don't support < +c = a < b # E: Unsupported operand types for < ("tuple[OnlyGT, ...]" and "tuple[object, ...]") +[builtins fixtures/ops.pyi] From 8df61eda7696a5d8165b4a6a12adab682e35f2e1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:12:37 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checkexpr.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index bcd23f61bc794..9031bdb624aca 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3713,9 +3713,7 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type: if not w.has_new_errors() and operator in ("<", ">", "<=", ">="): right_type = self.accept(right) if not self.chk.can_skip_diagnostics: - self._check_tuple_element_comparison( - operator, left_type, right_type, e - ) + self._check_tuple_element_comparison(operator, left_type, right_type, e) elif operator == "is" or operator == "is not": right_type = self.accept(right) # validate the right operand @@ -3888,11 +3886,7 @@ def dangerous_comparison( return not is_overlapping_types(left, right, ignore_promotions=False) def _check_tuple_element_comparison( - self, - operator: str, - left_type: Type, - right_type: Type, - context: Context, + self, operator: str, left_type: Type, right_type: Type, context: Context ) -> None: """Check that tuple element types support an ordering comparison.