[red-knot] Fix Stack overflow in Type::bool (#15843)

## Summary

This PR adds `Type::call_bound` method for calls that should follow
descriptor protocol calling convention. The PR is intentionally shallow
in scope and only fixes #15672

Couple of obvious things that weren't done:

* Switch to `call_bound` everywhere it should be used
* Address the fact, that red_knot resolves `__bool__ = bool` as a Union,
which includes `Type::Dynamic` and hence fails to infer that the
truthiness is always false for such a class (I've added a todo comment
in mdtests)
* Doesn't try to invent a new type for descriptors, although I have a
gut feeling it may be more convenient in the end, instead of doing
method lookup each time like I did in `call_bound`

## Test Plan

* extended mdtests with 2 examples from the issue
* cargo neatest run
This commit is contained in:
Mike Perlov 2025-02-04 15:40:07 -05:00 committed by GitHub
parent 444b055cec
commit e15419396c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 81 additions and 21 deletions

View file

@ -670,6 +670,10 @@ impl<'db> Type<'db> {
matches!(self, Type::ClassLiteral(..))
}
pub const fn is_instance(&self) -> bool {
matches!(self, Type::Instance(..))
}
pub fn module_literal(db: &'db dyn Db, importing_file: File, submodule: Module) -> Self {
Self::ModuleLiteral(ModuleLiteralType::new(db, importing_file, submodule))
}
@ -1843,19 +1847,8 @@ impl<'db> Type<'db> {
return Truthiness::Ambiguous;
};
// Check if the class has `__bool__ = bool` and avoid infinite recursion, since
// `Type::call` on `bool` will call `Type::bool` on the argument.
if bool_method
.into_class_literal()
.is_some_and(|ClassLiteralType { class }| {
class.is_known(db, KnownClass::Bool)
})
{
return Truthiness::Ambiguous;
}
if let Some(Type::BooleanLiteral(bool_val)) = bool_method
.call(db, &CallArguments::positional([*instance_ty]))
.call_bound(db, instance_ty, &CallArguments::positional([]))
.return_type(db)
{
bool_val.into()
@ -2148,6 +2141,52 @@ impl<'db> Type<'db> {
}
}
/// Return the outcome of calling an class/instance attribute of this type
/// using descriptor protocol.
///
/// `receiver_ty` must be `Type::Instance(_)` or `Type::ClassLiteral`.
///
/// TODO: handle `super()` objects properly
#[must_use]
fn call_bound(
self,
db: &'db dyn Db,
receiver_ty: &Type<'db>,
arguments: &CallArguments<'_, 'db>,
) -> CallOutcome<'db> {
debug_assert!(receiver_ty.is_instance() || receiver_ty.is_class_literal());
match self {
Type::FunctionLiteral(..) => {
// Functions are always descriptors, so this would effectively call
// the function with the instance as the first argument
self.call(db, &arguments.with_self(*receiver_ty))
}
Type::Instance(_) | Type::ClassLiteral(_) => {
// TODO descriptor protocol. For now, assume non-descriptor and call without `self` argument.
self.call(db, arguments)
}
Type::Union(union) => CallOutcome::union(
self,
union
.elements(db)
.iter()
.map(|elem| elem.call_bound(db, receiver_ty, arguments)),
),
Type::Intersection(_) => CallOutcome::callable(CallBinding::from_return_type(
todo_type!("Type::Intersection.call_bound()"),
)),
// Cases that duplicate, and thus must be kept in sync with, `Type::call()`
Type::Dynamic(_) => CallOutcome::callable(CallBinding::from_return_type(self)),
_ => CallOutcome::not_callable(self),
}
}
/// Look up a dunder method on the meta type of `self` and call it.
fn call_dunder(
self,