[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.
This commit is contained in:
Charlie Marsh
2026-01-04 17:11:00 -05:00
committed by GitHub
parent 11b551c2be
commit 92a2f2c992
2 changed files with 64 additions and 2 deletions

View File

@@ -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: <class 'Baz'>
```

View File

@@ -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