[ty] Retain the function-like-ness of `Callable` types when binding `self` (#21614)

## Summary

For something like this:

```py
from typing import Callable

def my_lossy_decorator(fn: Callable[..., int]) -> Callable[..., int]:
    return fn

class MyClass:
    @my_lossy_decorator
    def method(self) -> int:
        return 42
```

we will currently infer the type of `MyClass.method` as a function-like
`Callable`, but we will infer the type of `MyClass().method` as a
`Callable` that is _not_ function-like. That's because a `CallableType`
currently "forgets" whether it was function-like or not during the
`bound_self` transformation:


a57e291311/crates/ty_python_semantic/src/types.rs (L10985-L10987)

This seems incorrect, and it's quite different to what we do when
binding the `self` parameter of `FunctionLiteral` types: `BoundMethod`
types are all seen as subtypes of function-like `Callable` supertypes --
here's `BoundMethodType::into_callable_type`:


a57e291311/crates/ty_python_semantic/src/types.rs (L10844-L10860)

The bug here is also causing lots of false positives in the ecosystem
report on https://github.com/astral-sh/ruff/pull/21611: a decorated
method on a subclass is currently not seen as validly overriding an
undecorated method with the same signature on a superclass, because the
undecorated superclass method is seen as function-like after binding
`self` whereas the decorated subclass method is not.

Fixing the bug required adding a new API in `protocol_class.rs`, because
it turns out that for our purposes in protocol subtyping/assignability,
we really do want a callable type to forget its function-like-ness when
binding `self`.

I initially tried out this change without changing anything in
`protocol_class.rs`. However, it resulted in many ecosystem false
positives and new false positives on the typing conformance test suite.
This is because it would mean that no protocol with a `__call__` method
would ever be seen as a subtype of a `Callable` type, since the
`__call__` method on the protocol would be seen as being function-like
whereas the `Callable` type would not be seen as function-like.

## Test Plan

Added an mdtest that fails on `main`
This commit is contained in:
Alex Waygood 2025-11-24 21:14:03 +00:00 committed by GitHub
parent 7e277667d1
commit bab688b76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 48 additions and 5 deletions

View File

@ -237,4 +237,26 @@ class Matrix:
Matrix() < Matrix()
```
## `self`-binding behaviour of function-like `Callable`s
Binding the `self` parameter of a function-like `Callable` creates a new `Callable` that is also
function-like:
`main.py`:
```py
from typing import Callable
def my_lossy_decorator(fn: Callable[..., int]) -> Callable[..., int]:
return fn
class MyClass:
@my_lossy_decorator
def method(self) -> int:
return 42
reveal_type(MyClass().method) # revealed: (...) -> int
reveal_type(MyClass().method.__name__) # revealed: str
```
[`tensorbase`]: https://github.com/pytorch/pytorch/blob/f3913ea641d871f04fa2b6588a77f63efeeb9f10/torch/_tensor.py#L1084-L1092

View File

@ -11018,11 +11018,19 @@ impl<'db> CallableType<'db> {
db: &'db dyn Db,
self_type: Option<Type<'db>>,
) -> CallableType<'db> {
CallableType::new(db, self.signatures(db).bind_self(db, self_type), false)
CallableType::new(
db,
self.signatures(db).bind_self(db, self_type),
self.is_function_like(db),
)
}
pub(crate) fn apply_self(self, db: &'db dyn Db, self_type: Type<'db>) -> CallableType<'db> {
CallableType::new(db, self.signatures(db).apply_self(db, self_type), false)
CallableType::new(
db,
self.signatures(db).apply_self(db, self_type),
self.is_function_like(db),
)
}
/// Create a callable type which represents a fully-static "bottom" callable.

View File

@ -300,7 +300,7 @@ impl<'db> ProtocolInterface<'db> {
.and(db, || {
our_type.has_relation_to_impl(
db,
Type::Callable(other_type.bind_self(db, None)),
Type::Callable(protocol_bind_self(db, other_type, None)),
inferable,
relation,
relation_visitor,
@ -313,7 +313,7 @@ impl<'db> ProtocolInterface<'db> {
ProtocolMemberKind::Method(other_method),
) => our_method.bind_self(db, None).has_relation_to_impl(
db,
other_method.bind_self(db, None),
protocol_bind_self(db, other_method, None),
inferable,
relation,
relation_visitor,
@ -712,7 +712,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> {
.map(|callable| callable.apply_self(db, fallback_other))
.has_relation_to_impl(
db,
method.bind_self(db, Some(fallback_other)),
protocol_bind_self(db, *method, Some(fallback_other)),
inferable,
relation,
relation_visitor,
@ -912,3 +912,16 @@ fn proto_interface_cycle_initial<'db>(
) -> ProtocolInterface<'db> {
ProtocolInterface::empty(db)
}
/// Bind `self`, and *also* discard the functionlike-ness of the callable.
///
/// This additional upcasting is required in order for protocols with `__call__` method
/// members to be considered assignable to `Callable` types, since the `Callable` supertype
/// of the `__call__` method will be function-like but a `Callable` type is not.
fn protocol_bind_self<'db>(
db: &'db dyn Db,
callable: CallableType<'db>,
self_type: Option<Type<'db>>,
) -> CallableType<'db> {
CallableType::new(db, callable.signatures(db).bind_self(db, self_type), false)
}