Fix instance vs callable subtyping/assignability (#18260)

## Summary

Fix some issues with subtying/assignability for instances vs callables.
We need to look up dunders on the class, not the instance, and we should
limit our logic here to delegating to the type of `__call__`, so it
doesn't get out of sync with the calls we allow.

Also, we were just entirely missing assignability handling for
`__call__` implemented as anything other than a normal bound method
(though we had it for subtyping.)

A first step towards considering what else we want to change in
https://github.com/astral-sh/ty/issues/491

## Test Plan

mdtests

---------

Co-authored-by: med <medioqrity@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Carl Meyer 2025-05-22 14:47:05 -05:00 committed by GitHub
parent 0397682f1f
commit 0b181bc2ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 109 additions and 12 deletions

View file

@ -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()
```

View file

@ -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

View file

@ -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

View file

@ -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 {