Skip to content

Commit 08fe611

Browse files
committed
[ty] Fix unary and comparison operators for TypeVars with union bounds
This fixes an issue where comparison and unary operators were incorrectly flagged as unsupported for TypeVars with union bounds (e.g., `bound=float` which becomes `int | float`). Fixes astral-sh/ty#2652
1 parent 7c826af commit 08fe611

3 files changed

Lines changed: 240 additions & 2 deletions

File tree

crates/ty_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,95 @@ import b
412412
# error: [unsupported-operator] "Operator `<` is not supported between objects of type `a.Foo` and `b.Foo`"
413413
a.Foo() < b.Foo()
414414
```
415+
416+
## TypeVar Comparisons
417+
418+
TypeVars with bounds support comparison operations if the bound type supports them.
419+
420+
### TypeVar with `float` bound
421+
422+
Since `float` is treated as `int | float` in type annotations, TypeVars bounded by `float` should
423+
support all comparison operations that both `int` and `float` support:
424+
425+
```py
426+
from typing import TypeVar, Generic
427+
428+
T = TypeVar("T", bound=float)
429+
430+
class Range(Generic[T]):
431+
min: T
432+
max: T
433+
434+
def __init__(self, min: T, max: T) -> None:
435+
self.min = min
436+
self.max = max
437+
438+
def __contains__(self, value: T) -> bool:
439+
return self.min <= value <= self.max
440+
441+
def compare_float_bound(a: T, b: T) -> bool:
442+
return a <= b
443+
444+
def compare_with_literal(a: T) -> bool:
445+
return a <= 1.0
446+
```
447+
448+
### TypeVar with `int` bound
449+
450+
TypeVars bounded by `int` should support comparison operations:
451+
452+
```py
453+
from typing import TypeVar
454+
455+
U = TypeVar("U", bound=int)
456+
457+
def compare_int_bound(a: U, b: U) -> bool:
458+
return a <= b
459+
```
460+
461+
### TypeVar with `str` bound
462+
463+
TypeVars bounded by `str` should support comparison operations:
464+
465+
```py
466+
from typing import TypeVar
467+
468+
V = TypeVar("V", bound=str)
469+
470+
def compare_str_bound(a: V, b: V) -> bool:
471+
return a <= b
472+
```
473+
474+
### Constrained TypeVar comparisons
475+
476+
Constrained TypeVars support comparisons if all constraints support the operation:
477+
478+
```py
479+
from typing import TypeVar
480+
481+
W = TypeVar("W", int, str)
482+
483+
def compare_constrained(a: W, b: W) -> bool:
484+
# Both int and str support ==
485+
return a == b
486+
487+
X = TypeVar("X", int, str)
488+
489+
def compare_constrained_lt(a: X, b: X) -> bool:
490+
# Both int and str support <
491+
return a < b
492+
```
493+
494+
### TypeVar with `complex` bound
495+
496+
`complex` is treated as `int | float | complex`. Since `complex` doesn't support ordering
497+
comparisons like `<` and `<=`, only equality comparisons should work:
498+
499+
```py
500+
from typing import TypeVar
501+
502+
Y = TypeVar("Y", bound=complex)
503+
504+
def compare_complex_eq(a: Y, b: Y) -> bool:
505+
return a == b
506+
```

crates/ty_python_semantic/resources/mdtest/unary/invert_add_usub.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,59 @@ b = NoDunder()
3131
-b # error: [unsupported-operator] "Unary operator `-` is not supported for object of type `NoDunder`"
3232
~b # error: [unsupported-operator] "Unary operator `~` is not supported for object of type `NoDunder`"
3333
```
34+
35+
## TypeVar with bounds
36+
37+
TypeVars with bounds support unary operators if the bound type supports them.
38+
39+
### TypeVar with `float` bound
40+
41+
Since `float` is treated as `int | float` in type annotations, TypeVars bounded by `float` should
42+
support unary `+` and `-` operators:
43+
44+
```py
45+
from typing import TypeVar
46+
47+
T = TypeVar("T", bound=float)
48+
49+
def neg_float_bound(a: T) -> float:
50+
reveal_type(-a) # revealed: int | float
51+
return -a
52+
53+
def pos_float_bound(a: T) -> float:
54+
reveal_type(+a) # revealed: int | float
55+
return +a
56+
```
57+
58+
### TypeVar with `int` bound
59+
60+
TypeVars bounded by `int` should support all unary numeric operators:
61+
62+
```py
63+
from typing import TypeVar
64+
65+
U = TypeVar("U", bound=int)
66+
67+
def neg_int_bound(a: U) -> int:
68+
reveal_type(-a) # revealed: int
69+
return -a
70+
71+
def invert_int_bound(a: U) -> int:
72+
reveal_type(~a) # revealed: int
73+
return ~a
74+
```
75+
76+
### Constrained TypeVar
77+
78+
Constrained TypeVars support unary operators if all constraints support them. When the operator
79+
returns the same type for each constraint (e.g., `-int -> int`), the TypeVar is preserved:
80+
81+
```py
82+
from typing import TypeVar
83+
84+
V = TypeVar("V", int, float)
85+
86+
def neg_constrained(a: V) -> V:
87+
reveal_type(-a) # revealed: V@neg_constrained
88+
return -a
89+
```

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12573,8 +12573,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
1257312573
}
1257412574
}
1257512575
}
12576-
// For bounded TypeVars or unconstrained TypeVars, fall through to default handling.
12577-
_ => match operand_type.try_call_dunder(
12576+
// For bounded TypeVars with union bounds (like `bound=float` which becomes
12577+
// `int | float`), we need to delegate to the bound type. The `self` types of
12578+
// the dunder methods in typeshed don't match because they don't get the same
12579+
// `int | float` special treatment.
12580+
Some(TypeVarBoundOrConstraints::UpperBound(bound)) => {
12581+
self.infer_unary_expression_type(op, bound, unary)
12582+
}
12583+
// For unconstrained TypeVars, fall through to default handling.
12584+
None => match operand_type.try_call_dunder(
1257812585
self.db(),
1257912586
unary_dunder_method,
1258012587
CallArguments::none(),
@@ -13786,6 +13793,89 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
1378613793
}),
1378713794
),
1378813795

13796+
// Similar to `NewType`s, `TypeVar`s with union bounds (like `bound=float` which becomes
13797+
// `int | float`) need to delegate to the bound type. The `self` types of the dunder
13798+
// methods in typeshed don't match because they don't get the same `int | float` special
13799+
// treatment. In those cases we need to explicitly delegate to the bound type, so that
13800+
// it hits the `Type::Union` branches above.
13801+
//
13802+
// When both operands are the same bounded TypeVar, we check the comparison on the bound
13803+
// type paired with itself.
13804+
(Type::TypeVar(left_tvar), Type::TypeVar(right_tvar))
13805+
if left_tvar.identity(self.db()) == right_tvar.identity(self.db()) =>
13806+
{
13807+
match left_tvar.typevar(self.db()).bound_or_constraints(self.db()) {
13808+
Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some(
13809+
try_dunder(self, MemberLookupPolicy::default()).or_else(|_| {
13810+
visitor.visit((left, op, right), || {
13811+
self.infer_binary_type_comparison(bound, op, bound, range, visitor)
13812+
})
13813+
}),
13814+
),
13815+
Some(TypeVarBoundOrConstraints::Constraints(constraints)) => {
13816+
// For constrained TypeVars, check each constraint paired with itself.
13817+
let mut builder = UnionBuilder::new(self.db());
13818+
for &constraint in constraints.elements(self.db()) {
13819+
builder = builder.add(self.infer_binary_type_comparison(
13820+
constraint,
13821+
op,
13822+
constraint,
13823+
range,
13824+
visitor,
13825+
)?);
13826+
}
13827+
Some(Ok(builder.build()))
13828+
}
13829+
None => None, // Fall through to default handling
13830+
}
13831+
}
13832+
// When the left operand is a bounded TypeVar and the right is not a TypeVar,
13833+
// delegate to the bound type.
13834+
(Type::TypeVar(left_tvar), right) if !right.is_type_var() => {
13835+
match left_tvar.typevar(self.db()).bound_or_constraints(self.db()) {
13836+
Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some(
13837+
try_dunder(self, MemberLookupPolicy::default()).or_else(|_| {
13838+
visitor.visit((left, op, right), || {
13839+
self.infer_binary_type_comparison(bound, op, right, range, visitor)
13840+
})
13841+
}),
13842+
),
13843+
Some(TypeVarBoundOrConstraints::Constraints(constraints)) => {
13844+
let mut builder = UnionBuilder::new(self.db());
13845+
for &constraint in constraints.elements(self.db()) {
13846+
builder = builder.add(self.infer_binary_type_comparison(
13847+
constraint, op, right, range, visitor,
13848+
)?);
13849+
}
13850+
Some(Ok(builder.build()))
13851+
}
13852+
None => None,
13853+
}
13854+
}
13855+
// When the right operand is a bounded TypeVar and the left is not a TypeVar,
13856+
// delegate to the bound type.
13857+
(left, Type::TypeVar(right_tvar)) if !left.is_type_var() => {
13858+
match right_tvar.typevar(self.db()).bound_or_constraints(self.db()) {
13859+
Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some(
13860+
try_dunder(self, MemberLookupPolicy::default()).or_else(|_| {
13861+
visitor.visit((left, op, right), || {
13862+
self.infer_binary_type_comparison(left, op, bound, range, visitor)
13863+
})
13864+
}),
13865+
),
13866+
Some(TypeVarBoundOrConstraints::Constraints(constraints)) => {
13867+
let mut builder = UnionBuilder::new(self.db());
13868+
for &constraint in constraints.elements(self.db()) {
13869+
builder = builder.add(self.infer_binary_type_comparison(
13870+
left, op, constraint, range, visitor,
13871+
)?);
13872+
}
13873+
Some(Ok(builder.build()))
13874+
}
13875+
None => None,
13876+
}
13877+
}
13878+
1378913879
(Type::IntLiteral(n), Type::IntLiteral(m)) => Some(match op {
1379013880
ast::CmpOp::Eq => Ok(Type::BooleanLiteral(n == m)),
1379113881
ast::CmpOp::NotEq => Ok(Type::BooleanLiteral(n != m)),

0 commit comments

Comments
 (0)