mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-09 21:28:21 +00:00
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:
parent
0397682f1f
commit
0b181bc2ad
4 changed files with 109 additions and 12 deletions
|
@ -239,3 +239,37 @@ def _(flag: bool):
|
||||||
# error: [possibly-unbound-implicit-call]
|
# error: [possibly-unbound-implicit-call]
|
||||||
reveal_type(c[0]) # revealed: str
|
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()
|
||||||
|
```
|
||||||
|
|
|
@ -672,6 +672,29 @@ def f(x: int, y: str) -> None: ...
|
||||||
c1: Callable[[int], None] = partial(f, y="a")
|
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
|
## Generics
|
||||||
|
|
||||||
### Assignability of generic types parameterized by gradual types
|
### Assignability of generic types parameterized by gradual types
|
||||||
|
|
|
@ -1157,6 +1157,29 @@ def f(fn: Callable[[int], int]) -> None: ...
|
||||||
f(a)
|
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
|
### Class literals
|
||||||
|
|
||||||
#### Classes with metaclasses
|
#### Classes with metaclasses
|
||||||
|
|
|
@ -1291,12 +1291,20 @@ impl<'db> Type<'db> {
|
||||||
}
|
}
|
||||||
|
|
||||||
(Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => {
|
(Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => {
|
||||||
let call_symbol = self.member(db, "__call__").symbol;
|
let call_symbol = self
|
||||||
match call_symbol {
|
.member_lookup_with_policy(
|
||||||
Symbol::Type(Type::BoundMethod(call_function), _) => call_function
|
db,
|
||||||
.into_callable_type(db)
|
Name::new_static("__call__"),
|
||||||
.is_subtype_of(db, target),
|
MemberLookupPolicy::NO_INSTANCE_FALLBACK,
|
||||||
_ => false,
|
)
|
||||||
|
.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)) => {
|
(Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => {
|
||||||
|
@ -1641,12 +1649,20 @@ impl<'db> Type<'db> {
|
||||||
}
|
}
|
||||||
|
|
||||||
(Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => {
|
(Type::NominalInstance(_) | Type::ProtocolInstance(_), Type::Callable(_)) => {
|
||||||
let call_symbol = self.member(db, "__call__").symbol;
|
let call_symbol = self
|
||||||
match call_symbol {
|
.member_lookup_with_policy(
|
||||||
Symbol::Type(Type::BoundMethod(call_function), _) => call_function
|
db,
|
||||||
.into_callable_type(db)
|
Name::new_static("__call__"),
|
||||||
.is_assignable_to(db, target),
|
MemberLookupPolicy::NO_INSTANCE_FALLBACK,
|
||||||
_ => false,
|
)
|
||||||
|
.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),
|
instance.display(db),
|
||||||
owner.display(db)
|
owner.display(db)
|
||||||
);
|
);
|
||||||
|
|
||||||
let descr_get = self.class_member(db, "__get__".into()).symbol;
|
let descr_get = self.class_member(db, "__get__".into()).symbol;
|
||||||
|
|
||||||
if let Symbol::Type(descr_get, descr_get_boundness) = descr_get {
|
if let Symbol::Type(descr_get, descr_get_boundness) = descr_get {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue