[ty] propagate classmethod-ness through decorators returning Callables

This commit is contained in:
Carl Meyer 2025-12-12 20:21:41 -08:00
parent 7f7485d608
commit 88c12ca7ee
No known key found for this signature in database
GPG Key ID: 2D1FB7916A52E121
6 changed files with 75 additions and 24 deletions

View File

@ -194,7 +194,7 @@ static SYMPY: Benchmark = Benchmark::new(
max_dep_date: "2025-06-17",
python_version: PythonVersion::PY312,
},
13030,
13100,
);
static TANJUN: Benchmark = Benchmark::new(

View File

@ -113,9 +113,7 @@ intention that it shouldn't influence the method's descriptor behavior. For exam
```py
from typing import Callable
# TODO: this could use a generic signature, but we don't support
# `ParamSpec` and solving of typevars inside `Callable` types yet.
def memoize(f: Callable[[C1, int], str]) -> Callable[[C1, int], str]:
def memoize[**P, R](f: Callable[P, R]) -> Callable[P, R]:
raise NotImplementedError
class C1:
@ -259,4 +257,37 @@ reveal_type(MyClass().method) # revealed: (...) -> int
reveal_type(MyClass().method.__name__) # revealed: str
```
## classmethods passed through Callable-returning decorators
The behavior described above is also applied to classmethods. If a method is decorated with
`@classmethod`, and also with another decorator which returns a Callable type, we make the
assumption that the decorator returns a callable which still has the classmethod descriptor
behavior.
```py
from typing import Callable
def callable_identity[**P, R](func: Callable[P, R]) -> Callable[P, R]:
return func
class C:
@callable_identity
@classmethod
def f1(cls, x: int) -> str:
return "a"
@classmethod
@callable_identity
def f2(cls, x: int) -> str:
return "a"
# error: [too-many-positional-arguments]
# error: [invalid-argument-type]
C.f1(C, 1)
C.f1(1)
C().f1(1)
C.f2(1)
C().f2(1)
```
[`tensorbase`]: https://github.com/pytorch/pytorch/blob/f3913ea641d871f04fa2b6588a77f63efeeb9f10/torch/_tensor.py#L1084-L1092

View File

@ -444,20 +444,18 @@ When a `@classmethod` is additionally decorated with another decorator, it is st
class method:
```py
from __future__ import annotations
def does_nothing[T](f: T) -> T:
return f
class C:
@classmethod
@does_nothing
def f1(cls: type[C], x: int) -> str:
def f1(cls, x: int) -> str:
return "a"
@does_nothing
@classmethod
def f2(cls: type[C], x: int) -> str:
def f2(cls, x: int) -> str:
return "a"
reveal_type(C.f1(1)) # revealed: str

View File

@ -4601,15 +4601,18 @@ impl<'db> Type<'db> {
owner.display(db)
);
match self {
Type::Callable(callable) if callable.is_function_like(db) => {
// For "function-like" callables, model the behavior of `FunctionType.__get__`.
Type::Callable(callable)
if callable.is_function_like(db) || callable.is_classmethod_like(db) =>
{
// For "function-like" or "classmethod-like" callables, model the behavior of
// `FunctionType.__get__` or `classmethod.__get__`.
//
// It is a shortcut to model this in `try_call_dunder_get`. If we want to be really precise,
// we should instead return a new method-wrapper type variant for the synthesized `__get__`
// method of these synthesized functions. The method-wrapper would then be returned from
// `find_name_in_mro` when called on function-like `Callable`s. This would allow us to
// correctly model the behavior of *explicit* `SomeDataclass.__init__.__get__` calls.
return if instance.is_none(db) {
return if instance.is_none(db) && callable.is_function_like(db) {
Some((self, AttributeKind::NormalOrNonDataDescriptor))
} else {
Some((
@ -12243,6 +12246,10 @@ pub enum CallableTypeKind {
/// instances, i.e. they bind `self`.
FunctionLike,
/// A callable type that we believe represents a classmethod (i.e. it will unconditionally bind
/// the first argument on `__get__`).
ClassMethodLike,
/// Represents the value bound to a `typing.ParamSpec` type variable.
ParamSpecValue,
}
@ -12339,6 +12346,10 @@ impl<'db> CallableType<'db> {
matches!(self.kind(db), CallableTypeKind::FunctionLike)
}
pub(crate) fn is_classmethod_like(self, db: &'db dyn Db) -> bool {
matches!(self.kind(db), CallableTypeKind::ClassMethodLike)
}
pub(crate) fn bind_self(
self,
db: &'db dyn Db,

View File

@ -1087,7 +1087,12 @@ 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), CallableTypeKind::FunctionLike)
let kind = if self.is_classmethod(db) {
CallableTypeKind::ClassMethodLike
} else {
CallableTypeKind::FunctionLike
};
CallableType::new(db, self.signature(db), kind)
}
/// Convert the `FunctionType` into a [`BoundMethodType`].

View File

@ -2408,37 +2408,43 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
.map(|bindings| bindings.return_type(self.db()))
{
Ok(return_ty) => {
fn into_function_like_callable<'d>(
fn propagate_callable_kind<'d>(
db: &'d dyn Db,
ty: Type<'d>,
kind: CallableTypeKind,
) -> Option<Type<'d>> {
match ty {
Type::Callable(callable) => Some(Type::Callable(CallableType::new(
db,
callable.signatures(db),
CallableTypeKind::FunctionLike,
kind,
))),
Type::Union(union) => union
.try_map(db, |element| into_function_like_callable(db, *element)),
.try_map(db, |element| propagate_callable_kind(db, *element, kind)),
// Intersections are currently not handled here because that would require
// the decorator to be explicitly annotated as returning an intersection.
_ => None,
}
}
let is_input_function_like = inferred_ty
let propagatable_kind = inferred_ty
.try_upcast_to_callable(self.db())
.and_then(CallableTypes::exactly_one)
.is_some_and(|callable| callable.is_function_like(self.db()));
.and_then(|callable| match callable.kind(self.db()) {
kind @ (CallableTypeKind::FunctionLike
| CallableTypeKind::ClassMethodLike) => Some(kind),
_ => None,
});
if is_input_function_like
&& let Some(return_ty_function_like) =
into_function_like_callable(self.db(), return_ty)
if let Some(return_ty_modified) = propagatable_kind
.and_then(|kind| propagate_callable_kind(self.db(), return_ty, kind))
{
// When a method on a class is decorated with a function that returns a `Callable`, assume that
// the returned callable is also function-like. See "Decorating a method with a `Callable`-typed
// decorator" in `callables_as_descriptors.md` for the extended explanation.
return_ty_function_like
// When a method on a class is decorated with a function that returns a
// `Callable`, assume that the returned callable is also function-like (or
// classmethod-like). See "Decorating a method with a `Callable`-typed
// decorator" in `callables_as_descriptors.md` for the extended
// explanation.
return_ty_modified
} else {
return_ty
}