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 }