From b78d7494d4d2d3382b74def5936adc783099f730 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 22 May 2025 13:38:03 -0500 Subject: [PATCH 1/4] Fix instance vs callable subtyping/assignability Co-authored-by: med --- .../resources/mdtest/call/dunder.md | 34 +++++++++++++++ .../type_properties/is_assignable_to.md | 22 ++++++++++ .../mdtest/type_properties/is_subtype_of.md | 22 ++++++++++ crates/ty_python_semantic/src/types.rs | 41 +++++++++++++------ 4 files changed, 107 insertions(+), 12 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/dunder.md b/crates/ty_python_semantic/resources/mdtest/call/dunder.md index 5caeb1f3b5d558..8c2a70a2c4deb2 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 597fd4a61464e0..67d9097880ef64 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,28 @@ def f(x: int, y: str) -> None: ... c1: Callable[[int], None] = partial(f, y="a") ``` +### Classes with `__call__` as attribute + +A class with a callable `__call__` attribute is assignable to a compatible callable type. + +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 f9a97c8c51ef0a..a11223ec48515c 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,28 @@ def f(fn: Callable[[int], int]) -> None: ... f(a) ``` +### Classes with `__call__` as attribute + +A class with a callable `__call__` attribute is subtype of a compatible callable type. + +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 1db7479afa9f89..8a91eac8576380 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 { From 7fb0494c6f8e7587afb4005d5641fc3aba416200 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 22 May 2025 15:28:03 -0400 Subject: [PATCH 2/4] Update is_assignable_to.md Co-authored-by: Alex Waygood --- .../resources/mdtest/type_properties/is_assignable_to.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 67d9097880ef64..434c2c29251508 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 @@ -674,7 +674,7 @@ c1: Callable[[int], None] = partial(f, y="a") ### Classes with `__call__` as attribute -A class with a callable `__call__` attribute is assignable to a compatible callable type. +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. From 679c66e8cd96895b0eda5f8f168f0361cadee2da Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 22 May 2025 15:28:13 -0400 Subject: [PATCH 3/4] Update is_subtype_of.md Co-authored-by: Alex Waygood --- .../resources/mdtest/type_properties/is_subtype_of.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a11223ec48515c..ce4bfbc78d0d1e 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 @@ -1159,7 +1159,7 @@ f(a) ### Classes with `__call__` as attribute -A class with a callable `__call__` attribute is subtype of a compatible callable type. +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. From 610b570a5b3e2622ed06690b2ea3c401db985dbd Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 22 May 2025 14:43:52 -0500 Subject: [PATCH 4/4] pre-commit --- .../resources/mdtest/type_properties/is_assignable_to.md | 3 ++- .../resources/mdtest/type_properties/is_subtype_of.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 434c2c29251508..afc28ac58710c5 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 @@ -674,7 +674,8 @@ 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. +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. 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 ce4bfbc78d0d1e..8f0f360f096078 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 @@ -1159,7 +1159,8 @@ 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. +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.