From eeaaa8e9fec0b5d699ff5ad0e52e09d62b2a0aad Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 16 Dec 2025 09:16:40 -0800 Subject: [PATCH] [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. --- crates/ruff_benchmark/benches/ty_walltime.rs | 2 +- .../mdtest/call/callables_as_descriptors.md | 37 +++++++++++++++++-- .../resources/mdtest/call/methods.md | 6 +-- crates/ty_python_semantic/src/types.rs | 17 +++++++-- .../ty_python_semantic/src/types/function.rs | 7 +++- .../src/types/infer/builder.rs | 30 +++++++++------ 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index 7a0f9a5848..490cafc650 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -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( diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index 14d03c52cc..80625af9ad 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -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 diff --git a/crates/ty_python_semantic/resources/mdtest/call/methods.md b/crates/ty_python_semantic/resources/mdtest/call/methods.md index eb02c134f3..a791a96ca4 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/methods.md +++ b/crates/ty_python_semantic/resources/mdtest/call/methods.md @@ -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 diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 2e285d46a7..2632888aaf 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -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, diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 37c9a6d184..606b4ff6d3 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -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`]. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 76271b07ff..c93269ade2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -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> { 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 }