mirror of
https://github.com/astral-sh/ruff.git
synced 2025-10-01 14:21:24 +00:00
[ruff
] Add checks for mutable defaults in dataclass
es (#3877)
This commit is contained in:
parent
a36ce585ce
commit
d4af2dd5cf
11 changed files with 412 additions and 0 deletions
21
crates/ruff/resources/test/fixtures/ruff/RUF008.py
vendored
Normal file
21
crates/ruff/resources/test/fixtures/ruff/RUF008.py
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
KNOWINGLY_MUTABLE_DEFAULT = []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass()
|
||||||
|
class A:
|
||||||
|
mutable_default: list[int] = []
|
||||||
|
without_annotation = []
|
||||||
|
ignored_via_comment: list[int] = [] # noqa: RUF008
|
||||||
|
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
|
||||||
|
perfectly_fine: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class B:
|
||||||
|
mutable_default: list[int] = []
|
||||||
|
without_annotation = []
|
||||||
|
ignored_via_comment: list[int] = [] # noqa: RUF008
|
||||||
|
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
|
||||||
|
perfectly_fine: list[int] = field(default_factory=list)
|
28
crates/ruff/resources/test/fixtures/ruff/RUF009.py
vendored
Normal file
28
crates/ruff/resources/test/fixtures/ruff/RUF009.py
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
def default_function() -> list[int]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class ImmutableType(NamedTuple):
|
||||||
|
something: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass()
|
||||||
|
class A:
|
||||||
|
hidden_mutable_default: list[int] = default_function()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES = ImmutableType(40)
|
||||||
|
DEFAULT_A_FOR_ALL_DATACLASSES = A([1, 2, 3])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class B:
|
||||||
|
hidden_mutable_default: list[int] = default_function()
|
||||||
|
another_dataclass: A = A()
|
||||||
|
not_optimal: ImmutableType = ImmutableType(20)
|
||||||
|
good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
|
||||||
|
okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
|
|
@ -819,6 +819,24 @@ where
|
||||||
flake8_pie::rules::non_unique_enums(self, stmt, body);
|
flake8_pie::rules::non_unique_enums(self, stmt, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.settings.rules.any_enabled(&[
|
||||||
|
Rule::MutableDataclassDefault,
|
||||||
|
Rule::FunctionCallInDataclassDefaultArgument,
|
||||||
|
]) && ruff::rules::is_dataclass(self, decorator_list)
|
||||||
|
{
|
||||||
|
if self.settings.rules.enabled(Rule::MutableDataclassDefault) {
|
||||||
|
ruff::rules::mutable_dataclass_default(self, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.settings
|
||||||
|
.rules
|
||||||
|
.enabled(Rule::FunctionCallInDataclassDefaultArgument)
|
||||||
|
{
|
||||||
|
ruff::rules::function_call_in_dataclass_defaults(self, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.check_builtin_shadowing(name, stmt, false);
|
self.check_builtin_shadowing(name, stmt, false);
|
||||||
|
|
||||||
for expr in bases {
|
for expr in bases {
|
||||||
|
|
|
@ -700,6 +700,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<Rule> {
|
||||||
(Ruff, "005") => Rule::CollectionLiteralConcatenation,
|
(Ruff, "005") => Rule::CollectionLiteralConcatenation,
|
||||||
(Ruff, "006") => Rule::AsyncioDanglingTask,
|
(Ruff, "006") => Rule::AsyncioDanglingTask,
|
||||||
(Ruff, "007") => Rule::PairwiseOverZipped,
|
(Ruff, "007") => Rule::PairwiseOverZipped,
|
||||||
|
(Ruff, "008") => Rule::MutableDataclassDefault,
|
||||||
|
(Ruff, "009") => Rule::FunctionCallInDataclassDefaultArgument,
|
||||||
(Ruff, "100") => Rule::UnusedNOQA,
|
(Ruff, "100") => Rule::UnusedNOQA,
|
||||||
|
|
||||||
// flake8-django
|
// flake8-django
|
||||||
|
|
|
@ -644,6 +644,8 @@ ruff_macros::register_rules!(
|
||||||
rules::ruff::rules::AsyncioDanglingTask,
|
rules::ruff::rules::AsyncioDanglingTask,
|
||||||
rules::ruff::rules::UnusedNOQA,
|
rules::ruff::rules::UnusedNOQA,
|
||||||
rules::ruff::rules::PairwiseOverZipped,
|
rules::ruff::rules::PairwiseOverZipped,
|
||||||
|
rules::ruff::rules::MutableDataclassDefault,
|
||||||
|
rules::ruff::rules::FunctionCallInDataclassDefaultArgument,
|
||||||
// flake8-django
|
// flake8-django
|
||||||
rules::flake8_django::rules::DjangoNullableModelStringField,
|
rules::flake8_django::rules::DjangoNullableModelStringField,
|
||||||
rules::flake8_django::rules::DjangoLocalsInRenderFunction,
|
rules::flake8_django::rules::DjangoLocalsInRenderFunction,
|
||||||
|
|
|
@ -152,4 +152,16 @@ mod tests {
|
||||||
assert_yaml_snapshot!(diagnostics);
|
assert_yaml_snapshot!(diagnostics);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008.py"); "RUF008")]
|
||||||
|
#[test_case(Rule::FunctionCallInDataclassDefaultArgument, Path::new("RUF009.py"); "RUF009")]
|
||||||
|
fn mutable_defaults(rule_code: Rule, path: &Path) -> Result<()> {
|
||||||
|
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
|
||||||
|
let diagnostics = test_path(
|
||||||
|
Path::new("ruff").join(path).as_path(),
|
||||||
|
&settings::Settings::for_rule(rule_code),
|
||||||
|
)?;
|
||||||
|
assert_yaml_snapshot!(snapshot, diagnostics);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
mod ambiguous_unicode_character;
|
mod ambiguous_unicode_character;
|
||||||
mod asyncio_dangling_task;
|
mod asyncio_dangling_task;
|
||||||
mod collection_literal_concatenation;
|
mod collection_literal_concatenation;
|
||||||
|
mod mutable_defaults_in_dataclass_fields;
|
||||||
mod pairwise_over_zipped;
|
mod pairwise_over_zipped;
|
||||||
mod unused_noqa;
|
mod unused_noqa;
|
||||||
|
|
||||||
|
@ -12,6 +13,10 @@ pub use asyncio_dangling_task::{asyncio_dangling_task, AsyncioDanglingTask};
|
||||||
pub use collection_literal_concatenation::{
|
pub use collection_literal_concatenation::{
|
||||||
collection_literal_concatenation, CollectionLiteralConcatenation,
|
collection_literal_concatenation, CollectionLiteralConcatenation,
|
||||||
};
|
};
|
||||||
|
pub use mutable_defaults_in_dataclass_fields::{
|
||||||
|
function_call_in_dataclass_defaults, is_dataclass, mutable_dataclass_default,
|
||||||
|
FunctionCallInDataclassDefaultArgument, MutableDataclassDefault,
|
||||||
|
};
|
||||||
pub use pairwise_over_zipped::{pairwise_over_zipped, PairwiseOverZipped};
|
pub use pairwise_over_zipped::{pairwise_over_zipped, PairwiseOverZipped};
|
||||||
pub use unused_noqa::{UnusedCodes, UnusedNOQA};
|
pub use unused_noqa::{UnusedCodes, UnusedNOQA};
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,200 @@
|
||||||
|
use rustpython_parser::ast::{Expr, ExprKind, Stmt, StmtKind};
|
||||||
|
|
||||||
|
use ruff_diagnostics::{Diagnostic, Violation};
|
||||||
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
|
use ruff_python_ast::{call_path::compose_call_path, helpers::map_callable, types::Range};
|
||||||
|
use ruff_python_semantic::context::Context;
|
||||||
|
|
||||||
|
use crate::checkers::ast::Checker;
|
||||||
|
|
||||||
|
/// ## What it does
|
||||||
|
/// Checks for mutable default values in dataclasses without the use of
|
||||||
|
/// `dataclasses.field`.
|
||||||
|
///
|
||||||
|
/// ## Why is it bad?
|
||||||
|
/// Mutable default values share state across all instances of the dataclass,
|
||||||
|
/// while not being obvious. This can lead to bugs when the attributes are
|
||||||
|
/// changed in one instance, as those changes will unexpectedly affect all
|
||||||
|
/// other instances.
|
||||||
|
///
|
||||||
|
/// ## Examples:
|
||||||
|
/// ```python
|
||||||
|
/// from dataclasses import dataclass
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class A:
|
||||||
|
/// mutable_default: list[int] = []
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Use instead:
|
||||||
|
/// ```python
|
||||||
|
/// from dataclasses import dataclass, field
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class A:
|
||||||
|
/// mutable_default: list[int] = field(default_factory=list)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Alternatively, if you _want_ shared behaviour, make it more obvious
|
||||||
|
/// by assigning to a module-level variable:
|
||||||
|
/// ```python
|
||||||
|
/// from dataclasses import dataclass
|
||||||
|
///
|
||||||
|
/// I_KNOW_THIS_IS_SHARED_STATE = [1, 2, 3, 4]
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class A:
|
||||||
|
/// mutable_default: list[int] = I_KNOW_THIS_IS_SHARED_STATE
|
||||||
|
/// ```
|
||||||
|
#[violation]
|
||||||
|
pub struct MutableDataclassDefault;
|
||||||
|
|
||||||
|
impl Violation for MutableDataclassDefault {
|
||||||
|
#[derive_message_formats]
|
||||||
|
fn message(&self) -> String {
|
||||||
|
format!("Do not use mutable default values for dataclass attributes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ## What it does
|
||||||
|
/// Checks for function calls in dataclass defaults.
|
||||||
|
///
|
||||||
|
/// ## Why is it bad?
|
||||||
|
/// Function calls are only performed once, at definition time. The returned
|
||||||
|
/// value is then reused by all instances of the dataclass.
|
||||||
|
///
|
||||||
|
/// ## Examples:
|
||||||
|
/// ```python
|
||||||
|
/// from dataclasses import dataclass
|
||||||
|
///
|
||||||
|
/// def creating_list() -> list[]:
|
||||||
|
/// return [1, 2, 3, 4]
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class A:
|
||||||
|
/// mutable_default: list[int] = creating_list()
|
||||||
|
///
|
||||||
|
/// # also:
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class B:
|
||||||
|
/// also_mutable_default_but_sneakier: A = A()
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Use instead:
|
||||||
|
/// ```python
|
||||||
|
/// from dataclasses import dataclass, field
|
||||||
|
///
|
||||||
|
/// def creating_list() -> list[]:
|
||||||
|
/// return [1, 2, 3, 4]
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class A:
|
||||||
|
/// mutable_default: list[int] = field(default_factory=creating_list)
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class B:
|
||||||
|
/// also_mutable_default_but_sneakier: A = field(default_factory=A)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Alternatively, if you _want_ the shared behaviour, make it more obvious
|
||||||
|
/// by assigning it to a module-level variable:
|
||||||
|
/// ```python
|
||||||
|
/// from dataclasses import dataclass
|
||||||
|
///
|
||||||
|
/// def creating_list() -> list[]:
|
||||||
|
/// return [1, 2, 3, 4]
|
||||||
|
///
|
||||||
|
/// I_KNOW_THIS_IS_SHARED_STATE = creating_list()
|
||||||
|
///
|
||||||
|
/// @dataclass
|
||||||
|
/// class A:
|
||||||
|
/// mutable_default: list[int] = I_KNOW_THIS_IS_SHARED_STATE
|
||||||
|
/// ```
|
||||||
|
#[violation]
|
||||||
|
pub struct FunctionCallInDataclassDefaultArgument {
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Violation for FunctionCallInDataclassDefaultArgument {
|
||||||
|
#[derive_message_formats]
|
||||||
|
fn message(&self) -> String {
|
||||||
|
let FunctionCallInDataclassDefaultArgument { name } = self;
|
||||||
|
if let Some(name) = name {
|
||||||
|
format!("Do not perform function call `{name}` in dataclass defaults")
|
||||||
|
} else {
|
||||||
|
format!("Do not perform function call in dataclass defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_mutable_expr(expr: &Expr) -> bool {
|
||||||
|
matches!(
|
||||||
|
&expr.node,
|
||||||
|
ExprKind::List { .. }
|
||||||
|
| ExprKind::Dict { .. }
|
||||||
|
| ExprKind::Set { .. }
|
||||||
|
| ExprKind::ListComp { .. }
|
||||||
|
| ExprKind::DictComp { .. }
|
||||||
|
| ExprKind::SetComp { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_FUNCS: &[&[&str]] = &[&["dataclasses", "field"]];
|
||||||
|
|
||||||
|
fn is_allowed_func(context: &Context, func: &Expr) -> bool {
|
||||||
|
context.resolve_call_path(func).map_or(false, |call_path| {
|
||||||
|
ALLOWED_FUNCS
|
||||||
|
.iter()
|
||||||
|
.any(|target| call_path.as_slice() == *target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RUF009
|
||||||
|
pub fn function_call_in_dataclass_defaults(checker: &mut Checker, body: &[Stmt]) {
|
||||||
|
for statement in body {
|
||||||
|
if let StmtKind::AnnAssign {
|
||||||
|
value: Some(expr), ..
|
||||||
|
} = &statement.node
|
||||||
|
{
|
||||||
|
if let ExprKind::Call { func, .. } = &expr.node {
|
||||||
|
if !is_allowed_func(&checker.ctx, func) {
|
||||||
|
checker.diagnostics.push(Diagnostic::new(
|
||||||
|
FunctionCallInDataclassDefaultArgument {
|
||||||
|
name: compose_call_path(func),
|
||||||
|
},
|
||||||
|
Range::from(expr),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RUF008
|
||||||
|
pub fn mutable_dataclass_default(checker: &mut Checker, body: &[Stmt]) {
|
||||||
|
for statement in body {
|
||||||
|
if let StmtKind::AnnAssign {
|
||||||
|
value: Some(value), ..
|
||||||
|
}
|
||||||
|
| StmtKind::Assign { value, .. } = &statement.node
|
||||||
|
{
|
||||||
|
if is_mutable_expr(value) {
|
||||||
|
checker
|
||||||
|
.diagnostics
|
||||||
|
.push(Diagnostic::new(MutableDataclassDefault, Range::from(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_dataclass(checker: &Checker, decorator_list: &[Expr]) -> bool {
|
||||||
|
decorator_list.iter().any(|decorator| {
|
||||||
|
checker
|
||||||
|
.ctx
|
||||||
|
.resolve_call_path(map_callable(decorator))
|
||||||
|
.map_or(false, |call_path| {
|
||||||
|
call_path.as_slice() == ["dataclasses", "dataclass"]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
source: crates/ruff/src/rules/ruff/mod.rs
|
||||||
|
expression: diagnostics
|
||||||
|
---
|
||||||
|
- kind:
|
||||||
|
name: MutableDataclassDefault
|
||||||
|
body: Do not use mutable default values for dataclass attributes
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 8
|
||||||
|
column: 33
|
||||||
|
end_location:
|
||||||
|
row: 8
|
||||||
|
column: 35
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
- kind:
|
||||||
|
name: MutableDataclassDefault
|
||||||
|
body: Do not use mutable default values for dataclass attributes
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 9
|
||||||
|
column: 25
|
||||||
|
end_location:
|
||||||
|
row: 9
|
||||||
|
column: 27
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
- kind:
|
||||||
|
name: MutableDataclassDefault
|
||||||
|
body: Do not use mutable default values for dataclass attributes
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 17
|
||||||
|
column: 33
|
||||||
|
end_location:
|
||||||
|
row: 17
|
||||||
|
column: 35
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
- kind:
|
||||||
|
name: MutableDataclassDefault
|
||||||
|
body: Do not use mutable default values for dataclass attributes
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 18
|
||||||
|
column: 25
|
||||||
|
end_location:
|
||||||
|
row: 18
|
||||||
|
column: 27
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
source: crates/ruff/src/rules/ruff/mod.rs
|
||||||
|
expression: diagnostics
|
||||||
|
---
|
||||||
|
- kind:
|
||||||
|
name: FunctionCallInDataclassDefaultArgument
|
||||||
|
body: "Do not perform function call `default_function` in dataclass defaults"
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 15
|
||||||
|
column: 40
|
||||||
|
end_location:
|
||||||
|
row: 15
|
||||||
|
column: 58
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
- kind:
|
||||||
|
name: FunctionCallInDataclassDefaultArgument
|
||||||
|
body: "Do not perform function call `default_function` in dataclass defaults"
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 24
|
||||||
|
column: 40
|
||||||
|
end_location:
|
||||||
|
row: 24
|
||||||
|
column: 58
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
- kind:
|
||||||
|
name: FunctionCallInDataclassDefaultArgument
|
||||||
|
body: "Do not perform function call `A` in dataclass defaults"
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 25
|
||||||
|
column: 27
|
||||||
|
end_location:
|
||||||
|
row: 25
|
||||||
|
column: 30
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
- kind:
|
||||||
|
name: FunctionCallInDataclassDefaultArgument
|
||||||
|
body: "Do not perform function call `ImmutableType` in dataclass defaults"
|
||||||
|
suggestion: ~
|
||||||
|
fixable: false
|
||||||
|
location:
|
||||||
|
row: 26
|
||||||
|
column: 33
|
||||||
|
end_location:
|
||||||
|
row: 26
|
||||||
|
column: 50
|
||||||
|
fix:
|
||||||
|
edits: []
|
||||||
|
parent: ~
|
||||||
|
|
2
ruff.schema.json
generated
2
ruff.schema.json
generated
|
@ -2074,6 +2074,8 @@
|
||||||
"RUF005",
|
"RUF005",
|
||||||
"RUF006",
|
"RUF006",
|
||||||
"RUF007",
|
"RUF007",
|
||||||
|
"RUF008",
|
||||||
|
"RUF009",
|
||||||
"RUF1",
|
"RUF1",
|
||||||
"RUF10",
|
"RUF10",
|
||||||
"RUF100",
|
"RUF100",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue