Skip to content

Commit 0b181bc

Browse files
carljmmed1844AlexWaygood
authored
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 astral-sh/ty#491 ## Test Plan mdtests --------- Co-authored-by: med <medioqrity@gmail.com> Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
1 parent 0397682 commit 0b181bc

File tree

4 files changed

+109
-12
lines changed

4 files changed

+109
-12
lines changed

crates/ty_python_semantic/resources/mdtest/call/dunder.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,37 @@ def _(flag: bool):
239239
# error: [possibly-unbound-implicit-call]
240240
reveal_type(c[0]) # revealed: str
241241
```
242+
243+
## Dunder methods cannot be looked up on instances
244+
245+
Class-level annotations with no value assigned are considered instance-only, and aren't available as
246+
dunder methods:
247+
248+
```py
249+
from typing import Callable
250+
251+
class C:
252+
__call__: Callable[..., None]
253+
254+
# error: [call-non-callable]
255+
C()()
256+
257+
# error: [invalid-assignment]
258+
_: Callable[..., None] = C()
259+
```
260+
261+
And of course the same is true if we have only an implicit assignment inside a method:
262+
263+
```py
264+
from typing import Callable
265+
266+
class C:
267+
def __init__(self):
268+
self.__call__ = lambda *a, **kw: None
269+
270+
# error: [call-non-callable]
271+
C()()
272+
273+
# error: [invalid-assignment]
274+
_: Callable[..., None] = C()
275+
```

crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,29 @@ def f(x: int, y: str) -> None: ...
672672
c1: Callable[[int], None] = partial(f, y="a")
673673
```
674674

675+
### Classes with `__call__` as attribute
676+
677+
An instance type is assignable to a compatible callable type if the instance type's class has a
678+
callable `__call__` attribute.
679+
680+
TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may
681+
change for better compatibility with mypy/pyright.
682+
683+
```py
684+
from typing import Callable
685+
from ty_extensions import static_assert, is_assignable_to
686+
687+
def call_impl(a: int) -> str:
688+
return ""
689+
690+
class A:
691+
__call__: Callable[[int], str] = call_impl
692+
693+
static_assert(is_assignable_to(A, Callable[[int], str]))
694+
static_assert(not is_assignable_to(A, Callable[[int], int]))
695+
reveal_type(A()(1)) # revealed: str
696+
```
697+
675698
## Generics
676699

677700
### Assignability of generic types parameterized by gradual types

crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,29 @@ def f(fn: Callable[[int], int]) -> None: ...
11571157
f(a)
11581158
```
11591159

1160+
### Classes with `__call__` as attribute
1161+
1162+
An instance type can be a subtype of a compatible callable type if the instance type's class has a
1163+
callable `__call__` attribute.
1164+
1165+
TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may
1166+
change for better compatibility with mypy/pyright.
1167+
1168+
```py
1169+
from typing import Callable
1170+
from ty_extensions import static_assert, is_subtype_of
1171+
1172+
def call_impl(a: int) -> str:
1173+
return ""
1174+
1175+
class A:
1176+
__call__: Callable[[int], str] = call_impl
1177+
1178+
static_assert(is_subtype_of(A, Callable[[int], str]))
1179+
static_assert(not is_subtype_of(A, Callable[[int], int]))
1180+
reveal_type(A()(1)) # revealed: str
1181+
```
1182+
11601183
### Class literals
11611184

11621185
#### Classes with metaclasses

crates/ty_python_semantic/src/types.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,12 +1291,20 @@ impl<'db> Type<'db> {
12911291
}
12921292

12931293
(Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => {
1294-
let call_symbol = self.member(db, "__call__").symbol;
1295-
match call_symbol {
1296-
Symbol::Type(Type::BoundMethod(call_function), _) => call_function
1297-
.into_callable_type(db)
1298-
.is_subtype_of(db, target),
1299-
_ => false,
1294+
let call_symbol = self
1295+
.member_lookup_with_policy(
1296+
db,
1297+
Name::new_static("__call__"),
1298+
MemberLookupPolicy::NO_INSTANCE_FALLBACK,
1299+
)
1300+
.symbol;
1301+
// If the type of __call__ is a subtype of a callable type, this instance is.
1302+
// Don't add other special cases here; our subtyping of a callable type
1303+
// shouldn't get out of sync with the calls we will actually allow.
1304+
if let Symbol::Type(t, Boundness::Bound) = call_symbol {
1305+
t.is_subtype_of(db, target)
1306+
} else {
1307+
false
13001308
}
13011309
}
13021310
(Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => {
@@ -1641,12 +1649,20 @@ impl<'db> Type<'db> {
16411649
}
16421650

16431651
(Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => {
1644-
let call_symbol = self.member(db, "__call__").symbol;
1645-
match call_symbol {
1646-
Symbol::Type(Type::BoundMethod(call_function), _) => call_function
1647-
.into_callable_type(db)
1648-
.is_assignable_to(db, target),
1649-
_ => false,
1652+
let call_symbol = self
1653+
.member_lookup_with_policy(
1654+
db,
1655+
Name::new_static("__call__"),
1656+
MemberLookupPolicy::NO_INSTANCE_FALLBACK,
1657+
)
1658+
.symbol;
1659+
// If the type of __call__ is assignable to a callable type, this instance is.
1660+
// Don't add other special cases here; our assignability to a callable type
1661+
// shouldn't get out of sync with the calls we will actually allow.
1662+
if let Symbol::Type(t, Boundness::Bound) = call_symbol {
1663+
t.is_assignable_to(db, target)
1664+
} else {
1665+
false
16501666
}
16511667
}
16521668

@@ -2746,6 +2762,7 @@ impl<'db> Type<'db> {
27462762
instance.display(db),
27472763
owner.display(db)
27482764
);
2765+
27492766
let descr_get = self.class_member(db, "__get__".into()).symbol;
27502767

27512768
if let Symbol::Type(descr_get, descr_get_boundness) = descr_get {

0 commit comments

Comments
 (0)