Skip to content

Commit 885dc90

Browse files
authored
[ruff] Frozen Dataclass default should be valid (RUF009) (#18735)
<!-- Thank you for contributing to Ruff/ty! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? (Please prefix with `[ty]` for ty pull requests.) - Does this pull request include references to any relevant issues? --> ## Summary /closes #17424 <!-- What's the purpose of the change? What does it do, and why? --> ## Test Plan <!-- How was it tested? -->
1 parent d01e0fa commit 885dc90

5 files changed

Lines changed: 186 additions & 3 deletions

File tree

crates/ruff_linter/resources/test/fixtures/ruff/RUF009.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,19 @@ class ShouldMatchB008RuleOfImmutableTypeAnnotationIgnored:
110110
# ignored
111111
this_is_fine: int = f()
112112

113+
114+
# Test for:
115+
# https://github.com/astral-sh/ruff/issues/17424
116+
@dataclass(frozen=True)
117+
class C:
118+
foo: int = 1
119+
120+
121+
@dataclass
122+
class D:
123+
c: C = C()
124+
125+
126+
@dataclass
127+
class E:
128+
c: C = C()

crates/ruff_linter/resources/test/fixtures/ruff/RUF009_attrs.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,55 @@ def __set__(self, obj, value):
7373
@frozen
7474
class InventoryItem:
7575
quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100)
76+
77+
78+
# Test for:
79+
# https://github.com/astral-sh/ruff/issues/17424
80+
@frozen
81+
class C:
82+
foo: int = 1
83+
84+
85+
@attr.frozen
86+
class D:
87+
foo: int = 1
88+
89+
90+
@define
91+
class E:
92+
c: C = C()
93+
d: D = D()
94+
95+
96+
@attr.s
97+
class F:
98+
foo: int = 1
99+
100+
101+
@attr.mutable
102+
class G:
103+
foo: int = 1
104+
105+
106+
@attr.attrs
107+
class H:
108+
f: F = F()
109+
g: G = G()
110+
111+
112+
@attr.define
113+
class I:
114+
f: F = F()
115+
g: G = G()
116+
117+
118+
@attr.frozen
119+
class J:
120+
f: F = F()
121+
g: G = G()
122+
123+
124+
@attr.mutable
125+
class K:
126+
f: F = F()
127+
g: G = G()

crates/ruff_linter/src/rules/ruff/helpers.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ pub(super) fn dataclass_kind<'a>(
119119
};
120120

121121
match qualified_name.segments() {
122-
["attrs", func @ ("define" | "frozen" | "mutable")] | ["attr", func @ "s"] => {
122+
["attrs" | "attr", func @ ("define" | "frozen" | "mutable")]
123+
| ["attr", func @ ("s" | "attrs")] => {
123124
// `.define`, `.frozen` and `.mutable` all default `auto_attribs` to `None`,
124125
// whereas `@attr.s` implicitly sets `auto_attribs=False`.
125126
// https://www.attrs.org/en/stable/api.html#attrs.define
@@ -163,6 +164,35 @@ pub(super) fn dataclass_kind<'a>(
163164
None
164165
}
165166

167+
/// Return true if dataclass (stdlib or `attrs`) is frozen
168+
pub(super) fn is_frozen_dataclass(
169+
dataclass_decorator: &ast::Decorator,
170+
semantic: &SemanticModel,
171+
) -> bool {
172+
let Some(qualified_name) =
173+
semantic.resolve_qualified_name(map_callable(&dataclass_decorator.expression))
174+
else {
175+
return false;
176+
};
177+
178+
match qualified_name.segments() {
179+
["dataclasses", "dataclass"] => {
180+
let Expr::Call(ExprCall { arguments, .. }) = &dataclass_decorator.expression else {
181+
return false;
182+
};
183+
184+
let Some(keyword) = arguments.find_keyword("frozen") else {
185+
return false;
186+
};
187+
Truthiness::from_expr(&keyword.value, |id| semantic.has_builtin_binding(id))
188+
.into_bool()
189+
.unwrap_or_default()
190+
}
191+
["attrs" | "attr", "frozen"] => true,
192+
_ => false,
193+
}
194+
}
195+
166196
/// Returns `true` if the given class has "default copy" semantics.
167197
///
168198
/// For example, Pydantic `BaseModel` and `BaseSettings` subclasses copy attribute defaults on

crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt};
22

33
use ruff_macros::{ViolationMetadata, derive_message_formats};
44
use ruff_python_ast::name::{QualifiedName, UnqualifiedName};
5+
use ruff_python_semantic::SemanticModel;
56
use ruff_python_semantic::analyze::typing::{
67
is_immutable_annotation, is_immutable_func, is_immutable_newtype_call,
78
};
@@ -11,7 +12,7 @@ use crate::Violation;
1112
use crate::checkers::ast::Checker;
1213
use crate::rules::ruff::helpers::{
1314
AttrsAutoAttribs, DataclassKind, dataclass_kind, is_class_var_annotation, is_dataclass_field,
14-
is_descriptor_class,
15+
is_descriptor_class, is_frozen_dataclass,
1516
};
1617

1718
/// ## What it does
@@ -143,6 +144,7 @@ pub(crate) fn function_call_in_dataclass_default(checker: &Checker, class_def: &
143144
|| func.as_name_expr().is_some_and(|name| {
144145
is_immutable_newtype_call(name, checker.semantic(), &extend_immutable_calls)
145146
})
147+
|| is_frozen_dataclass_instantiation(func, semantic)
146148
{
147149
continue;
148150
}
@@ -160,3 +162,19 @@ fn any_annotated(class_body: &[Stmt]) -> bool {
160162
.iter()
161163
.any(|stmt| matches!(stmt, Stmt::AnnAssign(..)))
162164
}
165+
166+
/// Checks that the passed function is an instantiation of the class,
167+
/// retrieves the ``StmtClassDef`` and verifies that it is a frozen dataclass
168+
fn is_frozen_dataclass_instantiation(func: &Expr, semantic: &SemanticModel) -> bool {
169+
semantic.lookup_attribute(func).is_some_and(|id| {
170+
let binding = &semantic.binding(id);
171+
let Some(Stmt::ClassDef(class_def)) = binding.statement(semantic) else {
172+
return false;
173+
};
174+
175+
let Some((_, dataclass_decorator)) = dataclass_kind(class_def, semantic) else {
176+
return false;
177+
};
178+
is_frozen_dataclass(dataclass_decorator, semantic)
179+
})
180+
}

crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---
22
source: crates/ruff_linter/src/rules/ruff/mod.rs
3-
snapshot_kind: text
43
---
54
RUF009_attrs.py:46:41: RUF009 Do not perform function call `default_function` in dataclass defaults
65
|
@@ -31,3 +30,71 @@ RUF009_attrs.py:48:34: RUF009 Do not perform function call `ImmutableType` in da
3130
49 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
3231
50 | okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
3332
|
33+
34+
RUF009_attrs.py:108:12: RUF009 Do not perform function call `F` in dataclass defaults
35+
|
36+
106 | @attr.attrs
37+
107 | class H:
38+
108 | f: F = F()
39+
| ^^^ RUF009
40+
109 | g: G = G()
41+
|
42+
43+
RUF009_attrs.py:109:12: RUF009 Do not perform function call `G` in dataclass defaults
44+
|
45+
107 | class H:
46+
108 | f: F = F()
47+
109 | g: G = G()
48+
| ^^^ RUF009
49+
|
50+
51+
RUF009_attrs.py:114:12: RUF009 Do not perform function call `F` in dataclass defaults
52+
|
53+
112 | @attr.define
54+
113 | class I:
55+
114 | f: F = F()
56+
| ^^^ RUF009
57+
115 | g: G = G()
58+
|
59+
60+
RUF009_attrs.py:115:12: RUF009 Do not perform function call `G` in dataclass defaults
61+
|
62+
113 | class I:
63+
114 | f: F = F()
64+
115 | g: G = G()
65+
| ^^^ RUF009
66+
|
67+
68+
RUF009_attrs.py:120:12: RUF009 Do not perform function call `F` in dataclass defaults
69+
|
70+
118 | @attr.frozen
71+
119 | class J:
72+
120 | f: F = F()
73+
| ^^^ RUF009
74+
121 | g: G = G()
75+
|
76+
77+
RUF009_attrs.py:121:12: RUF009 Do not perform function call `G` in dataclass defaults
78+
|
79+
119 | class J:
80+
120 | f: F = F()
81+
121 | g: G = G()
82+
| ^^^ RUF009
83+
|
84+
85+
RUF009_attrs.py:126:12: RUF009 Do not perform function call `F` in dataclass defaults
86+
|
87+
124 | @attr.mutable
88+
125 | class K:
89+
126 | f: F = F()
90+
| ^^^ RUF009
91+
127 | g: G = G()
92+
|
93+
94+
RUF009_attrs.py:127:12: RUF009 Do not perform function call `G` in dataclass defaults
95+
|
96+
125 | class K:
97+
126 | f: F = F()
98+
127 | g: G = G()
99+
| ^^^ RUF009
100+
|

0 commit comments

Comments
 (0)