Skip to content

Commit b43a204

Browse files
eureka0928ntBre
andauthored
[ruff] Detect mutable defaults in field calls (RUF008) (#23046)
## Summary Resolves #16495. RUF008 previously only caught bare mutable defaults like `mutable_default: list[int] = []` in dataclass attributes, but missed mutable defaults wrapped in `field(default=...)` calls. For example, the following was not flagged: ```python @define class A: mutable_default: list[int] = attrs.field(default=[]) ``` This PR modifies `mutable_dataclass_default()` to look inside recognized dataclass field calls (`dataclasses.field()`, `attrs.field()`, `attr.ib()`, `attr.attrib()`) and check the `default` keyword argument for mutability. The approach mirrors how RUF009 already uses `is_dataclass_field()` to identify field calls — RUF008 now reuses the same helper to extract and inspect the `default` keyword argument. No duplicate diagnostics are introduced since RUF009 already skips field calls entirely. ## Test Plan `cargo nextest run -p ruff_linter` and `cargo clippy`. Added test cases covering: - Positive: `field(default=[])`, `attrs.field(default={})`, `attr.ib(default=[])`, `attr.attrib(default=set())`, `field(default=dict())` - Negative: `factory=list`, `default=()`, `default="hello"`, `default=1`, `default=KNOWINGLY_MUTABLE_DEFAULT`, `ClassVar` annotation with `field(default=[])` --------- Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
1 parent 80dbc62 commit b43a204

7 files changed

Lines changed: 311 additions & 5 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,18 @@ class AWithQuotes:
3434
correct_code: 'list[int]' = KNOWINGLY_MUTABLE_DEFAULT
3535
perfectly_fine: 'list[int]' = field(default_factory=list)
3636
class_variable: 'typing.ClassVar[list[int]]'= []
37+
38+
39+
# Mutable defaults wrapped in field() calls
40+
@dataclass
41+
class C:
42+
mutable_default: list[int] = field(default=[]) # RUF008
43+
mutable_default2: dict[str, int] = field(default={}) # RUF008
44+
mutable_default3: set[int] = field(default=set()) # RUF008
45+
mutable_default4: dict[str, int] = field(default=dict()) # RUF008
46+
correct_factory: list[int] = field(default_factory=list) # okay
47+
immutable_default: tuple[int, ...] = field(default=()) # okay
48+
immutable_default2: str = field(default="hello") # okay
49+
immutable_default3: int = field(default=1) # okay
50+
non_mutable_var: list[int] = field(default=KNOWINGLY_MUTABLE_DEFAULT) # okay
51+
class_variable: ClassVar[list[int]] = field(default=[]) # okay

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing
22
from typing import ClassVar, Sequence
33

4-
import attr
4+
import attr, attrs
55
from attr import s
66
from attrs import define, frozen
77

@@ -45,3 +45,33 @@ class D:
4545
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
4646
perfectly_fine: list[int] = field(default_factory=list)
4747
class_variable: ClassVar[list[int]] = []
48+
49+
50+
# Mutable defaults wrapped in field() calls
51+
@define
52+
class E:
53+
mutable_default: list[int] = attrs.field(default=[]) # RUF008
54+
mutable_default2: dict[str, int] = attrs.field(default={}) # RUF008
55+
mutable_default3: set[int] = attrs.field(default=set()) # RUF008
56+
mutable_default4: dict[str, int] = attrs.field(default=dict()) # RUF008
57+
correct_factory: list[int] = attrs.field(factory=list) # okay
58+
immutable_default: tuple[int, ...] = attrs.field(default=()) # okay
59+
immutable_default2: str = attrs.field(default="hello") # okay
60+
immutable_default3: int = attrs.field(default=1) # okay
61+
non_mutable_var: list[int] = attrs.field(default=KNOWINGLY_MUTABLE_DEFAULT) # okay
62+
class_variable: ClassVar[list[int]] = attrs.field(default=[]) # okay
63+
64+
65+
@attr.s
66+
class F:
67+
mutable_default: list[int] = attr.ib(default=[]) # RUF008
68+
mutable_default2: dict[str, int] = attr.ib(default={}) # RUF008
69+
correct_factory: list[int] = attr.ib(factory=list) # okay
70+
immutable_default: tuple[int, ...] = attr.ib(default=()) # okay
71+
72+
73+
@attr.s
74+
class G:
75+
mutable_default: list[int] = attr.attrib(default=[]) # RUF008
76+
mutable_default2: set[int] = attr.attrib(default=set()) # RUF008
77+
correct_factory: list[int] = attr.attrib(factory=list) # okay

crates/ruff_linter/src/preview.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,10 @@ pub(crate) const fn is_ble001_exc_info_suppression_enabled(settings: &LinterSett
265265
pub(crate) const fn is_py315_support_enabled(settings: &LinterSettings) -> bool {
266266
settings.preview.is_enabled()
267267
}
268+
269+
// https://github.com/astral-sh/ruff/pull/23046
270+
pub(crate) const fn is_mutable_default_in_dataclass_field_enabled(
271+
settings: &LinterSettings,
272+
) -> bool {
273+
settings.preview.is_enabled()
274+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,8 @@ mod tests {
611611
Ok(())
612612
}
613613

614+
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008.py"))]
615+
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008_attrs.py"))]
614616
#[test_case(Rule::UnrawRePattern, Path::new("RUF039.py"))]
615617
#[test_case(Rule::UnrawRePattern, Path::new("RUF039_concat.py"))]
616618
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_0.py"))]

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
use ruff_python_ast::{self as ast, Stmt};
1+
use ruff_python_ast::{self as ast, Expr, Stmt};
22

33
use ruff_macros::{ViolationMetadata, derive_message_formats};
44
use ruff_python_semantic::analyze::typing::{is_immutable_annotation, is_mutable_expr};
55
use ruff_text_size::Ranged;
66

77
use crate::Violation;
88
use crate::checkers::ast::Checker;
9-
use crate::rules::ruff::helpers::{dataclass_kind, is_class_var_annotation};
9+
use crate::preview::is_mutable_default_in_dataclass_field_enabled;
10+
use crate::rules::ruff::helpers::{dataclass_kind, is_class_var_annotation, is_dataclass_field};
1011

1112
/// ## What it does
1213
/// Checks for mutable default values in dataclass attributes.
@@ -22,6 +23,10 @@ use crate::rules::ruff::helpers::{dataclass_kind, is_class_var_annotation};
2223
/// If the default value is intended to be mutable, it must be annotated with
2324
/// `typing.ClassVar`; otherwise, a `ValueError` will be raised.
2425
///
26+
/// In [preview](https://docs.astral.sh/ruff/preview/) this rule also detects mutable defaults passed via the `default` keyword
27+
/// argument in `field()` (for stdlib dataclasses), `attrs.field()`, `attr.ib()`,
28+
/// and `attr.attrib()` calls.
29+
///
2530
/// ## Example
2631
/// ```python
2732
/// from dataclasses import dataclass
@@ -69,9 +74,9 @@ impl Violation for MutableDataclassDefault {
6974
pub(crate) fn mutable_dataclass_default(checker: &Checker, class_def: &ast::StmtClassDef) {
7075
let semantic = checker.semantic();
7176

72-
if dataclass_kind(class_def, semantic).is_none() {
77+
let Some((dataclass_kind, _)) = dataclass_kind(class_def, semantic) else {
7378
return;
74-
}
79+
};
7580

7681
for statement in &class_def.body {
7782
let Stmt::AnnAssign(ast::StmtAnnAssign {
@@ -83,6 +88,21 @@ pub(crate) fn mutable_dataclass_default(checker: &Checker, class_def: &ast::Stmt
8388
continue;
8489
};
8590

91+
let value = match &**value {
92+
Expr::Call(ast::ExprCall {
93+
func, arguments, ..
94+
}) if is_mutable_default_in_dataclass_field_enabled(checker.settings())
95+
&& is_dataclass_field(func, checker.semantic(), dataclass_kind) =>
96+
{
97+
arguments.find_argument_value("default", 0)
98+
}
99+
value => Some(value),
100+
};
101+
102+
let Some(value) = value else {
103+
continue;
104+
};
105+
86106
if is_mutable_expr(value, checker.semantic())
87107
&& !is_class_var_annotation(annotation, checker.semantic())
88108
&& !is_immutable_annotation(annotation, checker.semantic(), &[])
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF008 Do not use mutable default values for dataclass attributes
5+
--> RUF008.py:10:34
6+
|
7+
8 | @dataclass
8+
9 | class A:
9+
10 | mutable_default: list[int] = []
10+
| ^^
11+
11 | immutable_annotation: typing.Sequence[int] = []
12+
12 | without_annotation = []
13+
|
14+
15+
RUF008 Do not use mutable default values for dataclass attributes
16+
--> RUF008.py:20:34
17+
|
18+
18 | @dataclass
19+
19 | class B:
20+
20 | mutable_default: list[int] = []
21+
| ^^
22+
21 | immutable_annotation: Sequence[int] = []
23+
22 | without_annotation = []
24+
|
25+
26+
RUF008 Do not use mutable default values for dataclass attributes
27+
--> RUF008.py:31:36
28+
|
29+
29 | @dataclass
30+
30 | class AWithQuotes:
31+
31 | mutable_default: 'list[int]' = []
32+
| ^^
33+
32 | immutable_annotation: 'typing.Sequence[int]' = []
34+
33 | without_annotation = []
35+
|
36+
37+
RUF008 Do not use mutable default values for dataclass attributes
38+
--> RUF008.py:32:52
39+
|
40+
30 | class AWithQuotes:
41+
31 | mutable_default: 'list[int]' = []
42+
32 | immutable_annotation: 'typing.Sequence[int]' = []
43+
| ^^
44+
33 | without_annotation = []
45+
34 | correct_code: 'list[int]' = KNOWINGLY_MUTABLE_DEFAULT
46+
|
47+
48+
RUF008 Do not use mutable default values for dataclass attributes
49+
--> RUF008.py:36:51
50+
|
51+
34 | correct_code: 'list[int]' = KNOWINGLY_MUTABLE_DEFAULT
52+
35 | perfectly_fine: 'list[int]' = field(default_factory=list)
53+
36 | class_variable: 'typing.ClassVar[list[int]]'= []
54+
| ^^
55+
|
56+
57+
RUF008 Do not use mutable default values for dataclass attributes
58+
--> RUF008.py:42:48
59+
|
60+
40 | @dataclass
61+
41 | class C:
62+
42 | mutable_default: list[int] = field(default=[]) # RUF008
63+
| ^^
64+
43 | mutable_default2: dict[str, int] = field(default={}) # RUF008
65+
44 | mutable_default3: set[int] = field(default=set()) # RUF008
66+
|
67+
68+
RUF008 Do not use mutable default values for dataclass attributes
69+
--> RUF008.py:43:54
70+
|
71+
41 | class C:
72+
42 | mutable_default: list[int] = field(default=[]) # RUF008
73+
43 | mutable_default2: dict[str, int] = field(default={}) # RUF008
74+
| ^^
75+
44 | mutable_default3: set[int] = field(default=set()) # RUF008
76+
45 | mutable_default4: dict[str, int] = field(default=dict()) # RUF008
77+
|
78+
79+
RUF008 Do not use mutable default values for dataclass attributes
80+
--> RUF008.py:44:48
81+
|
82+
42 | mutable_default: list[int] = field(default=[]) # RUF008
83+
43 | mutable_default2: dict[str, int] = field(default={}) # RUF008
84+
44 | mutable_default3: set[int] = field(default=set()) # RUF008
85+
| ^^^^^
86+
45 | mutable_default4: dict[str, int] = field(default=dict()) # RUF008
87+
46 | correct_factory: list[int] = field(default_factory=list) # okay
88+
|
89+
90+
RUF008 Do not use mutable default values for dataclass attributes
91+
--> RUF008.py:45:54
92+
|
93+
43 | mutable_default2: dict[str, int] = field(default={}) # RUF008
94+
44 | mutable_default3: set[int] = field(default=set()) # RUF008
95+
45 | mutable_default4: dict[str, int] = field(default=dict()) # RUF008
96+
| ^^^^^^
97+
46 | correct_factory: list[int] = field(default_factory=list) # okay
98+
47 | immutable_default: tuple[int, ...] = field(default=()) # okay
99+
|
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF008 Do not use mutable default values for dataclass attributes
5+
--> RUF008_attrs.py:13:34
6+
|
7+
11 | @define
8+
12 | class A:
9+
13 | mutable_default: list[int] = []
10+
| ^^
11+
14 | immutable_annotation: typing.Sequence[int] = []
12+
15 | without_annotation = []
13+
|
14+
15+
RUF008 Do not use mutable default values for dataclass attributes
16+
--> RUF008_attrs.py:23:34
17+
|
18+
21 | @frozen
19+
22 | class B:
20+
23 | mutable_default: list[int] = []
21+
| ^^
22+
24 | immutable_annotation: Sequence[int] = []
23+
25 | without_annotation = []
24+
|
25+
26+
RUF008 Do not use mutable default values for dataclass attributes
27+
--> RUF008_attrs.py:33:34
28+
|
29+
31 | @attr.s
30+
32 | class C:
31+
33 | mutable_default: list[int] = []
32+
| ^^
33+
34 | immutable_annotation: Sequence[int] = []
34+
35 | without_annotation = []
35+
|
36+
37+
RUF008 Do not use mutable default values for dataclass attributes
38+
--> RUF008_attrs.py:42:34
39+
|
40+
40 | @s
41+
41 | class D:
42+
42 | mutable_default: list[int] = []
43+
| ^^
44+
43 | immutable_annotation: Sequence[int] = []
45+
44 | without_annotation = []
46+
|
47+
48+
RUF008 Do not use mutable default values for dataclass attributes
49+
--> RUF008_attrs.py:53:54
50+
|
51+
51 | @define
52+
52 | class E:
53+
53 | mutable_default: list[int] = attrs.field(default=[]) # RUF008
54+
| ^^
55+
54 | mutable_default2: dict[str, int] = attrs.field(default={}) # RUF008
56+
55 | mutable_default3: set[int] = attrs.field(default=set()) # RUF008
57+
|
58+
59+
RUF008 Do not use mutable default values for dataclass attributes
60+
--> RUF008_attrs.py:54:60
61+
|
62+
52 | class E:
63+
53 | mutable_default: list[int] = attrs.field(default=[]) # RUF008
64+
54 | mutable_default2: dict[str, int] = attrs.field(default={}) # RUF008
65+
| ^^
66+
55 | mutable_default3: set[int] = attrs.field(default=set()) # RUF008
67+
56 | mutable_default4: dict[str, int] = attrs.field(default=dict()) # RUF008
68+
|
69+
70+
RUF008 Do not use mutable default values for dataclass attributes
71+
--> RUF008_attrs.py:55:54
72+
|
73+
53 | mutable_default: list[int] = attrs.field(default=[]) # RUF008
74+
54 | mutable_default2: dict[str, int] = attrs.field(default={}) # RUF008
75+
55 | mutable_default3: set[int] = attrs.field(default=set()) # RUF008
76+
| ^^^^^
77+
56 | mutable_default4: dict[str, int] = attrs.field(default=dict()) # RUF008
78+
57 | correct_factory: list[int] = attrs.field(factory=list) # okay
79+
|
80+
81+
RUF008 Do not use mutable default values for dataclass attributes
82+
--> RUF008_attrs.py:56:60
83+
|
84+
54 | mutable_default2: dict[str, int] = attrs.field(default={}) # RUF008
85+
55 | mutable_default3: set[int] = attrs.field(default=set()) # RUF008
86+
56 | mutable_default4: dict[str, int] = attrs.field(default=dict()) # RUF008
87+
| ^^^^^^
88+
57 | correct_factory: list[int] = attrs.field(factory=list) # okay
89+
58 | immutable_default: tuple[int, ...] = attrs.field(default=()) # okay
90+
|
91+
92+
RUF008 Do not use mutable default values for dataclass attributes
93+
--> RUF008_attrs.py:67:50
94+
|
95+
65 | @attr.s
96+
66 | class F:
97+
67 | mutable_default: list[int] = attr.ib(default=[]) # RUF008
98+
| ^^
99+
68 | mutable_default2: dict[str, int] = attr.ib(default={}) # RUF008
100+
69 | correct_factory: list[int] = attr.ib(factory=list) # okay
101+
|
102+
103+
RUF008 Do not use mutable default values for dataclass attributes
104+
--> RUF008_attrs.py:68:56
105+
|
106+
66 | class F:
107+
67 | mutable_default: list[int] = attr.ib(default=[]) # RUF008
108+
68 | mutable_default2: dict[str, int] = attr.ib(default={}) # RUF008
109+
| ^^
110+
69 | correct_factory: list[int] = attr.ib(factory=list) # okay
111+
70 | immutable_default: tuple[int, ...] = attr.ib(default=()) # okay
112+
|
113+
114+
RUF008 Do not use mutable default values for dataclass attributes
115+
--> RUF008_attrs.py:75:54
116+
|
117+
73 | @attr.s
118+
74 | class G:
119+
75 | mutable_default: list[int] = attr.attrib(default=[]) # RUF008
120+
| ^^
121+
76 | mutable_default2: set[int] = attr.attrib(default=set()) # RUF008
122+
77 | correct_factory: list[int] = attr.attrib(factory=list) # okay
123+
|
124+
125+
RUF008 Do not use mutable default values for dataclass attributes
126+
--> RUF008_attrs.py:76:54
127+
|
128+
74 | class G:
129+
75 | mutable_default: list[int] = attr.attrib(default=[]) # RUF008
130+
76 | mutable_default2: set[int] = attr.attrib(default=set()) # RUF008
131+
| ^^^^^
132+
77 | correct_factory: list[int] = attr.attrib(factory=list) # okay
133+
|

0 commit comments

Comments
 (0)