From bab688b76cf6ef3aba9bf9b9ae7b04100d3ba64b Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 24 Nov 2025 21:14:03 +0000 Subject: [PATCH] [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: https://github.com/astral-sh/ruff/blob/a57e29131125bf05db7379e90c7616eec32624fe/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`: https://github.com/astral-sh/ruff/blob/a57e29131125bf05db7379e90c7616eec32624fe/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` --- .../mdtest/call/callables_as_descriptors.md | 22 +++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 12 ++++++++-- .../src/types/protocol_class.rs | 19 +++++++++++++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index eb45a6697e..14d03c52cc 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -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 diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 35574d16ad..0e641e8f80 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -11018,11 +11018,19 @@ impl<'db> CallableType<'db> { db: &'db dyn Db, self_type: Option>, ) -> 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. diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 185bb9d2d5..8bfc0525a6 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -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>, +) -> CallableType<'db> { + CallableType::new(db, callable.signatures(db).bind_self(db, self_type), false) +}