diff --git a/crates/ty_python_semantic/resources/mdtest/call/dunder.md b/crates/ty_python_semantic/resources/mdtest/call/dunder.md index 5caeb1f3b5..8c2a70a2c4 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/dunder.md +++ b/crates/ty_python_semantic/resources/mdtest/call/dunder.md @@ -239,3 +239,37 @@ def _(flag: bool): # error: [possibly-unbound-implicit-call] reveal_type(c[0]) # revealed: str ``` + +## Dunder methods cannot be looked up on instances + +Class-level annotations with no value assigned are considered instance-only, and aren't available as +dunder methods: + +```py +from typing import Callable + +class C: + __call__: Callable[..., None] + +# error: [call-non-callable] +C()() + +# error: [invalid-assignment] +_: Callable[..., None] = C() +``` + +And of course the same is true if we have only an implicit assignment inside a method: + +```py +from typing import Callable + +class C: + def __init__(self): + self.__call__ = lambda *a, **kw: None + +# error: [call-non-callable] +C()() + +# error: [invalid-assignment] +_: Callable[..., None] = C() +``` diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md index 597fd4a614..afc28ac587 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md @@ -672,6 +672,29 @@ def f(x: int, y: str) -> None: ... c1: Callable[[int], None] = partial(f, y="a") ``` +### Classes with `__call__` as attribute + +An instance type is assignable to a compatible callable type if the instance type's class has a +callable `__call__` attribute. + +TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may +change for better compatibility with mypy/pyright. + +```py +from typing import Callable +from ty_extensions import static_assert, is_assignable_to + +def call_impl(a: int) -> str: + return "" + +class A: + __call__: Callable[[int], str] = call_impl + +static_assert(is_assignable_to(A, Callable[[int], str])) +static_assert(not is_assignable_to(A, Callable[[int], int])) +reveal_type(A()(1)) # revealed: str +``` + ## Generics ### Assignability of generic types parameterized by gradual types diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md index f9a97c8c51..8f0f360f09 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md @@ -1157,6 +1157,29 @@ def f(fn: Callable[[int], int]) -> None: ... f(a) ``` +### Classes with `__call__` as attribute + +An instance type can be a subtype of a compatible callable type if the instance type's class has a +callable `__call__` attribute. + +TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may +change for better compatibility with mypy/pyright. + +```py +from typing import Callable +from ty_extensions import static_assert, is_subtype_of + +def call_impl(a: int) -> str: + return "" + +class A: + __call__: Callable[[int], str] = call_impl + +static_assert(is_subtype_of(A, Callable[[int], str])) +static_assert(not is_subtype_of(A, Callable[[int], int])) +reveal_type(A()(1)) # revealed: str +``` + ### Class literals #### Classes with metaclasses diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1db7479afa..8a91eac857 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1291,12 +1291,20 @@ impl<'db> Type<'db> { } (Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => { - let call_symbol = self.member(db, "__call__").symbol; - match call_symbol { - Symbol::Type(Type::BoundMethod(call_function), _) => call_function - .into_callable_type(db) - .is_subtype_of(db, target), - _ => false, + let call_symbol = self + .member_lookup_with_policy( + db, + Name::new_static("__call__"), + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .symbol; + // If the type of __call__ is a subtype of a callable type, this instance is. + // Don't add other special cases here; our subtyping of a callable type + // shouldn't get out of sync with the calls we will actually allow. + if let Symbol::Type(t, Boundness::Bound) = call_symbol { + t.is_subtype_of(db, target) + } else { + false } } (Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => { @@ -1641,12 +1649,20 @@ impl<'db> Type<'db> { } (Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => { - let call_symbol = self.member(db, "__call__").symbol; - match call_symbol { - Symbol::Type(Type::BoundMethod(call_function), _) => call_function - .into_callable_type(db) - .is_assignable_to(db, target), - _ => false, + let call_symbol = self + .member_lookup_with_policy( + db, + Name::new_static("__call__"), + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .symbol; + // If the type of __call__ is assignable to a callable type, this instance is. + // Don't add other special cases here; our assignability to a callable type + // shouldn't get out of sync with the calls we will actually allow. + if let Symbol::Type(t, Boundness::Bound) = call_symbol { + t.is_assignable_to(db, target) + } else { + false } } @@ -2746,6 +2762,7 @@ impl<'db> Type<'db> { instance.display(db), owner.display(db) ); + let descr_get = self.class_member(db, "__get__".into()).symbol; if let Symbol::Type(descr_get, descr_get_boundness) = descr_get {