Skip to content

Commit b4e40f5

Browse files
[ty] Fix __contains__ to respect descriptors (#23056)
## Summary Matches the pattern we use for `__getitem__`. See: astral-sh/ty#190.
1 parent 848cb72 commit b4e40f5

2 files changed

Lines changed: 37 additions & 20 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ reveal_type(42 in A()) # revealed: bool
6565
reveal_type(42 not in A()) # revealed: bool
6666
```
6767

68+
## `__contains__` implemented via descriptor
69+
70+
If `__contains__` is implemented as a descriptor (e.g., a class with `__get__` that returns a
71+
callable), the descriptor protocol should be properly invoked:
72+
73+
```py
74+
class Target:
75+
def __call__(self, item: object) -> bool:
76+
return True
77+
78+
class Descriptor:
79+
def __get__(self, instance: object, owner: type) -> Target:
80+
return Target()
81+
82+
class Container:
83+
__contains__: Descriptor = Descriptor()
84+
85+
reveal_type(1 in Container()) # revealed: bool
86+
reveal_type("hello" not in Container()) # revealed: bool
87+
```
88+
6889
## Wrong Return Type
6990

7091
Python coerces the results of containment checks to `bool`, even if `__contains__` returns a

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

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14585,26 +14585,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
1458514585
) -> Result<Type<'db>, UnsupportedComparisonError<'db>> {
1458614586
let db = self.db();
1458714587

14588-
let contains_dunder = right.class_member(db, "__contains__".into()).place;
14589-
let compare_result_opt = match contains_dunder {
14590-
Place::Defined(DefinedPlace {
14591-
ty: contains_dunder,
14592-
definedness: Definedness::AlwaysDefined,
14593-
..
14594-
}) => {
14595-
// If `__contains__` is available, it is used directly for the membership test.
14596-
contains_dunder
14597-
.try_call(db, &CallArguments::positional([right, left]))
14598-
.map(|bindings| bindings.return_type(db))
14599-
.ok()
14600-
}
14601-
_ => {
14602-
// iteration-based membership test
14603-
right
14604-
.try_iterate(db)
14605-
.map(|_| KnownClass::Bool.to_instance(db))
14606-
.ok()
14607-
}
14588+
let compare_result_opt = match right.try_call_dunder(
14589+
db,
14590+
"__contains__",
14591+
CallArguments::positional([left]),
14592+
TypeContext::default(),
14593+
) {
14594+
// If `__contains__` is available, it is used directly for the membership test.
14595+
Ok(bindings) => Some(bindings.return_type(db)),
14596+
// If `__contains__` is not available or possibly unbound,
14597+
// fall back to iteration-based membership test.
14598+
Err(CallDunderError::MethodNotAvailable | CallDunderError::PossiblyUnbound(_)) => right
14599+
.try_iterate(db)
14600+
.map(|_| KnownClass::Bool.to_instance(db))
14601+
.ok(),
14602+
// `__contains__` exists but can't be called with the given arguments.
14603+
Err(CallDunderError::CallError(..)) => None,
1460814604
};
1460914605

1461014606
compare_result_opt

0 commit comments

Comments
 (0)