[ty] propagate classmethod-ness through decorators returning Callables (#21958)

Fixes https://github.com/astral-sh/ty/issues/1787

## Summary

Allow method decorators returning Callables to presumptively propagate
"classmethod-ness" in the same way that they already presumptively
propagate "function-like-ness". We can't actually be sure that this is
the case, based on the decorator's annotations, but (along with other
type checkers) we heuristically assume it to be the case for decorators
applied via decorator syntax.

## Test Plan

Added mdtest.
This commit is contained in:
Carl Meyer 2025-12-16 09:16:40 -08:00 committed by GitHub
parent 7f7485d608
commit eeaaa8e9fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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", max_dep_date: "2025-06-17",
python_version: PythonVersion::PY312, python_version: PythonVersion::PY312,
}, },
13030, 13100,
); );
static TANJUN: Benchmark = Benchmark::new( 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 ```py
from typing import Callable from typing import Callable
# TODO: this could use a generic signature, but we don't support def memoize[**P, R](f: Callable[P, R]) -> Callable[P, R]:
# `ParamSpec` and solving of typevars inside `Callable` types yet.
def memoize(f: Callable[[C1, int], str]) -> Callable[[C1, int], str]:
raise NotImplementedError raise NotImplementedError
class C1: class C1:
@ -259,4 +257,37 @@ reveal_type(MyClass().method) # revealed: (...) -> int
reveal_type(MyClass().method.__name__) # revealed: str 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 [`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: class method:
```py ```py
from __future__ import annotations
def does_nothing[T](f: T) -> T: def does_nothing[T](f: T) -> T:
return f return f
class C: class C:
@classmethod @classmethod
@does_nothing @does_nothing
def f1(cls: type[C], x: int) -> str: def f1(cls, x: int) -> str:
return "a" return "a"
@does_nothing @does_nothing
@classmethod @classmethod
def f2(cls: type[C], x: int) -> str: def f2(cls, x: int) -> str:
return "a" return "a"
reveal_type(C.f1(1)) # revealed: str reveal_type(C.f1(1)) # revealed: str

View File

@ -4601,15 +4601,18 @@ impl<'db> Type<'db> {
owner.display(db) owner.display(db)
); );
match self { match self {
Type::Callable(callable) if callable.is_function_like(db) => { Type::Callable(callable)
// For "function-like" callables, model the behavior of `FunctionType.__get__`. 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, // 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__` // 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 // 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 // `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. // 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)) Some((self, AttributeKind::NormalOrNonDataDescriptor))
} else { } else {
Some(( Some((
@ -12243,6 +12246,10 @@ pub enum CallableTypeKind {
/// instances, i.e. they bind `self`. /// instances, i.e. they bind `self`.
FunctionLike, 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. /// Represents the value bound to a `typing.ParamSpec` type variable.
ParamSpecValue, ParamSpecValue,
} }
@ -12339,6 +12346,10 @@ impl<'db> CallableType<'db> {
matches!(self.kind(db), CallableTypeKind::FunctionLike) 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( pub(crate) fn bind_self(
self, self,
db: &'db dyn Db, db: &'db dyn Db,

View File

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