[ty] Callable types are disjoint from non-callable @final nominal instance types (#18368)

## Summary

Resolves [#513](https://github.com/astral-sh/ty/issues/513).

Callable types are now considered to be disjoint from nominal instance
types where:

* The class is `@final`, and
* Its `__call__` either does not exist or is not assignable to `(...) ->
Unknown`.

## Test Plan

Markdown tests.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
InSync 2025-05-30 06:27:27 +07:00 committed by GitHub
parent 695de4f27f
commit 9b0dfc505f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 0 deletions

View file

@ -439,3 +439,51 @@ static_assert(is_disjoint_from(Callable[[], None], Literal[b""]))
static_assert(is_disjoint_from(Callable[[], None], Literal[1]))
static_assert(is_disjoint_from(Callable[[], None], Literal[True]))
```
A callable type is disjoint from nominal instance types where the classes are final and whose
`__call__` is not callable.
```py
from ty_extensions import CallableTypeOf, is_disjoint_from, static_assert
from typing_extensions import Any, Callable, final
@final
class C: ...
static_assert(is_disjoint_from(bool, Callable[..., Any]))
static_assert(is_disjoint_from(C, Callable[..., Any]))
static_assert(is_disjoint_from(bool | C, Callable[..., Any]))
static_assert(not is_disjoint_from(str, Callable[..., Any]))
static_assert(not is_disjoint_from(bool | str, Callable[..., Any]))
def bound_with_valid_type():
@final
class D:
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
static_assert(not is_disjoint_from(D, Callable[..., Any]))
def possibly_unbound_with_valid_type(flag: bool):
@final
class E:
if flag:
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
static_assert(not is_disjoint_from(E, Callable[..., Any]))
def bound_with_invalid_type():
@final
class F:
__call__: int = 1
static_assert(is_disjoint_from(F, Callable[..., Any]))
def possibly_unbound_with_invalid_type(flag: bool):
@final
class G:
if flag:
__call__: int = 1
static_assert(is_disjoint_from(G, Callable[..., Any]))
```

View file

@ -2177,6 +2177,26 @@ impl<'db> Type<'db> {
true
}
(
Type::Callable(_) | Type::DataclassDecorator(_) | Type::DataclassTransformer(_),
Type::NominalInstance(instance),
)
| (
Type::NominalInstance(instance),
Type::Callable(_) | Type::DataclassDecorator(_) | Type::DataclassTransformer(_),
) if instance.class.is_final(db) => {
let member = self.member_lookup_with_policy(
db,
Name::new_static("__call__"),
MemberLookupPolicy::NO_INSTANCE_FALLBACK,
);
match member.symbol {
// TODO: ideally this would check disjointness of the `__call__` signature and the callable signature
Symbol::Type(ty, _) => !ty.is_assignable_to(db, CallableType::unknown(db)),
Symbol::Unbound => true,
}
}
(
Type::Callable(_) | Type::DataclassDecorator(_) | Type::DataclassTransformer(_),
_,