Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUF008/9 for non-dataclasses as well #4096

Closed
wants to merge 11 commits into from
20 changes: 20 additions & 0 deletions crates/ruff/resources/test/fixtures/ruff/RUF010.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import typing
from typing import Sequence

KNOWINGLY_MUTABLE_DEFAULT = []


class A:
mutable_default: list[int] = []
immutable_annotation: typing.Sequence[int] = []
without_annotation = []
ignored_via_comment: list[int] = [] # noqa: RUF010
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT


class B:
mutable_default: list[int] = []
immutable_annotation: Sequence[int] = []
without_annotation = []
ignored_via_comment: list[int] = [] # noqa: RUF010
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
30 changes: 30 additions & 0 deletions crates/ruff/resources/test/fixtures/ruff/RUF011.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import datetime
import typing
from typing import NamedTuple
from dataclasses import field

def default_function() -> list[int]:
return []


class ImmutableType(NamedTuple):
something: int = 8


class A:
hidden_mutable_default: list[int] = default_function()
class_variable: typing.ClassVar[list[int]] = default_function()
fine_date: datetime.date = datetime.date(2042, 1, 1)


DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES = ImmutableType(40)
DEFAULT_A_FOR_ALL_DATACLASSES = A([1, 2, 3])


class B:
hidden_mutable_default: list[int] = default_function()
another_class: A = A()
not_optimal: ImmutableType = ImmutableType(20)
good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
not_fine_field: list[int] = field(default_vactory=list)
38 changes: 32 additions & 6 deletions crates/ruff/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,19 +836,45 @@ where

if self.settings.rules.any_enabled(&[
Rule::MutableDataclassDefault,
Rule::MutableClassDefault,
Rule::FunctionCallInDataclassDefaultArgument,
]) && ruff::rules::is_dataclass(self, decorator_list)
{
if self.settings.rules.enabled(Rule::MutableDataclassDefault) {
ruff::rules::mutable_dataclass_default(self, body);
Rule::FunctionCallInClassDefaultArgument,
]) {
let is_dataclass = ruff::rules::is_dataclass(self, decorator_list);

if is_dataclass {
if self.settings.rules.enabled(Rule::MutableDataclassDefault) {
ruff::rules::mutable_class_default(self, true, body);
}
Comment on lines +846 to +848
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is my understanding correct that ruff now emits two diagnostics for the same locations if both the MutableDataclassDefault and MutableClassDefault rules are enabled?

I think we should avoid this when possible. Is the motivation of having two different rules so that we can show two different messages? We could e.g. parameterize the diagnostic with the class kind to emit two different messages but implement it as a single rule. @charliermarsh any thoughts on this?


if self
.settings
.rules
.enabled(Rule::FunctionCallInDataclassDefaultArgument)
{
ruff::rules::function_call_in_class_defaults(
self,
body,
is_dataclass,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Guaranteed to always be true

Suggested change
is_dataclass,
true,

true,
);
}
}
if self.settings.rules.enabled(Rule::MutableClassDefault) {
ruff::rules::mutable_class_default(self, false, body);
}

if self
.settings
.rules
.enabled(Rule::FunctionCallInDataclassDefaultArgument)
.enabled(Rule::FunctionCallInClassDefaultArgument)
{
ruff::rules::function_call_in_dataclass_defaults(self, body);
ruff::rules::function_call_in_class_defaults(
self,
body,
is_dataclass,
MichaReiser marked this conversation as resolved.
Show resolved Hide resolved
false,
);
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/ruff/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<Rule> {
(Ruff, "007") => Rule::PairwiseOverZipped,
(Ruff, "008") => Rule::MutableDataclassDefault,
(Ruff, "009") => Rule::FunctionCallInDataclassDefaultArgument,
(Ruff, "010") => Rule::MutableClassDefault,
(Ruff, "011") => Rule::FunctionCallInClassDefaultArgument,
(Ruff, "100") => Rule::UnusedNOQA,

// flake8-django
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ ruff_macros::register_rules!(
rules::ruff::rules::PairwiseOverZipped,
rules::ruff::rules::MutableDataclassDefault,
rules::ruff::rules::FunctionCallInDataclassDefaultArgument,
rules::ruff::rules::MutableClassDefault,
rules::ruff::rules::FunctionCallInClassDefaultArgument,
// flake8-django
rules::flake8_django::rules::DjangoNullableModelStringField,
rules::flake8_django::rules::DjangoLocalsInRenderFunction,
Expand Down
14 changes: 13 additions & 1 deletion crates/ruff/src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,19 @@ mod tests {

#[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<()> {
fn mutable_dataclass_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_messages!(snapshot, diagnostics);
Ok(())
}

#[test_case(Rule::MutableClassDefault, Path::new("RUF010.py"); "RUF010")]
#[test_case(Rule::FunctionCallInClassDefaultArgument, Path::new("RUF011.py"); "RUF011")]
adampauls marked this conversation as resolved.
Show resolved Hide resolved
fn mutable_class_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(),
Expand Down
9 changes: 5 additions & 4 deletions crates/ruff/src/rules/ruff/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ mod ambiguous_unicode_character;
mod asyncio_dangling_task;
mod collection_literal_concatenation;
mod confusables;
mod mutable_defaults_in_dataclass_fields;
mod mutable_defaults_in_class_fields;
mod pairwise_over_zipped;
mod unused_noqa;

Expand All @@ -14,9 +14,10 @@ pub use asyncio_dangling_task::{asyncio_dangling_task, AsyncioDanglingTask};
pub use collection_literal_concatenation::{
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 mutable_defaults_in_class_fields::{
function_call_in_class_defaults, is_dataclass, mutable_class_default,
FunctionCallInClassDefaultArgument, FunctionCallInDataclassDefaultArgument,
MutableClassDefault, MutableDataclassDefault,
};
pub use pairwise_over_zipped::{pairwise_over_zipped, PairwiseOverZipped};
pub use unused_noqa::{UnusedCodes, UnusedNOQA};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ impl Violation for MutableDataclassDefault {
}
}

/// This rule is same as MutableDataclassDefault, but for any class. The same arguments apply.
#[violation]
pub struct MutableClassDefault;

impl Violation for MutableClassDefault {
#[derive_message_formats]
fn message(&self) -> String {
format!("Do not use mutable default values for class attributes")
}
}

/// ## What it does
/// Checks for function calls in dataclass defaults.
///
Expand Down Expand Up @@ -148,6 +159,25 @@ impl Violation for FunctionCallInDataclassDefaultArgument {
}
}

/// Same as FunctionCallInDataclassDefaultArgument, but for any class.
/// Importantly, this error will be issued on calls to dataclasses.field
#[violation]
pub struct FunctionCallInClassDefaultArgument {
pub name: Option<String>,
}

impl Violation for FunctionCallInClassDefaultArgument {
#[derive_message_formats]
fn message(&self) -> String {
let FunctionCallInClassDefaultArgument { name } = self;
if let Some(name) = name {
format!("Do not perform function call `{name}` in non-dataclass attribute defaults")
} else {
format!("Do not perform function call in non-dataclass attribute defaults")
}
}
}

fn is_mutable_expr(expr: &Expr) -> bool {
matches!(
&expr.node,
Expand Down Expand Up @@ -178,8 +208,13 @@ fn is_class_var_annotation(context: &Context, annotation: &Expr) -> bool {
context.match_typing_expr(value, "ClassVar")
}

/// RUF009
pub fn function_call_in_dataclass_defaults(checker: &mut Checker, body: &[Stmt]) {
/// RUF009/RUF011
pub fn function_call_in_class_defaults(
checker: &mut Checker,
body: &[Stmt],
is_dataclass: bool,
emit_dataclass_error: bool,
Comment on lines +215 to +216
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Do we need both booleans. It seems that emit_dataclass_error is always true if is_dataclass is true

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it depends on if you think RUF008/9 are distinct from RUF010/11. Should the latter issue the same error as the former if the former are not enabled? Probably not right?

I would personally rather just collapse the error but I wasn't sure if it was acceptable to break backwards compatibility like that. That said, RUF008/9 are quite new so maybe it's okay?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see, the nesting in the call-site is different than I thought. But does that mean that Ruff now emits two errors when seeing a non-immutable function call: Once the dataclass error and once the normal error if both rules are enabled?

@charliermarsh what's your opinion on merging the rules and/or parametrizing the diagnostics with whether it is a dataclass or not?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this: I'll put up a PR that expands RUF008 to all classes, not just dataclasses. I think that's uncontroversial, since mutable defaults are never a good idea. There are some issues with RUF009 because we need exemptions for field_specifiers in general for @dataclass_transforms, which is not easy to do in general. Then we can sort out whether we want to keep RUF009 scoped to dataclasses or expand it to any class with exemptions for dataclass-like things.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. @charliermarsh what's your take?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added my opinion over in #4390 (comment)

) {
let extend_immutable_calls: Vec<CallPath> = checker
.settings
.flake8_bugbear
Expand All @@ -199,23 +234,41 @@ pub fn function_call_in_dataclass_defaults(checker: &mut Checker, body: &[Stmt])
continue;
}
if let ExprKind::Call { func, .. } = &expr.node {
if !is_immutable_func(&checker.ctx, func, &extend_immutable_calls)
&& !is_allowed_dataclass_function(&checker.ctx, func)
if !(is_immutable_func(&checker.ctx, func, &extend_immutable_calls)
|| is_dataclass && is_allowed_dataclass_function(&checker.ctx, func))
{
checker.diagnostics.push(Diagnostic::new(
FunctionCallInDataclassDefaultArgument {
name: compose_call_path(func),
},
expr.range(),
));
let diagnostic: Diagnostic = if emit_dataclass_error {
Diagnostic::new(
FunctionCallInDataclassDefaultArgument {
name: compose_call_path(func),
},
expr.range(),
)
} else {
Diagnostic::new(
FunctionCallInClassDefaultArgument {
name: compose_call_path(func),
},
expr.range(),
)
};
checker.diagnostics.push(diagnostic);
}
}
}
}
}

/// RUF008
pub fn mutable_dataclass_default(checker: &mut Checker, body: &[Stmt]) {
/// RUF008/RUF010
pub fn mutable_class_default(checker: &mut Checker, emit_dataclass_error: bool, body: &[Stmt]) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It can be difficult to understand the semantic of true in a call of mutable_class_default(checker, true, body) without looking at the method signature.

Introducing an enum can improve readability:

#[derive(Copy, Clone, Debug)]
enum ClassKind {
	Class,
	Dataclass
}

It has the added benefit that you can implement methods on the kind

fn diagnostic(emit_dataclass_error: bool, value: &Expr) -> Diagnostic {
if emit_dataclass_error {
Diagnostic::new(MutableDataclassDefault, value.range())
} else {
Diagnostic::new(MutableClassDefault, value.range())
}
}

for statement in body {
match &statement.node {
StmtKind::AnnAssign {
Expand All @@ -229,14 +282,14 @@ pub fn mutable_dataclass_default(checker: &mut Checker, body: &[Stmt]) {
{
checker
.diagnostics
.push(Diagnostic::new(MutableDataclassDefault, value.range()));
.push(diagnostic(emit_dataclass_error, value));
}
}
StmtKind::Assign { value, .. } => {
if is_mutable_expr(value) {
checker
.diagnostics
.push(Diagnostic::new(MutableDataclassDefault, value.range()));
.push(diagnostic(emit_dataclass_error, value));
}
}
_ => (),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
source: crates/ruff/src/rules/ruff/mod.rs
---
RUF010.py:8:34: RUF010 Do not use mutable default values for class attributes
|
8 | class A:
9 | mutable_default: list[int] = []
| ^^ RUF010
10 | immutable_annotation: typing.Sequence[int] = []
11 | without_annotation = []
|

RUF010.py:10:26: RUF010 Do not use mutable default values for class attributes
|
10 | mutable_default: list[int] = []
11 | immutable_annotation: typing.Sequence[int] = []
12 | without_annotation = []
| ^^ RUF010
13 | ignored_via_comment: list[int] = [] # noqa: RUF010
14 | correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
|

RUF010.py:16:34: RUF010 Do not use mutable default values for class attributes
|
16 | class B:
17 | mutable_default: list[int] = []
| ^^ RUF010
18 | immutable_annotation: Sequence[int] = []
19 | without_annotation = []
|

RUF010.py:18:26: RUF010 Do not use mutable default values for class attributes
|
18 | mutable_default: list[int] = []
19 | immutable_annotation: Sequence[int] = []
20 | without_annotation = []
| ^^ RUF010
21 | ignored_via_comment: list[int] = [] # noqa: RUF010
22 | correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
|


Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
source: crates/ruff/src/rules/ruff/mod.rs
---
RUF011.py:15:41: RUF011 Do not perform function call `default_function` in non-dataclass attribute defaults
|
15 | class A:
16 | hidden_mutable_default: list[int] = default_function()
| ^^^^^^^^^^^^^^^^^^ RUF011
17 | class_variable: typing.ClassVar[list[int]] = default_function()
18 | fine_date: datetime.date = datetime.date(2042, 1, 1)
|

RUF011.py:25:41: RUF011 Do not perform function call `default_function` in non-dataclass attribute defaults
|
25 | class B:
26 | hidden_mutable_default: list[int] = default_function()
| ^^^^^^^^^^^^^^^^^^ RUF011
27 | another_class: A = A()
28 | not_optimal: ImmutableType = ImmutableType(20)
|

RUF011.py:26:24: RUF011 Do not perform function call `A` in non-dataclass attribute defaults
|
26 | class B:
27 | hidden_mutable_default: list[int] = default_function()
28 | another_class: A = A()
| ^^^ RUF011
29 | not_optimal: ImmutableType = ImmutableType(20)
30 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
|

RUF011.py:27:34: RUF011 Do not perform function call `ImmutableType` in non-dataclass attribute defaults
|
27 | hidden_mutable_default: list[int] = default_function()
28 | another_class: A = A()
29 | not_optimal: ImmutableType = ImmutableType(20)
| ^^^^^^^^^^^^^^^^^ RUF011
30 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
31 | okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
|

RUF011.py:30:33: RUF011 Do not perform function call `field` in non-dataclass attribute defaults
|
30 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
31 | okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
32 | not_fine_field: list[int] = field(default_vactory=list)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF011
|


3 changes: 3 additions & 0 deletions ruff.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.