From 8359c96a659fd3328222f2ce08752fddabc43398 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sat, 24 Oct 2026 02:01:35 +0900 Subject: [PATCH 1/8] test --- pyrefly/lib/test/decorators.rs | 129 +++++++++++++++++++++++++++++++++ pyrefly/lib/test/shape_dsl.rs | 56 ++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 914896d385..2a76bb5e49 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -1120,6 +1120,135 @@ g(f) "#, ); +testcase!( + test_class_decorator_return_type_applied, + r#" +from typing import reveal_type + +def to_int(cls: type) -> int: + return 42 + +@to_int +class C: + ... + +reveal_type(C) # E: revealed type: int + "#, +); + +testcase!( + test_class_decorator_dataclass_preserves_class, + r#" +from dataclasses import dataclass +from typing import assert_type + +@dataclass +class Bare: + value: int + +@dataclass(kw_only=True) +class Called: + value: int + +assert_type(Bare, type[Bare]) +assert_type(Bare(1), Bare) +assert_type(Bare(1).value, int) +assert_type(Called, type[Called]) +assert_type(Called(value=1), Called) +assert_type(Called(value=1).value, int) +Called(1) # E: Expected argument `value` to be passed by name + "#, +); + +testcase!( + test_class_decorator_dataclass_transform_preserves_class, + r#" +from typing import assert_type, dataclass_transform + +@dataclass_transform(kw_only_default=True) +class Base: ... + +class Child(Base): + value: int + +assert_type(Base, type[Base]) +assert_type(Base(), Base) +assert_type(Child(value=1), Child) +assert_type(Child(value=1).value, int) +Child(1) # E: Expected argument `value` to be passed by name + "#, +); + +testcase!( + test_class_decorator_dataclass_transform_metadata_preserves_class, + r#" +from typing import Callable, assert_type, dataclass_transform + +# Transform metadata preserves the class even when the signature erases its type. +@dataclass_transform() +def model(cls: type[object]) -> type[object]: ... + +@dataclass_transform() +def model_factory(*, kw_only: bool = False) -> Callable[[type[object]], type[object]]: ... + +@model +class Bare: + value: int + +@model_factory(kw_only=True) +class Called: + value: int + +assert_type(Bare, type[Bare]) +assert_type(Bare(1), Bare) +assert_type(Bare(1).value, int) +assert_type(Called, type[Called]) +assert_type(Called(value=1), Called) +assert_type(Called(value=1).value, int) +Called(1) # E: Expected argument `value` to be passed by name + "#, +); + +testcase!( + test_class_decorator_special_function_kinds_preserve_class, + r#" +from functools import total_ordering +from typing import Protocol, assert_type, final, runtime_checkable +from typing_extensions import disjoint_base + +@final +class FinalClass: + value: int = 1 + +@disjoint_base +class DisjointClass: + value: int = 1 + +@runtime_checkable +class RuntimeProtocol(Protocol): + value: int + +@total_ordering +class OrderedClass: + def __lt__(self, other: OrderedClass) -> bool: ... + +assert_type(FinalClass, type[FinalClass]) +assert_type(FinalClass(), FinalClass) +assert_type(FinalClass().value, int) +assert_type(DisjointClass, type[DisjointClass]) +assert_type(DisjointClass(), DisjointClass) +assert_type(DisjointClass().value, int) +assert_type(OrderedClass, type[OrderedClass]) +assert_type(OrderedClass(), OrderedClass) +assert_type(OrderedClass() >= OrderedClass(), bool) + +def narrow(value: object) -> None: + if isinstance(value, RuntimeProtocol): + assert_type(value, RuntimeProtocol) + assert_type(value.value, int) + "#, +); + testcase!( test_decorator_missing_concatenate_parameters, r#" diff --git a/pyrefly/lib/test/shape_dsl.rs b/pyrefly/lib/test/shape_dsl.rs index d6c7496dc0..031e998ec1 100644 --- a/pyrefly/lib/test/shape_dsl.rs +++ b/pyrefly/lib/test/shape_dsl.rs @@ -8606,6 +8606,62 @@ def named_variadic(x: Float[Array, "*batch channels"]) -> None: "#, ); +testcase!( + test_shape_vars_decorator_preserves_class, + shape_extensions_env(), + r#" +import shape_extensions as se +from shape_extensions import shape_vars +from shape_extensions import shape_vars as array_metadata +from typing import assert_type + +@shape_vars("N") +class Direct: + value: int = 1 + +@array_metadata("N") +class Alias: + value: int = 1 + +@se.shape_vars("N") +class Qualified: + value: int = 1 + +assert_type(Direct().value, int) +assert_type(Alias().value, int) +assert_type(Qualified().value, int) + +def check_shapes(direct: Direct[3], alias: Alias[3], qualified: Qualified[3]) -> None: + assert_type(direct.value, int) + assert_type(alias.value, int) + assert_type(qualified.value, int) + +# Invalid bare metadata decorators still preserve the class during recovery. +@shape_vars # E: `@shape_vars` requires a declaration string +class Bare: + value: int = 1 + +assert_type(Bare, type[Bare]) +assert_type(Bare().value, int) +"#, +); + +testcase!( + test_shape_vars_decorator_name_is_not_metadata, + shape_extensions_env(), + r#" +from typing import Callable, assert_type + +# Only the special export is metadata, not an unrelated function with the same name. +def shape_vars(declaration: str) -> Callable[[type], int]: ... + +@shape_vars("N") +class Ordinary: ... + +assert_type(Ordinary, int) +"#, +); + testcase!( test_assert_type_gradual_shape_not_equivalent_to_concrete, shape_extensions_env(), From 5d71ff8dd19c3b8a74166af2a0551b51891f37b2 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 8 Oct 2026 11:30:47 +0900 Subject: [PATCH 2/8] Apply class decorator return types while preserving special decorators --- crates/pyrefly_types/src/stdlib.rs | 4 +++ pyrefly/lib/alt/function.rs | 46 ++++++++++++++++++++++++ pyrefly/lib/alt/solve.rs | 58 +++++++++++++++--------------- pyrefly/lib/binding/binding.rs | 4 ++- pyrefly/lib/binding/expr.rs | 7 ++++ 5 files changed, 90 insertions(+), 29 deletions(-) diff --git a/crates/pyrefly_types/src/stdlib.rs b/crates/pyrefly_types/src/stdlib.rs index f4ded04b50..988eae1339 100644 --- a/crates/pyrefly_types/src/stdlib.rs +++ b/crates/pyrefly_types/src/stdlib.rs @@ -318,6 +318,10 @@ impl Stdlib { Self::new_with_bootstrapping(true, PythonVersion::default(), &|_, _| None, &|_, _| None) } + pub fn is_bootstrapping(&self) -> bool { + self.object.is_err() + } + fn unwrap(x: &StdlibResult) -> &T { match x { Ok(x) => x, diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index f11ca4a852..2e10054aa2 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -2292,6 +2292,52 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { self.singledispatch_dispatcher_as_callback(widened, &application.original_decoratee) } + pub(crate) fn apply_class_decorator( + &self, + decorator: Type, + decoratee: Type, + range: TextRange, + errors: &ErrorCollector, + ) -> Type { + if self.stdlib.is_bootstrapping() { + return decoratee; + } + let preserves_class = match &decorator { + Type::KwCall(call) => { + call.func_metadata + .flags + .dataclass_transform_metadata + .is_some() + || call.has_function_kind(FunctionKind::Dataclass) + || call.has_function_kind(FunctionKind::DataclassTransform) + || call.has_function_kind(FunctionKind::DisjointBase) + || call.has_function_kind(FunctionKind::Final) + || call.has_function_kind(FunctionKind::RuntimeCheckable) + || call.has_function_kind(FunctionKind::TotalOrdering) + } + _ => decorator.toplevel_func_metadata().is_some_and(|meta| { + meta.flags.dataclass_transform_metadata.is_some() + || matches!( + meta.kind, + FunctionKind::Dataclass + | FunctionKind::DataclassTransform + | FunctionKind::DisjointBase + | FunctionKind::Final + | FunctionKind::RuntimeCheckable + | FunctionKind::TotalOrdering + ) + }), + }; + if preserves_class { + return decoratee; + } + let call_target = + self.as_call_target_or_error(decorator, CallStyle::FreeForm, range, errors, None); + let arg = CallArg::ty(&decoratee, range); + self.call_infer(call_target, &[arg], &[], range, errors, None, None, None) + .ty + } + /// For a type guard function, validate whether it has at least one /// positional argument. fn validate_type_guard_positional_argument_count( diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 47371bd3b5..7566f1ff6e 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -7425,35 +7425,37 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ); } } - // TODO: analyze the class decorators beyond the unknown-type check above. We don't - // support general type-level analysis of class decorators (the ones we do - // support, like dataclass-related ones, are handled via custom bindings). - // - // Note that all decorators have their own binding so they are still type checked for errors - // *inside* the decorator. The only application-level effect we honor is an - // explicit `type[Any]` return, which intentionally erases the class interface. - // - // We use `.any()` across the chain because if *any* decorator is annotated - // with `-> type[Any]`, all *later* decorators receive whatever class that - // dynamic decorator produces (an `Any`-ish class), so the final output must - // also be treated as dynamic. This is by intent: the escape hatch lets library - // authors signal that the class flows through a decorator a type checker cannot - // model accurately, so the end result is not modelable either. - let erases_class = decorators.iter().any(|idx| { - let decorator_ty = &self.get_idx(*idx).ty; - // Only an explicit `-> type[Any]` return annotation counts; a return - // type inferred from the body (e.g. `return cls` where `cls: type[Any]`) - // does not, since the user did not opt in to erasing the class. - !decorator_ty.toplevel_func_metadata().is_some_and(|meta| meta.flags.is_return_inferred) - && decorator_ty - .toplevel_callable_signatures() - .any(|(c, _)| matches!(&c.ret, Type::Type(inner) if matches!(&**inner, Type::Any(AnyStyle::Explicit)))) - }); - if erases_class { - self.heap.mk_type_of(self.heap.mk_any_explicit()) - } else { - self.heap.mk_class_def(cls.dupe()) + let mut ty = self.heap.mk_class_def(cls.dupe()); + for decorator_key in decorators.iter().rev() { + if self.bindings().get(*decorator_key).is_class_metadata { + continue; + } + let decorator = self.get_idx(*decorator_key); + let preserves_class = matches!( + &decorator.ty, + Type::Any(AnyStyle::Implicit | AnyStyle::Explicit) + ) || decorator.ty.toplevel_callable_signatures().any( + |(callable, _)| { + matches!(&callable.ret, Type::Any(_)) + || decorator.ty.toplevel_func_metadata().is_some_and(|meta| { + meta.flags.is_return_inferred + && matches!( + &callable.ret, + Type::Type(inner) if matches!( + &**inner, + Type::Any(AnyStyle::Explicit) + ) + ) + }) + }, + ); + if preserves_class { + continue; + } + let range = self.bindings().idx_to_key(*decorator_key).range(); + ty = self.apply_class_decorator(decorator.ty.clone(), ty, range, errors); } + ty } }, Binding::AnnotatedType(ann, val) => { diff --git a/pyrefly/lib/binding/binding.rs b/pyrefly/lib/binding/binding.rs index 2520235c43..c899329b03 100644 --- a/pyrefly/lib/binding/binding.rs +++ b/pyrefly/lib/binding/binding.rs @@ -148,7 +148,7 @@ assert_words!(BindingClassSynthesizedFields, 2); assert_bytes!(BindingLegacyTypeParam, 16); assert_words!(BindingYield, 4); assert_words!(BindingYieldFrom, 4); -assert_words!(BindingDecorator, 9); +assert_words!(BindingDecorator, 10); assert_bytes!(BindingDecoratedFunction, 24); assert_words!(BindingUndecoratedFunction, 16); @@ -2095,6 +2095,8 @@ pub enum FunctionParameter { pub struct BindingDecorator { pub expr: Expr, pub trailing_name: Option, + /// Metadata decorators are consumed during binding and must not replace the class object. + pub is_class_metadata: bool, } impl DisplayWith for BindingDecorator { diff --git a/pyrefly/lib/binding/expr.rs b/pyrefly/lib/binding/expr.rs index 0de17d28b9..9d771e2156 100644 --- a/pyrefly/lib/binding/expr.rs +++ b/pyrefly/lib/binding/expr.rs @@ -1647,6 +1647,12 @@ impl<'a> BindingsBuilder<'a> { ) -> Vec> { let mut decorator_keys = Vec::with_capacity(decorators.len()); for mut x in decorators { + let decorator_func = x + .expression + .as_call_expr() + .map_or(&x.expression, |call| &call.func); + let is_class_metadata = + self.as_special_export(decorator_func) == Some(SpecialExport::ShapedArray); self.ensure_expr(&mut x.expression, usage); let trailing_name = Ast::decorator_trailing_name(&x.expression).map(Name::new); let k = self.insert_binding( @@ -1654,6 +1660,7 @@ impl<'a> BindingsBuilder<'a> { BindingDecorator { expr: x.expression, trailing_name, + is_class_metadata, }, ); decorator_keys.push(k); From 323774b7c5a41125e2cecdb02599818ab0c3f694 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sat, 24 Oct 2026 17:24:52 +0900 Subject: [PATCH 3/8] fix test --- pyrefly/lib/alt/function.rs | 17 ++++++++++++-- pyrefly/lib/alt/solve.rs | 41 +++++++++++++++++---------------- pyrefly/lib/binding/bindings.rs | 12 ++++++++++ pyrefly/lib/test/calls.rs | 5 ++++ pyrefly/lib/test/decorators.rs | 35 ++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 22 deletions(-) diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index 2e10054aa2..7ec570e75c 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -2334,8 +2334,21 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let call_target = self.as_call_target_or_error(decorator, CallStyle::FreeForm, range, errors, None); let arg = CallArg::ty(&decoratee, range); - self.call_infer(call_target, &[arg], &[], range, errors, None, None, None) - .ty + let decorated = self + .call_infer(call_target, &[arg], &[], range, errors, None, None, None) + .ty; + // Retain class-definition metadata when the decorator returns the same nongeneric class. + // A specialized generic return must keep its inferred type arguments. + if let Type::ClassDef(cls) = &decoratee + && let Type::Type(inner) = &decorated + && let Type::ClassType(result) = &**inner + && result.class_object() == cls + && result.targs().is_empty() + { + decoratee + } else { + decorated + } } /// For a type guard function, validate whether it has at least one diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 7566f1ff6e..044fa0c1cc 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -7388,8 +7388,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { None => self.heap.mk_any_implicit(), Some(cls) => { // A class decorator obscures the class if its own type is `Any`, or if it - // returns an unknown type. Class decorators are not applied below, so the - // result is read from the decorator's signatures rather than by calling it. + // returns an unknown type. Read the return signatures before applying the + // decorators so this diagnostic does not depend on application results. // Like for functions, only the first decorator (in application order) that // makes the result unknown is blamed for it. let returns_unknown = |callable: &Type| { @@ -7431,24 +7431,25 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { continue; } let decorator = self.get_idx(*decorator_key); - let preserves_class = matches!( - &decorator.ty, - Type::Any(AnyStyle::Implicit | AnyStyle::Explicit) - ) || decorator.ty.toplevel_callable_signatures().any( - |(callable, _)| { - matches!(&callable.ret, Type::Any(_)) - || decorator.ty.toplevel_func_metadata().is_some_and(|meta| { - meta.flags.is_return_inferred - && matches!( - &callable.ret, - Type::Type(inner) if matches!( - &**inner, - Type::Any(AnyStyle::Explicit) - ) - ) - }) - }, - ); + let preserves_class = matches!(&decorator.ty, Type::Any(_)) + || decorator + .ty + .toplevel_callable_signatures() + .any(|(callable, _)| { + matches!(&callable.ret, Type::Any(_)) + || decorator.ty.toplevel_func_metadata().is_some_and( + |meta| { + meta.flags.is_return_inferred + && matches!( + &callable.ret, + Type::Type(inner) if matches!( + &**inner, + Type::Any(AnyStyle::Explicit) + ) + ) + }, + ) + }); if preserves_class { continue; } diff --git a/pyrefly/lib/binding/bindings.rs b/pyrefly/lib/binding/bindings.rs index 4121c722cb..a9d86234da 100644 --- a/pyrefly/lib/binding/bindings.rs +++ b/pyrefly/lib/binding/bindings.rs @@ -2703,6 +2703,18 @@ impl<'a> BindingsBuilder<'a> { let binding = if forward_to_first_use { Binding::ForwardToFirstUse(target_idx) + } else if deferred.usage.is_static() + && let Some(Binding::ClassDef(class_idx, decorators)) = self.idx_to_binding(target_idx) + && !decorators.is_empty() + && let Some(BindingClass::ClassDef(class)) = self.idx_to_binding(*class_idx) + && class + .def + .range + .contains_range(self.idx_to_key(deferred.bound_name_idx).range()) + { + // Self-references in class annotations refer to the undecorated class. Depending on + // its decorated value would create a cycle when a decorator's signature uses it. + Binding::ClassDef(*class_idx, Box::new([])) } else if let Some(b) = self.idx_to_binding(target_idx) && matches!( b, diff --git a/pyrefly/lib/test/calls.rs b/pyrefly/lib/test/calls.rs index d0d7b02749..8731deac9e 100644 --- a/pyrefly/lib/test/calls.rs +++ b/pyrefly/lib/test/calls.rs @@ -949,6 +949,11 @@ def identity[T](cls: type[T]) -> type[T]: class Old: ... Old() # E: `Old` is deprecated +alias = Old # E: `Old` is deprecated +alias() # E: `Old` is deprecated + +def accepts_subclass(cls: type[Old]) -> None: # E: `Old` is deprecated + cls() "#, ); diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 2a76bb5e49..5a46fde2e6 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -1136,6 +1136,41 @@ reveal_type(C) # E: revealed type: int "#, ); +testcase!( + test_class_decorator_error_preserves_inheritance, + r#" +from typing import assert_type + +@missing_decorator # E: Could not find name `missing_decorator` +class Base: + def inherited(self) -> int: + return 1 + +class Child(Base): ... + +assert_type(Base(), Base) +assert_type(Child().inherited(), int) + "#, +); + +testcase!( + test_class_decorator_preserves_returned_type_arguments, + r#" +from typing import Callable, assert_type, cast + +def identity[T](cls: type[T]) -> type[T]: + return cls + +@cast("Callable[[type[Box]], type[Box[int]]]", identity) +class Box[T]: + def __init__(self, value: T) -> None: + self.value = value + +assert_type(Box(1).value, int) +Box("bad") # E: is not assignable to parameter `value` with type `int` + "#, +); + testcase!( test_class_decorator_dataclass_preserves_class, r#" From 82152521dbdfb2877cc9bec0318cec33bc7c05ec Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 30 Oct 2026 00:51:02 +0900 Subject: [PATCH 4/8] 3 config --- crates/pyrefly_config/src/args.rs | 36 +++++++ crates/pyrefly_config/src/base.rs | 29 ++++++ crates/pyrefly_config/src/config.rs | 56 ++++++++++ pyrefly/lib/alt/solve.rs | 57 ++++++---- pyrefly/lib/solver/solver.rs | 3 + pyrefly/lib/state/state.rs | 3 + pyrefly/lib/state/steps.rs | 3 + pyrefly/lib/test/decorators.rs | 156 ++++++++++++++++++++++++++++ pyrefly/lib/test/util.rs | 9 ++ schemas/pyrefly.json | 6 ++ schemas/validate_schemas.py | 15 +++ website/docs/configuration.mdx | 34 ++++++ 12 files changed, 386 insertions(+), 21 deletions(-) diff --git a/crates/pyrefly_config/src/args.rs b/crates/pyrefly_config/src/args.rs index bf371d04c2..586405db8a 100644 --- a/crates/pyrefly_config/src/args.rs +++ b/crates/pyrefly_config/src/args.rs @@ -23,6 +23,7 @@ use pyrefly_util::arc_id::ArcId; use pyrefly_util::display; use pyrefly_util::interned_path::InternedPath; +use crate::base::ClassDecoratorBehavior; use crate::base::InferReturnTypes; use crate::base::Preset; use crate::base::RecursionOverflowHandler; @@ -370,6 +371,9 @@ pub struct ConfigOverrideArgs { /// are only eligible when --check-unannotated-defs is true. #[arg(long)] infer_return_types: Option, + /// How class decorators affect class types: ignore, typed-only (default), or all. + #[arg(long)] + class_decorator_behavior: Option, /// Whether Pyrefly will respect ignore statements for other tools, e.g. `# pyright: ignore`. /// Equivalent to passing the names of all tools to `--enabled-ignores`. #[arg( @@ -502,6 +506,7 @@ impl ConfigOverrideArgs { untyped_def_behavior, check_unannotated_defs, infer_return_types, + class_decorator_behavior, permissive_ignores, enabled_ignores, type_ignore_unknown_tag_behavior, @@ -534,6 +539,7 @@ impl ConfigOverrideArgs { || untyped_def_behavior.is_some() || check_unannotated_defs.is_some() || infer_return_types.is_some() + || class_decorator_behavior.is_some() || permissive_ignores.is_some() || enabled_ignores.is_some() || type_ignore_unknown_tag_behavior.is_some() @@ -643,6 +649,9 @@ impl ConfigOverrideArgs { if let Some(x) = &self.infer_return_types { config.root.infer_return_types = Some(*x); } + if let Some(x) = &self.class_decorator_behavior { + config.root.class_decorator_behavior = Some(*x); + } match (self.permissive_ignores, &self.enabled_ignores) { // Special case: if the underlying config sets enabled-ignores and --permissive-ignores // is passed on the command-line, we overwrite enabled-ignores. @@ -881,6 +890,33 @@ mod tests { ); } + #[test] + fn class_decorator_behavior_cli_overrides_config() { + for (value, expected) in [ + ("ignore", ClassDecoratorBehavior::Ignore), + ("typed-only", ClassDecoratorBehavior::TypedOnly), + ("all", ClassDecoratorBehavior::All), + ] { + let args = + ConfigOverrideArgs::parse_from(["pyrefly", "--class-decorator-behavior", value]); + assert!(args.has_overrides()); + let mut config = ConfigFile::default(); + config.interpreters.skip_interpreter_query = true; + config.root.class_decorator_behavior = Some(ClassDecoratorBehavior::All); + let (config, errors) = args.override_config(config); + assert!(errors.is_empty()); + assert_eq!(config.root.class_decorator_behavior, Some(expected)); + } + assert!( + ConfigOverrideArgs::try_parse_from([ + "pyrefly", + "--class-decorator-behavior", + "invalid" + ]) + .is_err() + ); + } + #[test] fn cli_interpreter_selection_overrides_config_skip() { for command_line in [ diff --git a/crates/pyrefly_config/src/base.rs b/crates/pyrefly_config/src/base.rs index 95902d4427..c165329fba 100644 --- a/crates/pyrefly_config/src/base.rs +++ b/crates/pyrefly_config/src/base.rs @@ -51,6 +51,29 @@ pub enum InferReturnTypes { Checked, } +/// Controls how class decorators affect the type bound to the class name. +#[derive( + Debug, + PartialEq, + Eq, + Deserialize, + Serialize, + Clone, + Copy, + Default, + ValueEnum +)] +#[serde(rename_all = "kebab-case")] +pub enum ClassDecoratorBehavior { + /// Preserve the class regardless of decorator return types. + Ignore, + /// Apply typed decorators and preserve the class for untyped decorators. + #[default] + TypedOnly, + /// Apply all decorators, including those that return `Any` or `Unknown`. + All, +} + /// How to handle when recursion depth limit is exceeded. #[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Copy, Default)] #[derive(ValueEnum)] @@ -336,6 +359,9 @@ pub struct ConfigBase { /// are only eligible when `check-unannotated-defs` is true. pub infer_return_types: Option, + /// How class decorators affect the class type. Defaults to `typed-only`. + pub class_decorator_behavior: Option, + /// Whether to disable type errors in language server. By default errors will be shown in IDEs. pub disable_type_errors_in_ide: Option, @@ -432,6 +458,7 @@ impl ConfigBase { replace_untyped_imports_with_any: Some(Vec::new()), check_unannotated_defs: Some(true), infer_return_types: Some(InferReturnTypes::Checked), + class_decorator_behavior: Some(ClassDecoratorBehavior::default()), disable_type_errors_in_ide: Some(false), ignore_errors_in_generated_code: Some(false), infer_with_first_use: Some(true), @@ -462,6 +489,7 @@ impl ConfigBase { untyped_def_behavior, check_unannotated_defs, infer_return_types, + class_decorator_behavior, disable_type_errors_in_ide, ignore_errors_in_generated_code, infer_with_first_use, @@ -496,6 +524,7 @@ impl ConfigBase { untyped_def_behavior, check_unannotated_defs, infer_return_types, + class_decorator_behavior, disable_type_errors_in_ide, ignore_errors_in_generated_code, infer_with_first_use, diff --git a/crates/pyrefly_config/src/config.rs b/crates/pyrefly_config/src/config.rs index b4e02170e8..4b2bcafd85 100644 --- a/crates/pyrefly_config/src/config.rs +++ b/crates/pyrefly_config/src/config.rs @@ -69,6 +69,7 @@ use uv_pep440::Version; #[cfg(not(target_arch = "wasm32"))] use uv_pep440::VersionSpecifiers; +use crate::base::ClassDecoratorBehavior; use crate::base::ConfigBase; use crate::base::ExtraConfigs; use crate::base::InferReturnTypes; @@ -1352,6 +1353,13 @@ impl ConfigFile { .expect(RESOLVED_SETTING) } + pub fn class_decorator_behavior(&self, path: &ModulePath) -> ClassDecoratorBehavior { + self.resolved(path) + .settings + .class_decorator_behavior + .expect(RESOLVED_SETTING) + } + pub fn infer_with_first_use(&self, path: &ModulePath) -> bool { self.resolved(path) .settings @@ -2906,6 +2914,7 @@ mod tests { untyped_def_behavior: Some(UntypedDefBehavior::CheckAndInferReturnType), check_unannotated_defs: None, infer_return_types: None, + class_decorator_behavior: None, permissive_ignores: None, enabled_ignores: None, type_ignore_unknown_tag_behavior: None, @@ -2942,6 +2951,7 @@ mod tests { untyped_def_behavior: Some(UntypedDefBehavior::CheckAndInferReturnAny), check_unannotated_defs: None, infer_return_types: None, + class_decorator_behavior: None, permissive_ignores: None, enabled_ignores: None, type_ignore_unknown_tag_behavior: None, @@ -3678,6 +3688,7 @@ output-format = "omit-errors" untyped_def_behavior: Some(UntypedDefBehavior::CheckAndInferReturnType), check_unannotated_defs: None, infer_return_types: None, + class_decorator_behavior: None, disable_type_errors_in_ide: Some(true), ignore_errors_in_generated_code: Some(false), infer_with_first_use: Some(true), @@ -3988,6 +3999,49 @@ output-format = "omit-errors" ); } + #[test] + fn test_class_decorator_behavior_config() { + let mut config = ConfigFile::default(); + config.interpreters.skip_interpreter_query = true; + assert!(config.configure().is_empty()); + assert_eq!( + config.class_decorator_behavior(&test_path("src/main.py")), + ClassDecoratorBehavior::TypedOnly, + ); + + for (value, expected) in [ + ("ignore", ClassDecoratorBehavior::Ignore), + ("typed-only", ClassDecoratorBehavior::TypedOnly), + ("all", ClassDecoratorBehavior::All), + ] { + let mut config: ConfigFile = toml::from_str(&format!( + "class-decorator-behavior = '{value}'\n\ + [[sub-config]]\n\ + matches = 'legacy/**'\n\ + class-decorator-behavior = 'ignore'\n" + )) + .unwrap(); + config.interpreters.skip_interpreter_query = true; + assert!(config.configure().is_empty()); + assert_eq!( + config.class_decorator_behavior(&test_path("src/main.py")), + expected, + ); + assert_eq!( + config.class_decorator_behavior(&test_path("legacy/main.py")), + ClassDecoratorBehavior::Ignore, + ); + let target_config = default_config_test_config(Some(serde_json::json!({ + "class-decorator-behavior": value + }))); + assert_eq!( + target_config.class_decorator_behavior(&test_path("src/main.py")), + expected, + ); + } + assert!(toml::from_str::("class-decorator-behavior = 'invalid'").is_err()); + } + /// `type-ignore-unknown-tag-behavior` resolves through the same chain as every /// other per-file setting, so a build system can supply it. `default_config` /// reaches it by the same path once the target config declines to answer. @@ -5405,6 +5459,7 @@ output-format = "omit-errors" untyped_def_behavior: Some(UntypedDefBehavior::CheckAndInferReturnType), check_unannotated_defs: None, infer_return_types: None, + class_decorator_behavior: None, disable_type_errors_in_ide: Some(true), ignore_errors_in_generated_code: Some(false), infer_with_first_use: Some(true), @@ -5451,6 +5506,7 @@ output-format = "omit-errors" untyped_def_behavior: Some(UntypedDefBehavior::CheckAndInferReturnType), check_unannotated_defs: None, infer_return_types: None, + class_decorator_behavior: None, disable_type_errors_in_ide: Some(true), ignore_errors_in_generated_code: Some(false), infer_with_first_use: Some(true), diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 044fa0c1cc..6de33cd088 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -150,6 +150,7 @@ use crate::binding::narrow::NarrowOp; use crate::binding::narrow::identifier_and_chain_for_expr; use crate::binding::narrow::identifier_and_chain_prefix_for_expr; use crate::binding::shape_type::BoundShapeComment; +use crate::config::base::ClassDecoratorBehavior; use crate::config::error_kind::ErrorKind; use crate::error::collector::ErrorCollector; use crate::error::context::ErrorContext; @@ -7426,33 +7427,47 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } let mut ty = self.heap.mk_class_def(cls.dupe()); + let behavior = self.solver().config.class_decorator_behavior; for decorator_key in decorators.iter().rev() { - if self.bindings().get(*decorator_key).is_class_metadata { + if behavior == ClassDecoratorBehavior::Ignore + || self.bindings().get(*decorator_key).is_class_metadata + { continue; } let decorator = self.get_idx(*decorator_key); - let preserves_class = matches!(&decorator.ty, Type::Any(_)) - || decorator - .ty - .toplevel_callable_signatures() - .any(|(callable, _)| { - matches!(&callable.ret, Type::Any(_)) - || decorator.ty.toplevel_func_metadata().is_some_and( - |meta| { - meta.flags.is_return_inferred - && matches!( - &callable.ret, - Type::Type(inner) if matches!( - &**inner, - Type::Any(AnyStyle::Explicit) - ) - ) - }, - ) - }); - if preserves_class { + // An invalid decorator must not erase the class during error recovery. + if matches!(&decorator.ty, Type::Any(AnyStyle::Error)) { continue; } + if behavior == ClassDecoratorBehavior::TypedOnly { + let instance_call = match &decorator.ty { + Type::ClassType(instance) => self.instance_as_dunder_call(instance), + _ => None, + }; + let callable = instance_call.as_ref().unwrap_or(&decorator.ty); + let preserves_class = matches!(callable, Type::Any(_)) + || callable + .toplevel_callable_signatures() + .any(|(signature, _)| { + matches!(&signature.ret, Type::Any(_)) + || signature.ret.has_top_level_implicit_any() + || callable.toplevel_func_metadata().is_some_and( + |meta| { + meta.flags.is_return_inferred + && matches!( + &signature.ret, + Type::Type(inner) if matches!( + &**inner, + Type::Any(AnyStyle::Explicit) + ) + ) + }, + ) + }); + if preserves_class { + continue; + } + } let range = self.bindings().idx_to_key(*decorator_key).range(); ty = self.apply_class_decorator(decorator.ty.clone(), ty, range, errors); } diff --git a/pyrefly/lib/solver/solver.rs b/pyrefly/lib/solver/solver.rs index 677048b9a0..d114b3a703 100644 --- a/pyrefly/lib/solver/solver.rs +++ b/pyrefly/lib/solver/solver.rs @@ -51,6 +51,7 @@ use vec1::Vec1; use crate::alt::answers::LookupAnswer; use crate::alt::answers_solver::AnswersSolver; use crate::alt::attr::AttrSubsetError; +use crate::config::base::ClassDecoratorBehavior; use crate::config::error_kind::ErrorKind; use crate::error::collector::ErrorBuilder; use crate::error::collector::ErrorCollector; @@ -556,6 +557,7 @@ struct VarState { #[derive(Debug, Clone, Copy, Default)] pub struct SolverConfig { + pub class_decorator_behavior: ClassDecoratorBehavior, pub infer_with_first_use: bool, pub tensor_shapes: bool, pub strict_callable_subtyping: bool, @@ -4558,6 +4560,7 @@ mod tests { #[test] fn finishing_takes_the_restriction_record_off_the_answer() { let solver = Solver::new(SolverConfig { + class_decorator_behavior: ClassDecoratorBehavior::default(), infer_with_first_use: true, tensor_shapes: false, strict_callable_subtyping: false, diff --git a/pyrefly/lib/state/state.rs b/pyrefly/lib/state/state.rs index 2497713bec..bea37eb17f 100644 --- a/pyrefly/lib/state/state.rs +++ b/pyrefly/lib/state/state.rs @@ -1637,6 +1637,8 @@ impl<'a> Transaction<'a> { lookup: &self.lookup(module_data), check_unannotated_defs: config.check_unannotated_defs(module_data.handle.path()), infer_return_types: config.infer_return_types(module_data.handle.path()), + class_decorator_behavior: config + .class_decorator_behavior(module_data.handle.path()), infer_with_first_use: config.infer_with_first_use(module_data.handle.path()), tensor_shapes, use_shape_comments_and_stubs: config.use_shape_comments_and_stubs(), @@ -2713,6 +2715,7 @@ impl<'a> Transaction<'a> { lookup: &self.lookup(m), check_unannotated_defs: config.check_unannotated_defs(m.handle.path()), infer_return_types: config.infer_return_types(m.handle.path()), + class_decorator_behavior: config.class_decorator_behavior(m.handle.path()), infer_with_first_use: config.infer_with_first_use(m.handle.path()), // This is a one-shot timing/diagnostic dump, so we intentionally do not // store the bit on `module_data` (no later dirty.find() re-check applies). diff --git a/pyrefly/lib/state/steps.rs b/pyrefly/lib/state/steps.rs index f427e61bd4..cfdcca1111 100644 --- a/pyrefly/lib/state/steps.rs +++ b/pyrefly/lib/state/steps.rs @@ -27,6 +27,7 @@ use crate::alt::answers::Answers; use crate::alt::answers::LookupAnswer; use crate::alt::answers::Solutions; use crate::binding::bindings::Bindings; +use crate::config::base::ClassDecoratorBehavior; use crate::config::base::InferReturnTypes; use crate::config::base::RecursionLimitConfig; use crate::error::collector::ErrorCollector; @@ -64,6 +65,7 @@ pub struct Context<'a, Lookup> { pub lookup: &'a Lookup, pub check_unannotated_defs: bool, pub infer_return_types: InferReturnTypes, + pub class_decorator_behavior: ClassDecoratorBehavior, pub infer_with_first_use: bool, pub tensor_shapes: bool, pub use_shape_comments_and_stubs: bool, @@ -562,6 +564,7 @@ impl Step { exports: Arc, ) -> Arc { let solver = Solver::new(SolverConfig { + class_decorator_behavior: ctx.class_decorator_behavior, infer_with_first_use: ctx.infer_with_first_use, tensor_shapes: ctx.tensor_shapes, strict_callable_subtyping: ctx.strict_callable_subtyping, diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 5a46fde2e6..6d4e25a406 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -7,6 +7,7 @@ use pyrefly_python::sys_info::PythonVersion; +use crate::config::base::ClassDecoratorBehavior; use crate::test::util::TestEnv; use crate::testcase; @@ -1120,6 +1121,161 @@ g(f) "#, ); +testcase!( + test_class_decorator_behavior_ignore, + TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::Ignore), + r#" +from dataclasses import dataclass +from typing import Any, assert_type + +def typed(cls: type) -> int: ... +def untyped(cls): return cls +def erased(cls: type) -> type[Any]: ... + +@typed +class Typed: ... + +@untyped +class Untyped: ... + +@erased +class Erased: ... + +assert_type(Typed, type[Typed]) +assert_type(Untyped, type[Untyped]) +assert_type(Erased, type[Erased]) +assert_type(Typed(), Typed) + +# Special decorators still provide their synthesized class behavior. +@dataclass +class Data: + value: int + +assert_type(Data(1).value, int) +"#, +); + +testcase!( + test_class_decorator_behavior_typed_only, + TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::TypedOnly), + r#" +from typing import Any, Callable, assert_type + +def typed(cls: type) -> int: ... +def untyped(cls): return cls +def explicit_any(cls: type) -> Any: ... +def inferred_type_any(cls: type[Any]): return cls +def factory() -> Callable: return untyped + +class UntypedDecorator: + def __call__(self, cls): return cls + +@typed +class Typed: ... + +@untyped +class Untyped: ... + +@explicit_any +class ExplicitAny: ... + +@inferred_type_any +class InferredTypeAny: ... + +@factory() +class Factory: ... + +@UntypedDecorator() +class Instance: ... + +assert_type(Typed, int) +assert_type(Untyped, type[Untyped]) +assert_type(ExplicitAny, type[ExplicitAny]) +assert_type(InferredTypeAny, type[InferredTypeAny]) +assert_type(Factory, type[Factory]) +assert_type(Instance, type[Instance]) +"#, +); + +testcase!( + test_class_decorator_behavior_default, + r#" +from typing import assert_type + +def typed(cls: type) -> int: ... +def untyped(cls): return cls + +@typed +class Typed: ... + +@untyped +class Untyped: ... + +assert_type(Typed, int) +assert_type(Untyped, type[Untyped]) +"#, +); + +testcase!( + test_class_decorator_behavior_all, + TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::All), + r#" +from dataclasses import dataclass +from typing import Any, Callable, assert_type + +def typed(cls: type) -> int: ... +def untyped(cls): return cls +def explicit_any(cls: type) -> Any: ... +def inferred_type_any(cls: type[Any]): return cls +def factory() -> Callable: return untyped +def to_str(value: int) -> str: ... + +class UntypedDecorator: + def __call__(self, cls): return cls + +@typed +class Typed: ... + +@untyped +class Untyped: ... + +@explicit_any +class ExplicitAny: ... + +@inferred_type_any +class InferredTypeAny: ... + +@factory() +class Factory: ... + +@UntypedDecorator() +class Instance: ... + +@to_str +@typed +class Stacked: ... + +assert_type(Typed, int) +assert_type(Untyped, Any) +assert_type(ExplicitAny, Any) +assert_type(InferredTypeAny, type[Any]) +assert_type(Factory, Any) +assert_type(Instance, Any) +assert_type(Stacked, str) + +@dataclass(kw_only=True) +class Data: + value: int + +assert_type(Data(value=1).value, int) + +@missing_decorator # E: Could not find name `missing_decorator` +class Invalid: ... + +assert_type(Invalid(), Invalid) +"#, +); + testcase!( test_class_decorator_return_type_applied, r#" diff --git a/pyrefly/lib/test/util.rs b/pyrefly/lib/test/util.rs index 9034a39df8..e1150a48cd 100644 --- a/pyrefly/lib/test/util.rs +++ b/pyrefly/lib/test/util.rs @@ -47,6 +47,7 @@ use ruff_text_size::TextSize; use tempfile::TempDir; use crate::binding::binding::KeyExport; +use crate::config::base::ClassDecoratorBehavior; use crate::config::base::InferReturnTypes; use crate::config::base::UntypedDefBehavior; use crate::config::config::ConfigFile; @@ -252,6 +253,7 @@ pub struct TestEnv { platform: PythonPlatform, check_unannotated_defs: bool, infer_return_types: InferReturnTypes, + class_decorator_behavior: ClassDecoratorBehavior, infer_with_first_use: bool, non_exhaustive_match_open_type_error: bool, recursion_depth_limit: Option, @@ -335,6 +337,7 @@ impl TestEnv { platform: PythonPlatform::default(), check_unannotated_defs: true, infer_return_types: InferReturnTypes::Checked, + class_decorator_behavior: ClassDecoratorBehavior::default(), infer_with_first_use: true, non_exhaustive_match_open_type_error: false, recursion_depth_limit: None, @@ -492,6 +495,11 @@ impl TestEnv { res } + pub fn with_class_decorator_behavior(mut self, behavior: ClassDecoratorBehavior) -> Self { + self.class_decorator_behavior = behavior; + self + } + pub fn with_recursion_depth_limit(mut self, recursion_depth_limit: u32) -> Self { self.recursion_depth_limit = Some(recursion_depth_limit); self @@ -899,6 +907,7 @@ impl TestEnv { config.python_environment.site_package_path = Some(self.site_package_path.clone()); config.root.check_unannotated_defs = Some(self.check_unannotated_defs); config.root.infer_return_types = Some(self.infer_return_types); + config.root.class_decorator_behavior = Some(self.class_decorator_behavior); config.root.infer_with_first_use = Some(self.infer_with_first_use); config.root.recursion_depth_limit = self.recursion_depth_limit; config.root.strict_callable_subtyping = Some(self.strict_callable_subtyping); diff --git a/schemas/pyrefly.json b/schemas/pyrefly.json index 2311a36b3f..bc9d116799 100644 --- a/schemas/pyrefly.json +++ b/schemas/pyrefly.json @@ -77,6 +77,12 @@ "type": "boolean", "default": true }, + "class-decorator-behavior": { + "description": "How class decorators affect class types. Ignore all decorator return types, ignore untyped decorators, or apply all decorator return types.", + "type": "string", + "enum": ["ignore", "typed-only", "all"], + "default": "typed-only" + }, "untyped-def-behavior": { "description": "How should Pyrefly treat function definitions with no parameter or return type annotations?", "type": "string", diff --git a/schemas/validate_schemas.py b/schemas/validate_schemas.py index 7638b2ccfa..c10519b543 100644 --- a/schemas/validate_schemas.py +++ b/schemas/validate_schemas.py @@ -65,6 +65,21 @@ def _make_validator(schema_file: Path): class TestPositiveValidation(unittest.TestCase): """Positive tests: valid config files should pass schema validation.""" + def test_class_decorator_behavior(self) -> None: + validator = _make_validator(SCHEMAS_DIR / "pyrefly.json") + for value in ("ignore", "typed-only", "all"): + with self.subTest(value=value): + validator.validate({"class-decorator-behavior": value}) + validator.validate( + { + "sub-config": [ + {"matches": "legacy/**", "class-decorator-behavior": value} + ] + } + ) + with self.assertRaises(jsonschema.ValidationError): + validator.validate({"class-decorator-behavior": "invalid"}) + def test_pyrefly_toml(self) -> None: toml_file = SCHEMAS_DIR / "test-pyrefly.toml" schema_file = SCHEMAS_DIR / "pyrefly.json" diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index eb73f42726..90e87f954a 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -1185,6 +1185,39 @@ in per-file `pyrefly check`; for per-file `check`, - Type: `bool` - Default: `true` - Flag equivalent: `--use-shape-comments-and-stubs=false` to opt out +### `class-decorator-behavior` + +Controls how class decorators affect the type bound to a class name. + +- `"ignore"`: Ignore decorator return types and preserve the class type. Use this + mode for compatibility with mypy's treatment of class decorators. +- `"typed-only"` (default): Apply typed decorators. Preserve the class type + when a decorator has type `Any` or returns `Any` or `Unknown`. This mode follows + the class-decorator behavior used by Pyright and ty. An explicitly annotated + `type[Any]` return still erases the class interface. An inferred `type[Any]` + return preserves it. +- `"all"`: Apply all decorator return types, including `Any` and `Unknown`. + Decorators apply from the bottom of the decorator list to the top. + +For example, a decorator annotated to return `int` changes the class name's type +to `int` in `"typed-only"` and `"all"` modes. In `"ignore"` mode, the name +retains its class type. + +```toml +class-decorator-behavior = "typed-only" +``` + +Special decorators such as `dataclass`, `dataclass_transform`, `final`, +`disjoint_base`, `runtime_checkable`, and `total_ordering` retain their supported +class behavior in all three modes. An invalid decorator also preserves the class +during error recovery. + +This option changes inferred types. Configure the `untyped-class-decorator` +error kind separately to control diagnostics for untyped class decorators. + +- Type: one of `"ignore"`, `"typed-only"`, `"all"` +- Default: `"typed-only"` +- Flag equivalent: `--class-decorator-behavior` ### `strict-callable-subtyping` @@ -1789,6 +1822,7 @@ We currently allow the following config options to be overridden in a SubConfig: - `replace-untyped-imports-with-any` - `check-unannotated-defs` - `infer-return-types` +- `class-decorator-behavior` - `ignore-errors-in-generated-code` - `permissive-ignores` - `enabled-ignores` From 77e22a00c4e392f3ed18a1344bd3bc345ff8490e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sat, 31 Oct 2026 05:41:54 +0900 Subject: [PATCH 5/8] fix generic --- pyrefly/lib/alt/function.rs | 22 ++++++----- pyrefly/lib/test/decorators.rs | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index 7ec570e75c..ff956ea064 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -2333,21 +2333,23 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } let call_target = self.as_call_target_or_error(decorator, CallStyle::FreeForm, range, errors, None); - let arg = CallArg::ty(&decoratee, range); + // Keep the class's type parameters concrete during inference, just as for generic + // function decorators. Promoting a class definition here would replace them with defaults. + let (decoratee_tparams, decoratee_arg) = match &decoratee { + Type::ClassDef(cls) => ( + self.get_class_tparams(cls).cloned(), + self.heap.mk_type_of(self.instantiate(cls)), + ), + _ => (None, decoratee.clone()), + }; + let arg = CallArg::ty(&decoratee_arg, range); let decorated = self .call_infer(call_target, &[arg], &[], range, errors, None, None, None) .ty; - // Retain class-definition metadata when the decorator returns the same nongeneric class. - // A specialized generic return must keep its inferred type arguments. - if let Type::ClassDef(cls) = &decoratee - && let Type::Type(inner) = &decorated - && let Type::ClassType(result) = &**inner - && result.class_object() == cls - && result.targs().is_empty() - { + if decorated == decoratee_arg { decoratee } else { - decorated + self.restore_decoratee_generics(decorated, decoratee_tparams) } } diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 6d4e25a406..4d7c8b5550 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -1273,6 +1273,16 @@ assert_type(Data(value=1).value, int) class Invalid: ... assert_type(Invalid(), Invalid) + +def generic_identity[T](cls: type[T]) -> type[T]: + return cls + +@generic_identity +class GenericClass[T]: + def method(self, value: T) -> T: + return value + +assert_type(GenericClass[int]().method(1), int) "#, ); @@ -1309,10 +1319,45 @@ assert_type(Child().inherited(), int) "#, ); +testcase!( + test_class_decorator_preserves_generic_class, + r#" +from typing import Callable, Generic, TypeVar, assert_type, overload + +T = TypeVar("T") + +# This is the overloaded class-preserving signature used by rich.repr.auto. +@overload +def auto(cls: type[T]) -> type[T]: ... +@overload +def auto(*, angular: bool = False) -> Callable[[type[T]], type[T]]: ... +def auto(cls=None, *, angular=False): + return cls if cls is not None else auto + +@auto +class Bare[T]: + def method(self, value: T) -> T: + return value + +@auto(angular=True) +class Factory(Generic[T]): + def method(self, value: T) -> T: + return value + +class BareChild(Bare[int]): ... +class FactoryChild(Factory[str]): ... + +assert_type(Bare[int]().method(1), int) +assert_type(Factory[str]().method("value"), str) +assert_type(BareChild().method(1), int) +assert_type(FactoryChild().method("value"), str) + "#, +); + testcase!( test_class_decorator_preserves_returned_type_arguments, r#" -from typing import Callable, assert_type, cast +from typing import Any, Callable, assert_type, cast def identity[T](cls: type[T]) -> type[T]: return cls @@ -1324,6 +1369,29 @@ class Box[T]: assert_type(Box(1).value, int) Box("bad") # E: is not assignable to parameter `value` with type `int` + +@cast("Callable[[type[Erased]], type[Erased[Any]]]", identity) +class Erased[T]: + def __init__(self, value: T) -> None: + self.value = value + +assert_type(Erased(1).value, Any) + "#, +); + +testcase!( + test_class_decorator_generic_noncallable_result, + r#" +from typing import Any, assert_type + +def instances[T](cls: type[T]) -> list[T]: ... + +@instances +class Box[T]: + value: T + +# Class type parameters cannot escape into a non-generic value. +assert_type(Box[0].value, Any) "#, ); From f34c1c17b337f9493f38a0e9418095a63d770f8c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 6 Nov 2026 21:35:11 +0900 Subject: [PATCH 6/8] fix founded problems --- pyrefly/lib/alt/function.rs | 31 +++++++++++++++ pyrefly/lib/alt/solve.rs | 29 -------------- pyrefly/lib/test/decorators.rs | 70 ++++++++++++++++++++++++++++++++++ website/docs/configuration.mdx | 9 +++-- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index ff956ea064..f17e41ca0b 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -74,6 +74,7 @@ use crate::binding::binding::KeyDecorator; use crate::binding::binding::KeyLegacyTypeParam; use crate::binding::binding::KeyUndecoratedFunction; use crate::binding::classmethod::MethodReceiverKind; +use crate::config::base::ClassDecoratorBehavior; use crate::config::error_kind::ErrorKind; use crate::error::collector::ErrorCollector; use crate::error::context::TypeCheckContext; @@ -2302,6 +2303,36 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if self.stdlib.is_bootstrapping() { return decoratee; } + if self.solver().config.class_decorator_behavior == ClassDecoratorBehavior::TypedOnly { + // Any untyped union member must preserve the class type. + let preserves_class = decorator.clone().into_unions().into_iter().any(|member| { + let instance_call = match &member { + Type::ClassType(instance) => self.instance_as_dunder_call(instance), + _ => None, + }; + let callable = instance_call.as_ref().unwrap_or(&member); + matches!(callable, Type::Any(_)) + || callable + .toplevel_callable_signatures() + .any(|(signature, _)| { + matches!(&signature.ret, Type::Any(_)) + || signature.ret.has_top_level_implicit_any() + || callable.toplevel_func_metadata().is_some_and(|meta| { + meta.flags.is_return_inferred + && matches!( + &signature.ret, + Type::Type(inner) if matches!( + &**inner, + Type::Any(AnyStyle::Explicit) + ) + ) + }) + }) + }); + if preserves_class { + return decoratee; + } + } let preserves_class = match &decorator { Type::KwCall(call) => { call.func_metadata diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 6de33cd088..4a42074556 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -7439,35 +7439,6 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if matches!(&decorator.ty, Type::Any(AnyStyle::Error)) { continue; } - if behavior == ClassDecoratorBehavior::TypedOnly { - let instance_call = match &decorator.ty { - Type::ClassType(instance) => self.instance_as_dunder_call(instance), - _ => None, - }; - let callable = instance_call.as_ref().unwrap_or(&decorator.ty); - let preserves_class = matches!(callable, Type::Any(_)) - || callable - .toplevel_callable_signatures() - .any(|(signature, _)| { - matches!(&signature.ret, Type::Any(_)) - || signature.ret.has_top_level_implicit_any() - || callable.toplevel_func_metadata().is_some_and( - |meta| { - meta.flags.is_return_inferred - && matches!( - &signature.ret, - Type::Type(inner) if matches!( - &**inner, - Type::Any(AnyStyle::Explicit) - ) - ) - }, - ) - }); - if preserves_class { - continue; - } - } let range = self.bindings().idx_to_key(*decorator_key).range(); ty = self.apply_class_decorator(decorator.ty.clone(), ty, range, errors); } diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 4d7c8b5550..4bcf2a0339 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -1197,6 +1197,76 @@ assert_type(Instance, type[Instance]) "#, ); +testcase!( + test_class_decorator_behavior_typed_only_union, + r#" +from typing import Any, Callable, assert_type + +def inferred_factory(value): + if value: + return lambda cls: cls + return value + +@inferred_factory(True) +class Inferred: + def method(self) -> int: + return 1 + +assert_type(Inferred, type[Inferred]) +assert_type(Inferred().method(), int) +value: str = Inferred().method() # E: `int` is not assignable to `str` + +def to_int(cls: type) -> int: ... +def to_str(cls: type) -> str: ... + +def typed_factory(flag: bool) -> Callable[[type], int] | Callable[[type], str]: + return to_int if flag else to_str + +@typed_factory(True) +class Typed: ... + +assert_type(Typed, int | str) + +def any_factory(value: Any) -> Callable[[type], int] | Any: + return value + +@any_factory(to_int) +class ExplicitAny: ... + +assert_type(ExplicitAny, type[ExplicitAny]) + +class UntypedDecorator: + def __call__(self, cls): + return cls + +def instance_factory(flag: bool) -> Callable[[type], int] | UntypedDecorator: + return to_int if flag else UntypedDecorator() + +@instance_factory(False) +class Instance: ... + +assert_type(Instance, type[Instance]) +"#, +); + +testcase!( + test_class_decorator_behavior_all_union, + TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::All), + r#" +from typing import Any, assert_type + +def factory(value): + if value: + return lambda cls: cls + return value + +@factory(True) +class C: ... + +assert_type(C, Any) +"#, +); + testcase!( test_class_decorator_behavior_default, r#" diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index 90e87f954a..e9734c17dd 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -1192,10 +1192,11 @@ Controls how class decorators affect the type bound to a class name. - `"ignore"`: Ignore decorator return types and preserve the class type. Use this mode for compatibility with mypy's treatment of class decorators. - `"typed-only"` (default): Apply typed decorators. Preserve the class type - when a decorator has type `Any` or returns `Any` or `Unknown`. This mode follows - the class-decorator behavior used by Pyright and ty. An explicitly annotated - `type[Any]` return still erases the class interface. An inferred `type[Any]` - return preserves it. + when a decorator has type `Any` or returns `Any` or `Unknown`. For a decorator + with a union type, any untyped member preserves the class type. This mode + follows the class-decorator behavior used by Pyright and ty. An explicitly + annotated `type[Any]` return still erases the class interface. An inferred + `type[Any]` return preserves it. - `"all"`: Apply all decorator return types, including `Any` and `Unknown`. Decorators apply from the bottom of the decorator list to the top. From de580f0c00c6e58477cf6db0b2e324a6e096c9dd Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sat, 7 Nov 2026 13:12:29 +0900 Subject: [PATCH 7/8] comment --- crates/pyrefly_config/src/base.rs | 4 +- crates/pyrefly_config/src/config.rs | 28 +++++ pyrefly/lib/alt/function.rs | 133 +++++++++++--------- pyrefly/lib/alt/solve.rs | 84 ++++++------- pyrefly/lib/alt/types/decorated_function.rs | 11 ++ pyrefly/lib/binding/bindings.rs | 14 +-- pyrefly/lib/binding/expr.rs | 6 +- pyrefly/lib/test/decorators.rs | 95 +++++++++++++- schemas/pyrefly.json | 2 +- website/docs/configuration.mdx | 34 +++-- 10 files changed, 282 insertions(+), 129 deletions(-) diff --git a/crates/pyrefly_config/src/base.rs b/crates/pyrefly_config/src/base.rs index c165329fba..278e73042d 100644 --- a/crates/pyrefly_config/src/base.rs +++ b/crates/pyrefly_config/src/base.rs @@ -65,7 +65,7 @@ pub enum InferReturnTypes { )] #[serde(rename_all = "kebab-case")] pub enum ClassDecoratorBehavior { - /// Preserve the class regardless of decorator return types. + /// Ignore inferred decorator results, but honor explicit `type[Any]` return annotations. Ignore, /// Apply typed decorators and preserve the class for untyped decorators. #[default] @@ -198,6 +198,7 @@ impl Preset { check_unannotated_defs: Some(false), infer_return_types: Some(InferReturnTypes::Never), infer_with_first_use: Some(false), + class_decorator_behavior: Some(ClassDecoratorBehavior::Ignore), enabled_ignores: Some(Tool::all()), ..Default::default() } @@ -217,6 +218,7 @@ impl Preset { check_unannotated_defs: Some(false), infer_return_types: Some(InferReturnTypes::Never), legacy_overload_expansion: Some(true), + class_decorator_behavior: Some(ClassDecoratorBehavior::Ignore), type_ignore_unknown_tag_behavior: Some(TypeIgnoreUnknownTagBehavior::Suppress), ..Default::default() } diff --git a/crates/pyrefly_config/src/config.rs b/crates/pyrefly_config/src/config.rs index 4b2bcafd85..f3609e864a 100644 --- a/crates/pyrefly_config/src/config.rs +++ b/crates/pyrefly_config/src/config.rs @@ -4042,6 +4042,34 @@ output-format = "omit-errors" assert!(toml::from_str::("class-decorator-behavior = 'invalid'").is_err()); } + #[test] + fn test_class_decorator_behavior_presets() { + for (preset, expected) in [ + (Preset::Basic, ClassDecoratorBehavior::Ignore), + (Preset::Legacy, ClassDecoratorBehavior::Ignore), + (Preset::Default, ClassDecoratorBehavior::TypedOnly), + (Preset::Strict, ClassDecoratorBehavior::TypedOnly), + (Preset::All, ClassDecoratorBehavior::TypedOnly), + ] { + for override_behavior in [ + None, + Some(ClassDecoratorBehavior::Ignore), + Some(ClassDecoratorBehavior::TypedOnly), + Some(ClassDecoratorBehavior::All), + ] { + let mut config = ConfigFile::default(); + config.interpreters.skip_interpreter_query = true; + config.root.preset = Some(preset); + config.root.class_decorator_behavior = override_behavior; + assert!(config.configure().is_empty()); + assert_eq!( + config.class_decorator_behavior(&test_path("src/main.py")), + override_behavior.unwrap_or(expected), + ); + } + } + } + /// `type-ignore-unknown-tag-behavior` resolves through the same chain as every /// other per-file setting, so a build system can supply it. `default_config` /// reaches it by the same path once the target config declines to answer. diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index f17e41ca0b..55a7f39886 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -58,6 +58,7 @@ use crate::alt::call::CallStyle; use crate::alt::call::CallTarget; use crate::alt::callable::CallArg; use crate::alt::singledispatch::DispatcherDef; +use crate::alt::types::decorated_function::ClassDecoratorKind; use crate::alt::types::decorated_function::Decorator; use crate::alt::types::decorated_function::SpecialDecorator; use crate::alt::types::decorated_function::UndecoratedFunction; @@ -74,7 +75,6 @@ use crate::binding::binding::KeyDecorator; use crate::binding::binding::KeyLegacyTypeParam; use crate::binding::binding::KeyUndecoratedFunction; use crate::binding::classmethod::MethodReceiverKind; -use crate::config::base::ClassDecoratorBehavior; use crate::config::error_kind::ErrorKind; use crate::error::collector::ErrorCollector; use crate::error::context::TypeCheckContext; @@ -2293,6 +2293,65 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { self.singledispatch_dispatcher_as_callback(widened, &application.original_decoratee) } + /// Classify once for both the untyped warning and decorator application. + pub(crate) fn class_decorator_kind(&self, decorator: &Type) -> ClassDecoratorKind { + let mut untyped = false; + let mut warn = false; + for member in decorator.clone().into_unions() { + let instance_call = match &member { + Type::ClassType(instance) => self.instance_as_dunder_call(instance), + _ => None, + }; + let callable = instance_call.as_ref().unwrap_or(&member); + if let Type::Any(style) = callable { + untyped = true; + warn |= *style != AnyStyle::Error; + continue; + } + let inferred = callable + .toplevel_func_metadata() + .is_some_and(|meta| meta.flags.is_return_inferred); + let mut signatures = callable.toplevel_callable_signatures().peekable(); + // A union is untyped if any member is untyped. An overload is untyped + // only if every signature has an untyped return. + let mut member_untyped = signatures.peek().is_some(); + let mut member_warn = false; + for (signature, _) in signatures { + let mut return_untyped = false; + for ret in signature.ret.clone().into_unions() { + match ret { + Type::Any(style) => { + return_untyped = true; + member_warn |= style == AnyStyle::Implicit; + } + Type::Type(inner) => match &*inner { + Type::Any(AnyStyle::Explicit) if !inferred => { + // An explicit erasure takes precedence over untyped alternatives. + return ClassDecoratorKind::ErasesClass; + } + Type::Any(style) => { + return_untyped = true; + member_warn |= *style != AnyStyle::Error; + } + _ => {} + }, + _ => {} + } + } + member_untyped &= return_untyped; + } + if member_untyped { + untyped = true; + warn |= member_warn; + } + } + if untyped { + ClassDecoratorKind::Untyped { warn } + } else { + ClassDecoratorKind::Typed + } + } + pub(crate) fn apply_class_decorator( &self, decorator: Type, @@ -2301,65 +2360,25 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { errors: &ErrorCollector, ) -> Type { if self.stdlib.is_bootstrapping() { + // Decorator inference depends on the builtins that bootstrapping is still constructing. return decoratee; } - if self.solver().config.class_decorator_behavior == ClassDecoratorBehavior::TypedOnly { - // Any untyped union member must preserve the class type. - let preserves_class = decorator.clone().into_unions().into_iter().any(|member| { - let instance_call = match &member { - Type::ClassType(instance) => self.instance_as_dunder_call(instance), - _ => None, - }; - let callable = instance_call.as_ref().unwrap_or(&member); - matches!(callable, Type::Any(_)) - || callable - .toplevel_callable_signatures() - .any(|(signature, _)| { - matches!(&signature.ret, Type::Any(_)) - || signature.ret.has_top_level_implicit_any() - || callable.toplevel_func_metadata().is_some_and(|meta| { - meta.flags.is_return_inferred - && matches!( - &signature.ret, - Type::Type(inner) if matches!( - &**inner, - Type::Any(AnyStyle::Explicit) - ) - ) - }) - }) - }); - if preserves_class { - return decoratee; - } - } - let preserves_class = match &decorator { - Type::KwCall(call) => { - call.func_metadata - .flags - .dataclass_transform_metadata - .is_some() - || call.has_function_kind(FunctionKind::Dataclass) - || call.has_function_kind(FunctionKind::DataclassTransform) - || call.has_function_kind(FunctionKind::DisjointBase) - || call.has_function_kind(FunctionKind::Final) - || call.has_function_kind(FunctionKind::RuntimeCheckable) - || call.has_function_kind(FunctionKind::TotalOrdering) - } - _ => decorator.toplevel_func_metadata().is_some_and(|meta| { - meta.flags.dataclass_transform_metadata.is_some() - || matches!( - meta.kind, - FunctionKind::Dataclass - | FunctionKind::DataclassTransform - | FunctionKind::DisjointBase - | FunctionKind::Final - | FunctionKind::RuntimeCheckable - | FunctionKind::TotalOrdering - ) - }), + let metadata = match &decorator { + Type::KwCall(call) => Some(&call.func_metadata), + _ => decorator.toplevel_func_metadata(), }; - if preserves_class { + if metadata.is_some_and(|meta| { + meta.flags.dataclass_transform_metadata.is_some() + || matches!( + meta.kind, + FunctionKind::Dataclass + | FunctionKind::DataclassTransform + | FunctionKind::DisjointBase + | FunctionKind::Final + | FunctionKind::RuntimeCheckable + | FunctionKind::TotalOrdering + ) + }) { return decoratee; } let call_target = diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 4a42074556..3f3d2d2a67 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -75,6 +75,7 @@ use crate::alt::types::class_metadata::ClassMetadata; use crate::alt::types::class_metadata::ClassMro; use crate::alt::types::class_metadata::ClassSynthesizedFields; use crate::alt::types::class_metadata::DataclassKind; +use crate::alt::types::decorated_function::ClassDecoratorKind; use crate::alt::types::decorated_function::Decorator; use crate::alt::types::decorated_function::UndecoratedFunction; use crate::alt::types::legacy_lookup::LegacyTypeParameterLookup; @@ -7388,59 +7389,50 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { Binding::ClassDef(x, decorators) => match &self.get_idx(*x).0 { None => self.heap.mk_any_implicit(), Some(cls) => { - // A class decorator obscures the class if its own type is `Any`, or if it - // returns an unknown type. Read the return signatures before applying the - // decorators so this diagnostic does not depend on application results. - // Like for functions, only the first decorator (in application order) that - // makes the result unknown is blamed for it. - let returns_unknown = |callable: &Type| { - let mut signatures = callable.toplevel_callable_signatures().peekable(); - signatures.peek().is_some() - && signatures.all(|(sig, _)| sig.ret.has_top_level_implicit_any()) - }; - let mut obscured = false; - for idx in decorators.iter().rev() { - let decorator_ty = &self.get_idx(*idx).ty; - let decorator_is_any = matches!( - decorator_ty, - Type::Any(AnyStyle::Implicit | AnyStyle::Explicit) - ); - if decorator_is_any - || (!obscured - && match decorator_ty { - Type::ClassType(instance) => self - .instance_as_dunder_call(instance) - .is_some_and(|call| returns_unknown(&call)), - _ => returns_unknown(decorator_ty), - }) - { - obscured = true; - self.error( - errors, - self.bindings().idx_to_key(*idx).range(), - ErrorKind::UntypedClassDecorator, - format!( - "Untyped class decorator may modify `{}` in unexpected ways", - cls.name() - ), - ); - } - } let mut ty = self.heap.mk_class_def(cls.dupe()); let behavior = self.solver().config.class_decorator_behavior; + let mut obscured = false; for decorator_key in decorators.iter().rev() { - if behavior == ClassDecoratorBehavior::Ignore - || self.bindings().get(*decorator_key).is_class_metadata - { - continue; - } let decorator = self.get_idx(*decorator_key); - // An invalid decorator must not erase the class during error recovery. - if matches!(&decorator.ty, Type::Any(AnyStyle::Error)) { + if self.bindings().get(*decorator_key).is_class_metadata + || decorator.ty.is_error() + { continue; } let range = self.bindings().idx_to_key(*decorator_key).range(); - ty = self.apply_class_decorator(decorator.ty.clone(), ty, range, errors); + let kind = self.class_decorator_kind(&decorator.ty); + if let ClassDecoratorKind::Untyped { warn } = kind { + if warn && !obscured { + self.error( + errors, + range, + ErrorKind::UntypedClassDecorator, + format!( + "Untyped class decorator may modify `{}` in unexpected ways", + cls.name() + ), + ); + } + obscured = true; + } + match (behavior, kind) { + (ClassDecoratorBehavior::Ignore, ClassDecoratorKind::ErasesClass) => { + ty = self.heap.mk_type_of(self.heap.mk_any_explicit()); + } + (ClassDecoratorBehavior::Ignore, _) + | ( + ClassDecoratorBehavior::TypedOnly, + ClassDecoratorKind::Untyped { .. }, + ) => {} + _ => { + ty = self.apply_class_decorator( + decorator.ty.clone(), + ty, + range, + errors, + ); + } + } } ty } diff --git a/pyrefly/lib/alt/types/decorated_function.rs b/pyrefly/lib/alt/types/decorated_function.rs index d1d0d08b1f..af32873f8c 100644 --- a/pyrefly/lib/alt/types/decorated_function.rs +++ b/pyrefly/lib/alt/types/decorated_function.rs @@ -56,6 +56,17 @@ pub struct Decorator { pub deprecation: Option, } +/// Whether a class decorator can be tracked or explicitly erases the class type. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ClassDecoratorKind { + Typed, + /// Explicit `Any` returns disable tracking without an implicit-unknown warning. + Untyped { + warn: bool, + }, + ErasesClass, +} + impl Display for Decorator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Decorator[{}]", self.ty) diff --git a/pyrefly/lib/binding/bindings.rs b/pyrefly/lib/binding/bindings.rs index a9d86234da..e96070e2fd 100644 --- a/pyrefly/lib/binding/bindings.rs +++ b/pyrefly/lib/binding/bindings.rs @@ -2705,15 +2705,13 @@ impl<'a> BindingsBuilder<'a> { Binding::ForwardToFirstUse(target_idx) } else if deferred.usage.is_static() && let Some(Binding::ClassDef(class_idx, decorators)) = self.idx_to_binding(target_idx) - && !decorators.is_empty() - && let Some(BindingClass::ClassDef(class)) = self.idx_to_binding(*class_idx) - && class - .def - .range - .contains_range(self.idx_to_key(deferred.bound_name_idx).range()) + && decorators.iter().any(|decorator| { + self.idx_to_key(*decorator) + .range() + .contains_range(self.idx_to_key(deferred.bound_name_idx).range()) + }) { - // Self-references in class annotations refer to the undecorated class. Depending on - // its decorated value would create a cycle when a decorator's signature uses it. + // Self-references in decorator expressions use the undecorated class to avoid cycles. Binding::ClassDef(*class_idx, Box::new([])) } else if let Some(b) = self.idx_to_binding(target_idx) && matches!( diff --git a/pyrefly/lib/binding/expr.rs b/pyrefly/lib/binding/expr.rs index 9d771e2156..824a0a03b0 100644 --- a/pyrefly/lib/binding/expr.rs +++ b/pyrefly/lib/binding/expr.rs @@ -1651,8 +1651,10 @@ impl<'a> BindingsBuilder<'a> { .expression .as_call_expr() .map_or(&x.expression, |call| &call.func); - let is_class_metadata = - self.as_special_export(decorator_func) == Some(SpecialExport::ShapedArray); + let is_class_metadata = matches!( + self.as_special_export(decorator_func), + Some(SpecialExport::ShapeVars | SpecialExport::StaticJaxtyping) + ); self.ensure_expr(&mut x.expression, usage); let trailing_name = Ast::decorator_trailing_name(&x.expression).map(Name::new); let k = self.insert_binding( diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 4bcf2a0339..4a4c1cda38 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -1143,7 +1143,7 @@ class Erased: ... assert_type(Typed, type[Typed]) assert_type(Untyped, type[Untyped]) -assert_type(Erased, type[Erased]) +assert_type(Erased, type[Any]) assert_type(Typed(), Typed) # Special decorators still provide their synthesized class behavior. @@ -1267,6 +1267,99 @@ assert_type(C, Any) "#, ); +testcase!( + test_class_decorator_type_return_annotations, + TestEnv::new().enable_untyped_class_decorator_error(), + r#" +from typing import Any, assert_type + +def bare(cls: type) -> type: + return cls + +def bare_union(cls: type) -> type | None: + return cls + +def explicit(cls: type) -> type[Any]: + return cls + +def explicit_union(cls: type) -> type[Any] | None: + return cls + +@bare # E: Untyped class decorator +class Bare: + x: int = 1 + +@bare_union # E: Untyped class decorator +class BareUnion: + x: int = 1 + +@explicit +class Explicit: + x: int = 1 + +@explicit_union +class ExplicitUnion: ... + +assert_type(Bare, type[Bare]) +assert_type(Bare().x, int) +assert_type(BareUnion, type[BareUnion]) +assert_type(BareUnion().x, int) +assert_type(Explicit, type[Any]) +assert_type(ExplicitUnion, type[Any] | None) +"#, +); + +testcase!( + test_class_decorator_overloads_and_union_classification, + TestEnv::new().enable_untyped_class_decorator_error(), + r#" +from typing import Any, assert_type, overload + +@overload +def deco(cls: type[int]) -> Any: ... +@overload +def deco(cls: type) -> int: ... +def deco(cls: Any) -> Any: ... + +@deco +class Typed: ... + +assert_type(Typed, int) + +def factory(value): + if value: + return lambda cls: cls + return value + +@factory(True) # E: Untyped class decorator +class Untyped: + x: int = 1 + +assert_type(Untyped, type[Untyped]) +assert_type(Untyped().x, int) +"#, +); + +testcase!( + test_class_decorator_self_references_in_annotations, + r#" +from typing import Callable, assert_type, cast + +def to_wrapper(cls: type) -> Callable[[], int]: ... + +@to_wrapper +class C: + other: "C" # E: Expected a type form + def clone(self) -> "C": ... # E: Expected a type form + +@cast("Callable[[type[D]], int]", lambda cls: 1) +class D: ... + +assert_type(C, Callable[[], int]) +assert_type(D, int) +"#, +); + testcase!( test_class_decorator_behavior_default, r#" diff --git a/schemas/pyrefly.json b/schemas/pyrefly.json index bc9d116799..1cc0c0eeb8 100644 --- a/schemas/pyrefly.json +++ b/schemas/pyrefly.json @@ -78,7 +78,7 @@ "default": true }, "class-decorator-behavior": { - "description": "How class decorators affect class types. Ignore all decorator return types, ignore untyped decorators, or apply all decorator return types.", + "description": "How class decorators affect class types. Ignore decorator results except explicit type[Any] erasure, apply only typed decorators, or apply all decorators. Defaults to ignore in basic and legacy presets, and typed-only otherwise.", "type": "string", "enum": ["ignore", "typed-only", "all"], "default": "typed-only" diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index e9734c17dd..43d7492153 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -894,7 +894,7 @@ Silences every error kind. Other settings (scalar flags, behavior settings) are A minimal-checking preset used when automatic configuration selection finds no migratable mypy/pyright configuration (or migration fails). It can also be selected explicitly with `preset = "basic"`, `--preset basic`, or the IDE's `"basic"` type-checking mode. It enables parse errors plus a small set of high-confidence, locally-fixable checks. Stricter checks like override validation, annotation completeness, and broader call-shape or assignment validation are disabled. -- Sets [`check-unannotated-defs`](#check-unannotated-defs) `= false`, [`infer-return-types`](#infer-return-types) `= "never"`, [`infer-with-first-use`](#infer-with-first-use) `= false`, and [`enabled-ignores`](#enabled-ignores) to every tool (equivalent to [`permissive-ignores`](#permissive-ignores) `= true`) +- Sets [`check-unannotated-defs`](#check-unannotated-defs) `= false`, [`infer-return-types`](#infer-return-types) `= "never"`, [`infer-with-first-use`](#infer-with-first-use) `= false`, [`class-decorator-behavior`](#class-decorator-behavior) `= "ignore"`, and [`enabled-ignores`](#enabled-ignores) to every tool (equivalent to [`permissive-ignores`](#permissive-ignores) `= true`) - Enables (as errors): [`bad-class-definition`](./error-kinds.mdx#bad-class-definition), [`bad-instantiation`](./error-kinds.mdx#bad-instantiation), [`bad-keyword-argument`](./error-kinds.mdx#bad-keyword-argument), [`bad-raise`](./error-kinds.mdx#bad-raise), [`bad-unpacking`](./error-kinds.mdx#bad-unpacking), [`division-by-zero`](./error-kinds.mdx#division-by-zero), [`invalid-annotation`](./error-kinds.mdx#invalid-annotation), [`invalid-literal`](./error-kinds.mdx#invalid-literal), [`invalid-super-call`](./error-kinds.mdx#invalid-super-call), [`invalid-syntax`](./error-kinds.mdx#invalid-syntax), [`missing-import`](./error-kinds.mdx#missing-import), [`not-async`](./error-kinds.mdx#not-async), [`parse-error`](./error-kinds.mdx#parse-error), [`unexpected-keyword`](./error-kinds.mdx#unexpected-keyword), [`unexpected-positional-argument`](./error-kinds.mdx#unexpected-positional-argument), [`unknown-name`](./error-kinds.mdx#unknown-name), [`unused-coroutine`](./error-kinds.mdx#unused-coroutine) - Enables (as warnings): [`fallback-search-path-relative-import`](./error-kinds.mdx#fallback-search-path-relative-import) - Keeps as hints: [`inactive-code`](./error-kinds.mdx#inactive-code), [`unused-import`](./error-kinds.mdx#unused-import), [`unused-parameter`](./error-kinds.mdx#unused-parameter), [`unused-variable`](./error-kinds.mdx#unused-variable) @@ -905,7 +905,7 @@ A minimal-checking preset used when automatic configuration selection finds no m A looser, less-strict preset intended for codebases migrating from mypy. This is the preset `pyrefly init` writes when it detects an existing mypy configuration. Pyrefly does not aim to mimic mypy precisely, but the preset preserves selected defaults that would otherwise produce new migration errors. -- Sets [`check-unannotated-defs`](#check-unannotated-defs) `= false`, [`infer-return-types`](#infer-return-types) `= "never"`, [`legacy-overload-expansion`](#legacy-overload-expansion) `= true`, [`replace-untyped-imports-with-any`](#replace-untyped-imports-with-any) `= ["*"]` +- Sets [`check-unannotated-defs`](#check-unannotated-defs) `= false`, [`infer-return-types`](#infer-return-types) `= "never"`, [`legacy-overload-expansion`](#legacy-overload-expansion) `= true`, [`replace-untyped-imports-with-any`](#replace-untyped-imports-with-any) `= ["*"]`, [`class-decorator-behavior`](#class-decorator-behavior) `= "ignore"` - Sets [`type-ignore-unknown-tag-behavior`](#type-ignore-unknown-tag-behavior) `= "suppress"` so existing mypy-coded ignores retain their legacy blanket-suppression behavior. - Disables: [`bad-override-mutable-attribute`](./error-kinds.mdx#bad-override-mutable-attribute), [`bad-override-param-name`](./error-kinds.mdx#bad-override-param-name), [`unbound-name`](./error-kinds.mdx#unbound-name). When both the parent and child members are plain read-write attributes, widening or incompatible types report plain `bad-override`, which this preset leaves enabled. Some overrides involving properties, including incompatible property getters, still report `bad-override-mutable-attribute` and are suppressed by this preset. @@ -1185,20 +1185,25 @@ in per-file `pyrefly check`; for per-file `check`, - Type: `bool` - Default: `true` - Flag equivalent: `--use-shape-comments-and-stubs=false` to opt out + ### `class-decorator-behavior` Controls how class decorators affect the type bound to a class name. -- `"ignore"`: Ignore decorator return types and preserve the class type. Use this - mode for compatibility with mypy's treatment of class decorators. -- `"typed-only"` (default): Apply typed decorators. Preserve the class type - when a decorator has type `Any` or returns `Any` or `Unknown`. For a decorator - with a union type, any untyped member preserves the class type. This mode - follows the class-decorator behavior used by Pyright and ty. An explicitly - annotated `type[Any]` return still erases the class interface. An inferred - `type[Any]` return preserves it. +- `"ignore"`: Preserve the class type without inferring decorator results. + An explicit `type[Any]` return annotation still erases the class interface. + This is the default for the `basic` and `legacy` presets. +- `"typed-only"`: Apply typed decorators. Preserve the class type when a + decorator has type `Any` or returns `Any`, `Unknown`, or `type[Unknown]`. + A bare `type` return annotation and an inferred `type[Any]` return preserve + the class interface. Return unions also follow these rules. An explicit + `type[Any]` return annotation takes precedence and permits class erasure. + A decorator union is untyped if any member is untyped. An overloaded + decorator is untyped only if every signature has an untyped return. + This is the default for the `default`, `strict`, and `all` presets. - `"all"`: Apply all decorator return types, including `Any` and `Unknown`. Decorators apply from the bottom of the decorator list to the top. + Use this opt-in mode to track type erasure through untyped decorators too. For example, a decorator annotated to return `int` changes the class name's type to `int` in `"typed-only"` and `"all"` modes. In `"ignore"` mode, the name @@ -1213,11 +1218,14 @@ Special decorators such as `dataclass`, `dataclass_transform`, `final`, class behavior in all three modes. An invalid decorator also preserves the class during error recovery. -This option changes inferred types. Configure the `untyped-class-decorator` -error kind separately to control diagnostics for untyped class decorators. +The same untyped classification controls decorator application and the +`untyped-class-decorator` diagnostic. An explicit `Any` return disables tracking +without this warning. Implicit or inferred unknown types receive the warning +only for the first untyped decorator in application order. +Configure its severity separately to control reporting. - Type: one of `"ignore"`, `"typed-only"`, `"all"` -- Default: `"typed-only"` +- Default: `"ignore"` for `basic` and `legacy`, `"typed-only"` otherwise - Flag equivalent: `--class-decorator-behavior` ### `strict-callable-subtyping` From 52d88f717ef4e953770b8ccf38275d0e45833907 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 10 Nov 2026 12:09:01 +0900 Subject: [PATCH 8/8] comment --- pyrefly/lib/alt/call.rs | 38 ++++++ pyrefly/lib/alt/class/class_field.rs | 2 +- pyrefly/lib/alt/function.rs | 85 ++----------- pyrefly/lib/alt/solve.rs | 88 ++++++++++---- pyrefly/lib/alt/types/decorated_function.rs | 11 -- pyrefly/lib/test/decorators.rs | 126 ++++++++++++++++++++ website/docs/configuration.mdx | 20 ++-- 7 files changed, 252 insertions(+), 118 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index ea77018fb5..8ccaa94662 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -3094,6 +3094,44 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } + /// Preserve unknown `__new__` returns when a constructor is used as a class decorator. + pub(crate) fn class_decorator_constructor_to_callable(&self, cls: &ClassType) -> Type { + let mut constructor = self.constructor_to_callable(cls); + if let Some(new_member) = + self.get_class_member_with_defining_class(cls.class_object(), &dunder::NEW) + && new_member.value.is_function_without_return_annotation() + && let Some(new_metadata) = new_member.value.ty().toplevel_func_metadata() + { + // Ordinary construction gives an unannotated `__new__` a synthetic `Self` return. + // That fallback does not describe a decorator that returns its class argument. + constructor = self.unions( + constructor + .into_unions() + .into_iter() + .map(|mut member| { + if member + .toplevel_func_metadata() + .is_some_and(|meta| meta.kind == new_metadata.kind) + { + member.transform_toplevel_callable_signatures(|signature, _| { + signature.ret = self.heap.mk_any_implicit(); + }); + } + member + }) + .collect(), + ); + } + if let Some(tparams) = self + .get_class_tparams(cls.class_object()) + .filter(|p| !p.is_empty()) + { + self.normalize_class_constructor_tparams(constructor, tparams) + } else { + constructor + } + } + /// Convert a bare class definition while keeping its type parameters generic. pub fn constructor_to_callable_for_class_def(&self, cls: &Class) -> Type { let Some(class_tparams) = self diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 411c865dd0..d897644a53 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1242,7 +1242,7 @@ impl ClassField { } } - fn is_function_without_return_annotation(&self) -> bool { + pub(crate) fn is_function_without_return_annotation(&self) -> bool { match &self.0 { ClassFieldInner::Property { .. } => false, ClassFieldInner::Descriptor { .. } => false, diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index 55a7f39886..03c10c9805 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -58,7 +58,6 @@ use crate::alt::call::CallStyle; use crate::alt::call::CallTarget; use crate::alt::callable::CallArg; use crate::alt::singledispatch::DispatcherDef; -use crate::alt::types::decorated_function::ClassDecoratorKind; use crate::alt::types::decorated_function::Decorator; use crate::alt::types::decorated_function::SpecialDecorator; use crate::alt::types::decorated_function::UndecoratedFunction; @@ -2293,63 +2292,19 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { self.singledispatch_dispatcher_as_callback(widened, &application.original_decoratee) } - /// Classify once for both the untyped warning and decorator application. - pub(crate) fn class_decorator_kind(&self, decorator: &Type) -> ClassDecoratorKind { - let mut untyped = false; - let mut warn = false; - for member in decorator.clone().into_unions() { - let instance_call = match &member { - Type::ClassType(instance) => self.instance_as_dunder_call(instance), - _ => None, - }; - let callable = instance_call.as_ref().unwrap_or(&member); - if let Type::Any(style) = callable { - untyped = true; - warn |= *style != AnyStyle::Error; - continue; - } - let inferred = callable + /// Whether a return annotation explicitly opts out of tracking the class interface. + pub(crate) fn class_decorator_erases_class(&self, decorator: &Type) -> bool { + decorator.clone().into_unions().iter().any(|member| { + let inferred = member .toplevel_func_metadata() .is_some_and(|meta| meta.flags.is_return_inferred); - let mut signatures = callable.toplevel_callable_signatures().peekable(); - // A union is untyped if any member is untyped. An overload is untyped - // only if every signature has an untyped return. - let mut member_untyped = signatures.peek().is_some(); - let mut member_warn = false; - for (signature, _) in signatures { - let mut return_untyped = false; - for ret in signature.ret.clone().into_unions() { - match ret { - Type::Any(style) => { - return_untyped = true; - member_warn |= style == AnyStyle::Implicit; - } - Type::Type(inner) => match &*inner { - Type::Any(AnyStyle::Explicit) if !inferred => { - // An explicit erasure takes precedence over untyped alternatives. - return ClassDecoratorKind::ErasesClass; - } - Type::Any(style) => { - return_untyped = true; - member_warn |= *style != AnyStyle::Error; - } - _ => {} - }, - _ => {} - } - } - member_untyped &= return_untyped; - } - if member_untyped { - untyped = true; - warn |= member_warn; - } - } - if untyped { - ClassDecoratorKind::Untyped { warn } - } else { - ClassDecoratorKind::Typed - } + !inferred + && member.toplevel_callable_signatures().any(|(signature, _)| { + signature.ret.clone().into_unions().iter().any(|ret| { + matches!(ret, Type::Type(inner) if matches!(&**inner, Type::Any(AnyStyle::Explicit))) + }) + }) + }) } pub(crate) fn apply_class_decorator( @@ -2363,24 +2318,6 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // Decorator inference depends on the builtins that bootstrapping is still constructing. return decoratee; } - let metadata = match &decorator { - Type::KwCall(call) => Some(&call.func_metadata), - _ => decorator.toplevel_func_metadata(), - }; - if metadata.is_some_and(|meta| { - meta.flags.dataclass_transform_metadata.is_some() - || matches!( - meta.kind, - FunctionKind::Dataclass - | FunctionKind::DataclassTransform - | FunctionKind::DisjointBase - | FunctionKind::Final - | FunctionKind::RuntimeCheckable - | FunctionKind::TotalOrdering - ) - }) { - return decoratee; - } let call_target = self.as_call_target_or_error(decorator, CallStyle::FreeForm, range, errors, None); // Keep the class's type parameters concrete during inference, just as for generic diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 3f3d2d2a67..1842af9b3d 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -19,6 +19,7 @@ use pyrefly_types::dimension::Int; use pyrefly_types::dimension::gradual_size; use pyrefly_types::facet::FacetKind; use pyrefly_types::function::BodyKind; +use pyrefly_types::function::FunctionKind; use pyrefly_types::identity::IdentityIgnored; use pyrefly_types::known_class::KnownClass; use pyrefly_types::shaped_array::IntTuple; @@ -75,7 +76,6 @@ use crate::alt::types::class_metadata::ClassMetadata; use crate::alt::types::class_metadata::ClassMro; use crate::alt::types::class_metadata::ClassSynthesizedFields; use crate::alt::types::class_metadata::DataclassKind; -use crate::alt::types::decorated_function::ClassDecoratorKind; use crate::alt::types::decorated_function::Decorator; use crate::alt::types::decorated_function::UndecoratedFunction; use crate::alt::types::legacy_lookup::LegacyTypeParameterLookup; @@ -7394,14 +7394,77 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let mut obscured = false; for decorator_key in decorators.iter().rev() { let decorator = self.get_idx(*decorator_key); + let metadata = match &decorator.ty { + Type::KwCall(call) => Some(&call.func_metadata), + ty => ty.toplevel_func_metadata(), + }; if self.bindings().get(*decorator_key).is_class_metadata || decorator.ty.is_error() + || metadata.is_some_and(|meta| { + meta.flags.dataclass_transform_metadata.is_some() + || matches!( + meta.kind, + FunctionKind::Dataclass + | FunctionKind::DataclassTransform + | FunctionKind::DisjointBase + | FunctionKind::Final + | FunctionKind::RuntimeCheckable + | FunctionKind::TotalOrdering + ) + }) { continue; } + let callable = self.unions( + decorator + .ty + .clone() + .into_unions() + .into_iter() + .map(|member| match &member { + Type::ClassDef(cls) => self + .class_decorator_constructor_to_callable( + &self.as_class_type_unchecked(cls), + ), + Type::Type(inner) if let Type::ClassType(cls) = &**inner => { + self.class_decorator_constructor_to_callable(cls) + } + Type::ClassType(instance) => { + self.instance_as_dunder_call(instance).unwrap_or(member) + } + _ => member, + }) + .collect(), + ); + if behavior == ClassDecoratorBehavior::Ignore { + if self.class_decorator_erases_class(&callable) { + ty = self.heap.mk_type_of(self.heap.mk_any_explicit()); + } + continue; + } let range = self.bindings().idx_to_key(*decorator_key).range(); - let kind = self.class_decorator_kind(&decorator.ty); - if let ClassDecoratorKind::Untyped { warn } = kind { + let decorated = + self.apply_class_decorator(callable.clone(), ty.clone(), range, errors); + let mut untyped = decorator.ty.is_any(); + let mut warn = untyped; + for member in decorated.clone().into_unions() { + match &member { + Type::Any(style) => { + untyped = true; + warn |= *style == AnyStyle::Implicit; + } + Type::Type(inner) + if let Type::Any(style) = &**inner + && (*style != AnyStyle::Explicit + || !self.class_decorator_erases_class(&callable)) => + { + untyped = true; + warn |= *style != AnyStyle::Error; + } + _ => {} + } + } + if untyped { if warn && !obscured { self.error( errors, @@ -7415,23 +7478,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } obscured = true; } - match (behavior, kind) { - (ClassDecoratorBehavior::Ignore, ClassDecoratorKind::ErasesClass) => { - ty = self.heap.mk_type_of(self.heap.mk_any_explicit()); - } - (ClassDecoratorBehavior::Ignore, _) - | ( - ClassDecoratorBehavior::TypedOnly, - ClassDecoratorKind::Untyped { .. }, - ) => {} - _ => { - ty = self.apply_class_decorator( - decorator.ty.clone(), - ty, - range, - errors, - ); - } + if behavior == ClassDecoratorBehavior::All || !untyped { + ty = decorated; } } ty diff --git a/pyrefly/lib/alt/types/decorated_function.rs b/pyrefly/lib/alt/types/decorated_function.rs index af32873f8c..d1d0d08b1f 100644 --- a/pyrefly/lib/alt/types/decorated_function.rs +++ b/pyrefly/lib/alt/types/decorated_function.rs @@ -56,17 +56,6 @@ pub struct Decorator { pub deprecation: Option, } -/// Whether a class decorator can be tracked or explicitly erases the class type. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum ClassDecoratorKind { - Typed, - /// Explicit `Any` returns disable tracking without an implicit-unknown warning. - Untyped { - warn: bool, - }, - ErasesClass, -} - impl Display for Decorator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Decorator[{}]", self.ty) diff --git a/pyrefly/lib/test/decorators.rs b/pyrefly/lib/test/decorators.rs index 4a4c1cda38..99c7fe271c 100644 --- a/pyrefly/lib/test/decorators.rs +++ b/pyrefly/lib/test/decorators.rs @@ -1121,6 +1121,132 @@ g(f) "#, ); +testcase!( + test_class_decorator_untyped_constructor_result, + TestEnv::new().enable_untyped_class_decorator_error(), + r#" +from typing import assert_type + +class Passthrough: + def __new__(cls, c): + return c + +@Passthrough # E: Untyped class decorator +class D: + x: int = 1 + +assert_type(D, type[D]) +assert_type(D.x, int) + +def decorator_factory() -> type[Passthrough]: + return Passthrough + +@decorator_factory() # E: Untyped class decorator +class E: + x: int = 1 + +assert_type(E, type[E]) +assert_type(E.x, int) + +class Wrapper[T]: + def __init__(self, wrapped: type[T]) -> None: + self.wrapped = wrapped + +@Wrapper +class Wrapped: + x: int = 1 + +assert_type(Wrapped.wrapped().x, int) +"#, +); + +testcase!( + test_class_decorator_selected_overload_returns_any, + r#" +from typing import Any, assert_type, overload + +@overload +def deco(cls: type) -> Any: ... +@overload +def deco(cls: str) -> int: ... +def deco(cls: Any) -> Any: ... + +@deco +class C: + x: int = 1 + +assert_type(C, type[C]) +assert_type(C.x, int) + +@overload +def erasing_overload(cls: type) -> Any: ... +@overload +def erasing_overload(cls: str) -> type[Any]: ... +def erasing_overload(cls: Any) -> Any: ... + +@erasing_overload +class D: + x: int = 1 + +assert_type(D, type[D]) +assert_type(D.x, int) +"#, +); + +testcase!( + test_class_decorator_untyped_call_results_all, + TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::All), + r#" +from typing import Any, assert_type, overload + +class Passthrough: + def __new__(cls, c): + return c + +@Passthrough +class D: ... + +@overload +def deco(cls: type) -> Any: ... +@overload +def deco(cls: str) -> int: ... +def deco(cls: Any) -> Any: ... + +@deco +class C: ... + +assert_type(D, Any) +assert_type(C, Any) +"#, +); + +testcase!( + test_class_decorator_ignore_dataclass_transform_erasure, + TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::Ignore), + r#" +from typing import Any, Callable, assert_type, dataclass_transform + +@dataclass_transform() +def model(cls: type) -> type[Any]: ... + +@dataclass_transform() +def factory(*, frozen: bool = False) -> Callable[[type], type[Any]]: ... + +@model +class Direct: + x: int + +@factory(frozen=True) +class Factory: + x: int + +assert_type(Direct(x=1).x, int) +assert_type(Factory(x=1).x, int) +Direct(x="wrong") # E: Argument `Literal['wrong']` is not assignable to parameter `x` with type `int` +Factory(x="wrong") # E: Argument `Literal['wrong']` is not assignable to parameter `x` with type `int` +"#, +); + testcase!( test_class_decorator_behavior_ignore, TestEnv::new().with_class_decorator_behavior(ClassDecoratorBehavior::Ignore), diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index 43d7492153..44ff374c21 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -1193,13 +1193,11 @@ Controls how class decorators affect the type bound to a class name. - `"ignore"`: Preserve the class type without inferring decorator results. An explicit `type[Any]` return annotation still erases the class interface. This is the default for the `basic` and `legacy` presets. -- `"typed-only"`: Apply typed decorators. Preserve the class type when a - decorator has type `Any` or returns `Any`, `Unknown`, or `type[Unknown]`. - A bare `type` return annotation and an inferred `type[Any]` return preserve - the class interface. Return unions also follow these rules. An explicit - `type[Any]` return annotation takes precedence and permits class erasure. - A decorator union is untyped if any member is untyped. An overloaded - decorator is untyped only if every signature has an untyped return. +- `"typed-only"`: Apply decorators whose call results are typed. Preserve the + class type when the result contains `Any`, `Unknown`, or `type[Unknown]` at + the top level. A bare `type` return annotation and an inferred `type[Any]` + return preserve the class interface. An explicit `type[Any]` return still + permits class erasure. Overloaded decorators use the selected overload's result. This is the default for the `default`, `strict`, and `all` presets. - `"all"`: Apply all decorator return types, including `Any` and `Unknown`. Decorators apply from the bottom of the decorator list to the top. @@ -1218,11 +1216,9 @@ Special decorators such as `dataclass`, `dataclass_transform`, `final`, class behavior in all three modes. An invalid decorator also preserves the class during error recovery. -The same untyped classification controls decorator application and the -`untyped-class-decorator` diagnostic. An explicit `Any` return disables tracking -without this warning. Implicit or inferred unknown types receive the warning -only for the first untyped decorator in application order. -Configure its severity separately to control reporting. +The `untyped-class-decorator` diagnostic reports the first unknown call result +when decorators are applied. An explicit `Any` return does not receive this +warning. Configure its severity separately to control reporting. - Type: one of `"ignore"`, `"typed-only"`, `"all"` - Default: `"ignore"` for `basic` and `legacy`, `"typed-only"` otherwise