From 27729444c8d98ba7e1dd2c263e109586429a3ee9 Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 29 Oct 2025 09:44:52 -0400 Subject: [PATCH 1/2] [ty] Infer type of `self` for decorated methods and properties --- crates/ruff_benchmark/benches/ty_walltime.rs | 2 +- crates/ty_ide/src/semantic_tokens.rs | 2 +- .../resources/mdtest/annotations/self.md | 36 +++++++++++++-- .../resources/mdtest/overloads.md | 1 + ..._-_`@classmethod`_(aaa04d4cfa3adaba).snap" | 45 ++++++++++++------- .../ty_python_semantic/src/types/function.rs | 18 +++++--- .../src/types/infer/builder.rs | 26 +++++++---- 7 files changed, 97 insertions(+), 33 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index be6195d96aa99..47bff641d7666 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -226,7 +226,7 @@ static STATIC_FRAME: Benchmark = Benchmark::new( max_dep_date: "2025-08-09", python_version: PythonVersion::PY311, }, - 750, + 800, ); #[track_caller] diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index 4e66881f485e8..12e5e6581ba0b 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -1413,7 +1413,7 @@ u = List.__name__ # __name__ should be variable "property" @ 168..176: Decorator "prop" @ 185..189: Method [definition] "self" @ 190..194: SelfParameter - "self" @ 212..216: Variable + "self" @ 212..216: TypeParameter "CONSTANT" @ 217..225: Variable [readonly] "obj" @ 227..230: Variable "MyClass" @ 233..240: Class diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/self.md b/crates/ty_python_semantic/resources/mdtest/annotations/self.md index 621db497c4ab4..efa6b8b0e5f73 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/self.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/self.md @@ -116,7 +116,7 @@ A.implicit_self(1) Passing `self` implicitly also verifies the type: ```py -from typing import Never +from typing import Never, Callable class Strange: def can_not_be_called(self: Never) -> None: ... @@ -139,6 +139,9 @@ The first parameter of instance methods always has type `Self`, if it is not exp The name `self` is not special in any way. ```py +def some_decorator(f: Callable) -> Callable: + return f + class B: def name_does_not_matter(this) -> Self: reveal_type(this) # revealed: Self@name_does_not_matter @@ -153,18 +156,45 @@ class B: reveal_type(self) # revealed: Self@keyword_only return self + @some_decorator + def decorated_method(self) -> Self: + reveal_type(self) # revealed: Self@decorated_method + return self + @property def a_property(self) -> Self: - # TODO: Should reveal Self@a_property - reveal_type(self) # revealed: Unknown + reveal_type(self) # revealed: Self@a_property return self + async def async_method(self) -> Self: + reveal_type(self) # revealed: Self@async_method + return self + + @staticmethod + def static_method(self): + # The parameter can be called `self`, but it is not treated as `Self` + reveal_type(self) # revealed: Unknown + + @staticmethod + @some_decorator + def decorated_static_method(self): + reveal_type(self) # revealed: Unknown + + @some_decorator + @staticmethod + def decorated_static_method_2(self): + reveal_type(self) # revealed: Unknown + reveal_type(B().name_does_not_matter()) # revealed: B reveal_type(B().positional_only(1)) # revealed: B reveal_type(B().keyword_only(x=1)) # revealed: B +reveal_type(B().decorated_method()) # revealed: Unknown # TODO: this should be B reveal_type(B().a_property) # revealed: Unknown + +async def _(): + reveal_type(await B().async_method()) # revealed: B ``` This also works for generic classes: diff --git a/crates/ty_python_semantic/resources/mdtest/overloads.md b/crates/ty_python_semantic/resources/mdtest/overloads.md index 794abac2fc9da..7cf7bfc8380ab 100644 --- a/crates/ty_python_semantic/resources/mdtest/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/overloads.md @@ -598,6 +598,7 @@ class CheckClassMethod: # error: [invalid-overload] def try_from3(cls, x: int | str) -> CheckClassMethod | None: if isinstance(x, int): + # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" return cls(x) return None diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" index a2e027157665f..56546e27afd93 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" @@ -53,20 +53,21 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/overloads.md 39 | # error: [invalid-overload] 40 | def try_from3(cls, x: int | str) -> CheckClassMethod | None: 41 | if isinstance(x, int): -42 | return cls(x) -43 | return None -44 | -45 | @overload -46 | @classmethod -47 | def try_from4(cls, x: int) -> CheckClassMethod: ... -48 | @overload -49 | @classmethod -50 | def try_from4(cls, x: str) -> None: ... -51 | @classmethod -52 | def try_from4(cls, x: int | str) -> CheckClassMethod | None: -53 | if isinstance(x, int): -54 | return cls(x) -55 | return None +42 | # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" +43 | return cls(x) +44 | return None +45 | +46 | @overload +47 | @classmethod +48 | def try_from4(cls, x: int) -> CheckClassMethod: ... +49 | @overload +50 | @classmethod +51 | def try_from4(cls, x: str) -> None: ... +52 | @classmethod +53 | def try_from4(cls, x: int | str) -> CheckClassMethod | None: +54 | if isinstance(x, int): +55 | return cls(x) +56 | return None ``` # Diagnostics @@ -124,8 +125,22 @@ error[invalid-overload]: Overloaded function `try_from3` does not use the `@clas | | | Missing here 41 | if isinstance(x, int): -42 | return cls(x) +42 | # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" | info: rule `invalid-overload` is enabled by default ``` + +``` +error[call-non-callable]: Object of type `CheckClassMethod` is not callable + --> src/mdtest_snippet.py:43:20 + | +41 | if isinstance(x, int): +42 | # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" +43 | return cls(x) + | ^^^^^^ +44 | return None + | +info: rule `call-non-callable` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 459edb6c25d1b..8fafa7bf2b810 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -185,6 +185,16 @@ pub struct DataclassTransformerParams<'db> { impl get_size2::GetSize for DataclassTransformerParams<'_> {} +/// Whether a function should implicitly be treated as a staticmethod based on its name. +pub(crate) fn is_implicit_staticmethod(function_name: &str) -> bool { + matches!(function_name, "__new__") +} + +/// Whether a function should implicitly be treated as a classmethod based on its name. +pub(crate) fn is_implicit_classmethod(function_name: &str) -> bool { + matches!(function_name, "__init_subclass__" | "__class_getitem__") +} + /// Representation of a function definition in the AST: either a non-generic function, or a generic /// function that has not been specialized. /// @@ -257,17 +267,15 @@ impl<'db> OverloadLiteral<'db> { /// Returns true if this overload is decorated with `@staticmethod`, or if it is implicitly a /// staticmethod. pub(crate) fn is_staticmethod(self, db: &dyn Db) -> bool { - self.has_known_decorator(db, FunctionDecorators::STATICMETHOD) || self.name(db) == "__new__" + self.has_known_decorator(db, FunctionDecorators::STATICMETHOD) + || is_implicit_staticmethod(self.name(db)) } /// Returns true if this overload is decorated with `@classmethod`, or if it is implicitly a /// classmethod. pub(crate) fn is_classmethod(self, db: &dyn Db) -> bool { self.has_known_decorator(db, FunctionDecorators::CLASSMETHOD) - || matches!( - self.name(db).as_str(), - "__init_subclass__" | "__class_getitem__" - ) + || is_implicit_classmethod(self.name(db)) } fn node<'ast>( diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 9a5091f837a3b..edf8581bcd184 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -78,6 +78,7 @@ use crate::types::diagnostic::{ }; use crate::types::function::{ FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, + is_implicit_classmethod, is_implicit_staticmethod, }; use crate::types::generics::{ GenericContext, InferableTypeVars, LegacyGenericBase, SpecializationBuilder, bind_typevar, @@ -2580,18 +2581,27 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; } - let method = infer_definition_types(db, method_definition) - .declaration_type(method_definition) - .inner_type() - .as_function_literal()?; + let function_node = function_definition.node(self.module()); + let function_name = &function_node.name; - if method.is_classmethod(db) { - // TODO: set the type for `cls` argument - return None; - } else if method.is_staticmethod(db) { + // TODO: handle implicit type of `cls` for classmethods + if is_implicit_classmethod(function_name) || is_implicit_staticmethod(function_name) { return None; } + let inference = infer_definition_types(db, method_definition); + for decorator in &function_node.decorator_list { + let decorator_ty = inference.expression_type(&decorator.expression); + if decorator_ty.as_class_literal().is_some_and(|class| { + matches!( + class.known(db), + Some(KnownClass::Classmethod | KnownClass::Staticmethod) + ) + }) { + return None; + } + } + let class_definition = self.index.expect_single_definition(class); let class_literal = infer_definition_types(db, class_definition) .declaration_type(class_definition) From f5641d17eb75959c87cbce7a5525fc4447c04fd2 Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 29 Oct 2025 17:17:47 -0400 Subject: [PATCH 2/2] Review findings --- .../ty_python_semantic/resources/mdtest/annotations/self.md | 2 +- crates/ty_python_semantic/resources/mdtest/overloads.md | 2 +- ...at\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/self.md b/crates/ty_python_semantic/resources/mdtest/annotations/self.md index efa6b8b0e5f73..b635104a758ac 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/self.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/self.md @@ -179,7 +179,7 @@ class B: @some_decorator def decorated_static_method(self): reveal_type(self) # revealed: Unknown - + # TODO: On Python <3.10, this should ideally be rejected, because `staticmethod` objects were not callable. @some_decorator @staticmethod def decorated_static_method_2(self): diff --git a/crates/ty_python_semantic/resources/mdtest/overloads.md b/crates/ty_python_semantic/resources/mdtest/overloads.md index 7cf7bfc8380ab..f74cafb80bef3 100644 --- a/crates/ty_python_semantic/resources/mdtest/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/overloads.md @@ -598,7 +598,7 @@ class CheckClassMethod: # error: [invalid-overload] def try_from3(cls, x: int | str) -> CheckClassMethod | None: if isinstance(x, int): - # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" + # error: [call-non-callable] return cls(x) return None diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" index 56546e27afd93..9fb873f913df0 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" @@ -53,7 +53,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/overloads.md 39 | # error: [invalid-overload] 40 | def try_from3(cls, x: int | str) -> CheckClassMethod | None: 41 | if isinstance(x, int): -42 | # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" +42 | # error: [call-non-callable] 43 | return cls(x) 44 | return None 45 | @@ -125,7 +125,7 @@ error[invalid-overload]: Overloaded function `try_from3` does not use the `@clas | | | Missing here 41 | if isinstance(x, int): -42 | # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" +42 | # error: [call-non-callable] | info: rule `invalid-overload` is enabled by default @@ -136,7 +136,7 @@ error[call-non-callable]: Object of type `CheckClassMethod` is not callable --> src/mdtest_snippet.py:43:20 | 41 | if isinstance(x, int): -42 | # error: [call-non-callable] "Object of type `CheckClassMethod` is not callable" +42 | # error: [call-non-callable] 43 | return cls(x) | ^^^^^^ 44 | return None