[ty] Treat functions, methods, and dynamic types as function-like Callables (#20842)

## Summary

Treat functions, methods, and dynamic types as function-like `Callable`s

closes https://github.com/astral-sh/ty/issues/1342
closes https://github.com/astral-sh/ty/issues/1344

## Ecosystem analysis

All removed diagnostics look like cases of
https://github.com/astral-sh/ty/issues/1344

## Test Plan

Added regression test
This commit is contained in:
David Peter 2025-10-13 15:21:55 +02:00 committed by GitHub
parent 513d2996ec
commit 195e8f0684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 101 additions and 19 deletions

View file

@ -1204,9 +1204,9 @@ python-version = "3.12"
from dataclasses import dataclass
from typing import Callable
from types import FunctionType
from ty_extensions import CallableTypeOf, TypeOf, static_assert, is_subtype_of, is_assignable_to
from ty_extensions import CallableTypeOf, TypeOf, static_assert, is_subtype_of, is_assignable_to, is_equivalent_to
@dataclass
@dataclass(order=True)
class C:
x: int
@ -1233,8 +1233,20 @@ static_assert(not is_assignable_to(EquivalentPureCallableType, DunderInitType))
static_assert(is_subtype_of(DunderInitType, EquivalentFunctionLikeCallableType))
static_assert(is_assignable_to(DunderInitType, EquivalentFunctionLikeCallableType))
static_assert(not is_subtype_of(EquivalentFunctionLikeCallableType, DunderInitType))
static_assert(not is_assignable_to(EquivalentFunctionLikeCallableType, DunderInitType))
static_assert(is_subtype_of(EquivalentFunctionLikeCallableType, DunderInitType))
static_assert(is_assignable_to(EquivalentFunctionLikeCallableType, DunderInitType))
static_assert(is_equivalent_to(EquivalentFunctionLikeCallableType, DunderInitType))
static_assert(is_subtype_of(DunderInitType, FunctionType))
```
It should be possible to mock out synthesized methods:
```py
from unittest.mock import Mock
def test_c():
c = C(1)
c.__lt__ = Mock()
```

View file

@ -25,6 +25,12 @@ x = [a, b]
reveal_type(x) # revealed: list[Unknown | ((_: int) -> int)]
```
The inferred `Callable` type is function-like, i.e. we can still access attributes like `__name__`:
```py
reveal_type(x[0].__name__) # revealed: Unknown | str
```
## Mixed list
```py

View file

@ -963,6 +963,40 @@ c: Callable[[Any], str] = f
c: Callable[[Any], str] = g
```
A function with no explicit return type should be assignable to a callable with a return type of
`Any`.
```py
def h():
return
c: Callable[[], Any] = h
```
And, similarly for parameters with no annotations:
```py
def i(a, b, /) -> None:
return
c: Callable[[Any, Any], None] = i
```
Additionally, a function definition that includes both `*args` and `**kwargs` parameters that are
annotated as `Any` or kept unannotated should be assignable to a callable with `...` as the
parameter type.
```py
def variadic_without_annotation(*args, **kwargs):
return
def variadic_with_annotation(*args: Any, **kwargs: Any) -> Any:
return
c: Callable[..., Any] = variadic_without_annotation
c: Callable[..., Any] = variadic_with_annotation
```
### Method types
```py

View file

@ -464,7 +464,7 @@ gradual types. The cases with fully static types and using different combination
are covered above.
```py
from ty_extensions import Unknown, CallableTypeOf, is_equivalent_to, static_assert
from ty_extensions import Unknown, CallableTypeOf, TypeOf, is_equivalent_to, static_assert
from typing import Any, Callable
static_assert(is_equivalent_to(Callable[..., int], Callable[..., int]))
@ -476,14 +476,17 @@ static_assert(not is_equivalent_to(Callable[[int, str], None], Callable[[int, st
static_assert(not is_equivalent_to(Callable[..., None], Callable[[], None]))
```
A function with no explicit return type should be gradual equivalent to a callable with a return
type of `Any`.
A function with no explicit return type should be gradually equivalent to a function-like callable
with a return type of `Any`.
```py
def f1():
return
static_assert(is_equivalent_to(CallableTypeOf[f1], Callable[[], Any]))
def f1_equivalent() -> Any:
return
static_assert(is_equivalent_to(CallableTypeOf[f1], CallableTypeOf[f1_equivalent]))
```
And, similarly for parameters with no annotations.
@ -492,12 +495,15 @@ And, similarly for parameters with no annotations.
def f2(a, b, /) -> None:
return
static_assert(is_equivalent_to(CallableTypeOf[f2], Callable[[Any, Any], None]))
def f2_equivalent(a: Any, b: Any, /) -> None:
return
static_assert(is_equivalent_to(CallableTypeOf[f2], CallableTypeOf[f2_equivalent]))
```
Additionally, as per the spec, a function definition that includes both `*args` and `**kwargs`
parameter that are annotated as `Any` or kept unannotated should be gradual equivalent to a callable
with `...` as the parameter type.
A function definition that includes both `*args` and `**kwargs` parameter that are annotated as
`Any` or kept unannotated should be gradual equivalent to a callable with `...` as the parameter
type.
```py
def variadic_without_annotation(*args, **kwargs):
@ -506,12 +512,27 @@ def variadic_without_annotation(*args, **kwargs):
def variadic_with_annotation(*args: Any, **kwargs: Any) -> Any:
return
static_assert(is_equivalent_to(CallableTypeOf[variadic_without_annotation], Callable[..., Any]))
static_assert(is_equivalent_to(CallableTypeOf[variadic_with_annotation], Callable[..., Any]))
def _(
signature_variadic_without_annotation: CallableTypeOf[variadic_without_annotation],
signature_variadic_with_annotation: CallableTypeOf[variadic_with_annotation],
) -> None:
# revealed: (...) -> Unknown
reveal_type(signature_variadic_without_annotation)
# revealed: (...) -> Any
reveal_type(signature_variadic_with_annotation)
```
But, a function with either `*args` or `**kwargs` (and not both) is not gradual equivalent to a
callable with `...` as the parameter type.
Note that `variadic_without_annotation` and `variadic_with_annotation` are *not* considered
gradually equivalent to `Callable[..., Any]`, because the latter is not a function-like callable
type:
```py
static_assert(not is_equivalent_to(CallableTypeOf[variadic_without_annotation], Callable[..., Any]))
static_assert(not is_equivalent_to(CallableTypeOf[variadic_with_annotation], Callable[..., Any]))
```
A function with either `*args` or `**kwargs` (and not both) is is not equivalent to a callable with
`...` as the parameter type.
```py
def variadic_args(*args):
@ -520,6 +541,15 @@ def variadic_args(*args):
def variadic_kwargs(**kwargs):
return
def _(
signature_variadic_args: CallableTypeOf[variadic_args],
signature_variadic_kwargs: CallableTypeOf[variadic_kwargs],
) -> None:
# revealed: (*args) -> Unknown
reveal_type(signature_variadic_args)
# revealed: (**kwargs) -> Unknown
reveal_type(signature_variadic_kwargs)
static_assert(not is_equivalent_to(CallableTypeOf[variadic_args], Callable[..., Any]))
static_assert(not is_equivalent_to(CallableTypeOf[variadic_kwargs], Callable[..., Any]))
```

View file

@ -1401,7 +1401,7 @@ impl<'db> Type<'db> {
match self {
Type::Callable(_) => Some(self),
Type::Dynamic(_) => Some(CallableType::single(db, Signature::dynamic(self))),
Type::Dynamic(_) => Some(CallableType::function_like(db, Signature::dynamic(self))),
Type::FunctionLiteral(function_literal) => {
Some(Type::Callable(function_literal.into_callable_type(db)))
@ -9770,7 +9770,7 @@ impl<'db> BoundMethodType<'db> {
.iter()
.map(|signature| signature.bind_self(db, Some(self_instance))),
),
false,
true,
)
}

View file

@ -943,7 +943,7 @@ impl<'db> FunctionType<'db> {
/// Convert the `FunctionType` into a [`CallableType`].
pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> CallableType<'db> {
CallableType::new(db, self.signature(db), false)
CallableType::new(db, self.signature(db), true)
}
/// Convert the `FunctionType` into a [`BoundMethodType`].