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