diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF009.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF009.py index a98835577c1d9..b8c76574c25bb 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF009.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF009.py @@ -110,3 +110,19 @@ class ShouldMatchB008RuleOfImmutableTypeAnnotationIgnored: # ignored this_is_fine: int = f() + +# Test for: +# https://github.com/astral-sh/ruff/issues/17424 +@dataclass(frozen=True) +class C: + foo: int = 1 + + +@dataclass +class D: + c: C = C() + + +@dataclass +class E: + c: C = C() \ No newline at end of file diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF009_attrs.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF009_attrs.py index 7910b3932306b..8f2a5c9fdd73f 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF009_attrs.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF009_attrs.py @@ -73,3 +73,55 @@ def __set__(self, obj, value): @frozen class InventoryItem: quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100) + + +# Test for: +# https://github.com/astral-sh/ruff/issues/17424 +@frozen +class C: + foo: int = 1 + + +@attr.frozen +class D: + foo: int = 1 + + +@define +class E: + c: C = C() + d: D = D() + + +@attr.s +class F: + foo: int = 1 + + +@attr.mutable +class G: + foo: int = 1 + + +@attr.attrs +class H: + f: F = F() + g: G = G() + + +@attr.define +class I: + f: F = F() + g: G = G() + + +@attr.frozen +class J: + f: F = F() + g: G = G() + + +@attr.mutable +class K: + f: F = F() + g: G = G() diff --git a/crates/ruff_linter/src/rules/ruff/helpers.rs b/crates/ruff_linter/src/rules/ruff/helpers.rs index 4fef08b2aace9..c1426de616f7e 100644 --- a/crates/ruff_linter/src/rules/ruff/helpers.rs +++ b/crates/ruff_linter/src/rules/ruff/helpers.rs @@ -119,7 +119,8 @@ pub(super) fn dataclass_kind<'a>( }; match qualified_name.segments() { - ["attrs", func @ ("define" | "frozen" | "mutable")] | ["attr", func @ "s"] => { + ["attrs" | "attr", func @ ("define" | "frozen" | "mutable")] + | ["attr", func @ ("s" | "attrs")] => { // `.define`, `.frozen` and `.mutable` all default `auto_attribs` to `None`, // whereas `@attr.s` implicitly sets `auto_attribs=False`. // https://www.attrs.org/en/stable/api.html#attrs.define @@ -163,6 +164,35 @@ pub(super) fn dataclass_kind<'a>( None } +/// Return true if dataclass (stdlib or `attrs`) is frozen +pub(super) fn is_frozen_dataclass( + dataclass_decorator: &ast::Decorator, + semantic: &SemanticModel, +) -> bool { + let Some(qualified_name) = + semantic.resolve_qualified_name(map_callable(&dataclass_decorator.expression)) + else { + return false; + }; + + match qualified_name.segments() { + ["dataclasses", "dataclass"] => { + let Expr::Call(ExprCall { arguments, .. }) = &dataclass_decorator.expression else { + return false; + }; + + let Some(keyword) = arguments.find_keyword("frozen") else { + return false; + }; + Truthiness::from_expr(&keyword.value, |id| semantic.has_builtin_binding(id)) + .into_bool() + .unwrap_or_default() + } + ["attrs" | "attr", "frozen"] => true, + _ => false, + } +} + /// Returns `true` if the given class has "default copy" semantics. /// /// For example, Pydantic `BaseModel` and `BaseSettings` subclasses copy attribute defaults on diff --git a/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs b/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs index bd21ae829ae3f..2e1045ba7662e 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs @@ -2,6 +2,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::name::{QualifiedName, UnqualifiedName}; +use ruff_python_semantic::SemanticModel; use ruff_python_semantic::analyze::typing::{ is_immutable_annotation, is_immutable_func, is_immutable_newtype_call, }; @@ -11,7 +12,7 @@ use crate::Violation; use crate::checkers::ast::Checker; use crate::rules::ruff::helpers::{ AttrsAutoAttribs, DataclassKind, dataclass_kind, is_class_var_annotation, is_dataclass_field, - is_descriptor_class, + is_descriptor_class, is_frozen_dataclass, }; /// ## What it does @@ -143,6 +144,7 @@ pub(crate) fn function_call_in_dataclass_default(checker: &Checker, class_def: & || func.as_name_expr().is_some_and(|name| { is_immutable_newtype_call(name, checker.semantic(), &extend_immutable_calls) }) + || is_frozen_dataclass_instantiation(func, semantic) { continue; } @@ -160,3 +162,19 @@ fn any_annotated(class_body: &[Stmt]) -> bool { .iter() .any(|stmt| matches!(stmt, Stmt::AnnAssign(..))) } + +/// Checks that the passed function is an instantiation of the class, +/// retrieves the ``StmtClassDef`` and verifies that it is a frozen dataclass +fn is_frozen_dataclass_instantiation(func: &Expr, semantic: &SemanticModel) -> bool { + semantic.lookup_attribute(func).is_some_and(|id| { + let binding = &semantic.binding(id); + let Some(Stmt::ClassDef(class_def)) = binding.statement(semantic) else { + return false; + }; + + let Some((_, dataclass_decorator)) = dataclass_kind(class_def, semantic) else { + return false; + }; + is_frozen_dataclass(dataclass_decorator, semantic) + }) +} diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap index 5ed5adb316ebb..58cadaeeb2130 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap @@ -1,6 +1,5 @@ --- source: crates/ruff_linter/src/rules/ruff/mod.rs -snapshot_kind: text --- RUF009_attrs.py:46:41: RUF009 Do not perform function call `default_function` in dataclass defaults | @@ -31,3 +30,71 @@ RUF009_attrs.py:48:34: RUF009 Do not perform function call `ImmutableType` in da 49 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES 50 | okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES | + +RUF009_attrs.py:108:12: RUF009 Do not perform function call `F` in dataclass defaults + | +106 | @attr.attrs +107 | class H: +108 | f: F = F() + | ^^^ RUF009 +109 | g: G = G() + | + +RUF009_attrs.py:109:12: RUF009 Do not perform function call `G` in dataclass defaults + | +107 | class H: +108 | f: F = F() +109 | g: G = G() + | ^^^ RUF009 + | + +RUF009_attrs.py:114:12: RUF009 Do not perform function call `F` in dataclass defaults + | +112 | @attr.define +113 | class I: +114 | f: F = F() + | ^^^ RUF009 +115 | g: G = G() + | + +RUF009_attrs.py:115:12: RUF009 Do not perform function call `G` in dataclass defaults + | +113 | class I: +114 | f: F = F() +115 | g: G = G() + | ^^^ RUF009 + | + +RUF009_attrs.py:120:12: RUF009 Do not perform function call `F` in dataclass defaults + | +118 | @attr.frozen +119 | class J: +120 | f: F = F() + | ^^^ RUF009 +121 | g: G = G() + | + +RUF009_attrs.py:121:12: RUF009 Do not perform function call `G` in dataclass defaults + | +119 | class J: +120 | f: F = F() +121 | g: G = G() + | ^^^ RUF009 + | + +RUF009_attrs.py:126:12: RUF009 Do not perform function call `F` in dataclass defaults + | +124 | @attr.mutable +125 | class K: +126 | f: F = F() + | ^^^ RUF009 +127 | g: G = G() + | + +RUF009_attrs.py:127:12: RUF009 Do not perform function call `G` in dataclass defaults + | +125 | class K: +126 | f: F = F() +127 | g: G = G() + | ^^^ RUF009 + |