From 92a2f2c99226706feb4bd1b35d4fea205ae2113c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 4 Jan 2026 17:11:00 -0500 Subject: [PATCH] [ty] Apply class decorators via `try_call()` (#22375) ## Summary Decorators are now called with the class as an argument, and the return type becomes the class's type. This mirrors how function decorators already work. Closes https://github.com/astral-sh/ty/issues/2313. --- .../resources/mdtest/decorators.md | 35 +++++++++++++++++++ .../src/types/infer/builder.rs | 31 ++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/decorators.md b/crates/ty_python_semantic/resources/mdtest/decorators.md index 124e2d9a82..a8e353c5e6 100644 --- a/crates/ty_python_semantic/resources/mdtest/decorators.md +++ b/crates/ty_python_semantic/resources/mdtest/decorators.md @@ -234,3 +234,38 @@ def takes_no_argument() -> str: @takes_no_argument def g(x): ... ``` + +## Class decorators + +Class decorator calls are validated, emitting diagnostics for invalid arguments: + +```py +def takes_int(x: int) -> int: + return x + +# error: [invalid-argument-type] +@takes_int +class Foo: ... +``` + +Using `None` as a decorator is an error: + +```py +# error: [call-non-callable] +@None +class Bar: ... +``` + +A decorator can enforce type constraints on the class being decorated: + +```py +def decorator(cls: type[int]) -> type[int]: + return cls + +# error: [invalid-argument-type] +@decorator +class Baz: ... + +# TODO: the revealed type should ideally be `type[int]` (the decorator's return type) +reveal_type(Baz) # revealed: +``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 453eb9a15e..420286ca31 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -2797,6 +2797,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { body: _, } = class_node; + let mut decorator_types_and_nodes: Vec<(Type<'db>, &ast::Decorator)> = + Vec::with_capacity(decorator_list.len()); let mut deprecated = None; let mut type_check_only = false; let mut dataclass_params = None; @@ -2831,6 +2833,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { continue; } + // Skip identity decorators to avoid salsa cycles on typeshed. + if decorator_ty.as_function_literal().is_some_and(|function| { + matches!( + function.known(self.db()), + Some( + KnownFunction::Final + | KnownFunction::DisjointBase + | KnownFunction::RuntimeCheckable + ) + ) + }) { + continue; + } + if let Type::FunctionLiteral(f) = decorator_ty { // We do not yet detect or flag `@dataclass_transform` applied to more than one // overload, or an overload and the implementation both. Nevertheless, this is not @@ -2852,6 +2868,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { dataclass_transformer_params = Some(params); continue; } + + decorator_types_and_nodes.push((decorator_ty, decorator)); } let body_scope = self @@ -2868,7 +2886,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ) }; - let ty = match (maybe_known_class, &*name.id) { + let inferred_ty = match (maybe_known_class, &*name.id) { (None, "NamedTuple") if in_typing_module() => { Type::SpecialForm(SpecialFormType::NamedTuple) } @@ -2885,10 +2903,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { )), }; + // Validate decorator calls (but don't use return types yet). + for (decorator_ty, decorator_node) in decorator_types_and_nodes.iter().rev() { + if let Err(CallError(_, bindings)) = + decorator_ty.try_call(self.db(), &CallArguments::positional([inferred_ty])) + { + bindings.report_diagnostics(&self.context, (*decorator_node).into()); + } + } + self.add_declaration_with_binding( class_node.into(), definition, - &DeclaredAndInferredType::are_the_same_type(ty), + &DeclaredAndInferredType::are_the_same_type(inferred_ty), ); // if there are type parameters, then the keywords and bases are within that scope