[ty] Fix binary intersection comparison inference logic (#18266)

## Summary

Resolves https://github.com/astral-sh/ty/issues/485.

`infer_binary_intersection_type_comparison()` now checks for all
positive members before concluding that an operation is unsupported for
a given intersection type.

## Test Plan

Markdown tests.

---------

Co-authored-by: David Peter <mail@david-peter.de>
This commit is contained in:
InSync 2025-05-23 17:55:17 +07:00 committed by GitHub
parent 6392dccd24
commit a1399656c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 95 additions and 25 deletions

View file

@ -109,23 +109,50 @@ def _(o: object):
### Unsupported operators for positive contributions
Raise an error if any of the positive contributions to the intersection type are unsupported for the
given operator:
Raise an error if the given operator is unsupported for all positive contributions to the
intersection type:
```py
class NonContainer1: ...
class NonContainer2: ...
def _(x: object):
if isinstance(x, NonContainer1):
if isinstance(x, NonContainer2):
reveal_type(x) # revealed: NonContainer1 & NonContainer2
# error: [unsupported-operator] "Operator `in` is not supported for types `int` and `NonContainer1`"
reveal_type(2 in x) # revealed: bool
```
Do not raise an error if at least one of the positive contributions to the intersection type support
the operator:
```py
class Container:
def __contains__(self, x) -> bool:
return False
class NonContainer: ...
def _(x: object):
if isinstance(x, Container):
if isinstance(x, NonContainer):
reveal_type(x) # revealed: Container & NonContainer
if isinstance(x, NonContainer1):
if isinstance(x, Container):
if isinstance(x, NonContainer2):
reveal_type(x) # revealed: NonContainer1 & Container & NonContainer2
reveal_type(2 in x) # revealed: bool
```
# error: [unsupported-operator] "Operator `in` is not supported for types `int` and `NonContainer`"
reveal_type(2 in x) # revealed: bool
Do also raise an error if the intersection has no positive contributions at all, unless the operator
is supported on `object`:
```py
def _(x: object):
if not isinstance(x, NonContainer1):
reveal_type(x) # revealed: ~NonContainer1
# error: [unsupported-operator] "Operator `in` is not supported for types `int` and `object`, in comparing `Literal[2]` with `~NonContainer1`"
reveal_type(2 in x) # revealed: bool
reveal_type(2 is x) # revealed: bool
```
### Unsupported operators for negative contributions

View file

@ -6537,20 +6537,27 @@ impl<'db> TypeInferenceBuilder<'db> {
intersection_on: IntersectionOn,
range: TextRange,
) -> Result<Type<'db>, CompareUnsupportedError<'db>> {
enum State<'db> {
// We have not seen any positive elements (yet)
NoPositiveElements,
// The operator was unsupported on all elements that we have seen so far.
// Contains the first error we encountered.
UnsupportedOnAllElements(CompareUnsupportedError<'db>),
// The operator was supported on at least one positive element.
Supported,
}
// If a comparison yields a definitive true/false answer on a (positive) part
// of an intersection type, it will also yield a definitive answer on the full
// intersection type, which is even more specific.
for pos in intersection.positive(self.db()) {
let result = match intersection_on {
IntersectionOn::Left => {
self.infer_binary_type_comparison(*pos, op, other, range)?
}
IntersectionOn::Right => {
self.infer_binary_type_comparison(other, op, *pos, range)?
}
IntersectionOn::Left => self.infer_binary_type_comparison(*pos, op, other, range),
IntersectionOn::Right => self.infer_binary_type_comparison(other, op, *pos, range),
};
if let Type::BooleanLiteral(b) = result {
return Ok(Type::BooleanLiteral(b));
if let Ok(Type::BooleanLiteral(_)) = result {
return result;
}
}
@ -6619,19 +6626,55 @@ impl<'db> TypeInferenceBuilder<'db> {
builder = builder.add_positive(KnownClass::Bool.to_instance(self.db()));
let mut state = State::NoPositiveElements;
for pos in intersection.positive(self.db()) {
let result = match intersection_on {
IntersectionOn::Left => {
self.infer_binary_type_comparison(*pos, op, other, range)?
}
IntersectionOn::Right => {
self.infer_binary_type_comparison(other, op, *pos, range)?
}
IntersectionOn::Left => self.infer_binary_type_comparison(*pos, op, other, range),
IntersectionOn::Right => self.infer_binary_type_comparison(other, op, *pos, range),
};
builder = builder.add_positive(result);
match result {
Ok(ty) => {
state = State::Supported;
builder = builder.add_positive(ty);
}
Err(error) => {
match state {
State::NoPositiveElements => {
// This is the first positive element, but the operation is not supported.
// Store the error and continue.
state = State::UnsupportedOnAllElements(error);
}
State::UnsupportedOnAllElements(_) => {
// We already have an error stored, and continue to see elements on which
// the operator is not supported. Continue with the same state (only keep
// the first error).
}
State::Supported => {
// We previously saw a positive element that supported the operator,
// so the overall operation is still supported.
}
}
}
}
}
Ok(builder.build())
match state {
State::Supported => Ok(builder.build()),
State::NoPositiveElements => {
// We didn't see any positive elements, check if the operation is supported on `object`:
match intersection_on {
IntersectionOn::Left => {
self.infer_binary_type_comparison(Type::object(self.db()), op, other, range)
}
IntersectionOn::Right => {
self.infer_binary_type_comparison(other, op, Type::object(self.db()), range)
}
}
}
State::UnsupportedOnAllElements(error) => Err(error),
}
}
/// Infers the type of a binary comparison (e.g. 'left == right'). See