Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF008.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,14 @@ class B:
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
perfectly_fine: list[int] = field(default_factory=list)
class_variable: ClassVar[list[int]] = []

# Lint should account for deferred annotations
# See https://github.com/astral-sh/ruff/issues/15857
@dataclass
class AWithQuotes:
mutable_default: 'list[int]' = []
immutable_annotation: 'typing.Sequence[int]' = []
without_annotation = []
correct_code: 'list[int]' = KNOWINGLY_MUTABLE_DEFAULT
perfectly_fine: 'list[int]' = field(default_factory=list)
class_variable: 'typing.ClassVar[list[int]]'= []
19 changes: 19 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF008_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Lint should account for deferred annotations
# See https://github.com/astral-sh/ruff/issues/15857

from __future__ import annotations

import typing
from dataclasses import dataclass


@dataclass
class Example():
"""Class that uses ClassVar."""

options: ClassVar[dict[str, str]] = {}


if typing.TYPE_CHECKING:
from typing import ClassVar

15 changes: 15 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF009_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING


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

@dataclass()
class A:
hidden_mutable_default: list[int] = default_function()
class_variable: typing.ClassVar[list[int]] = default_function()
another_class_var: ClassVar[list[int]] = default_function()

if TYPE_CHECKING:
from typing import ClassVar
15 changes: 15 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,18 @@ class K(SQLModel):
class L(SQLModel):
id: int
i_j: list[K] = list()

# Lint should account for deferred annotations
# See https://github.com/astral-sh/ruff/issues/15857
class AWithQuotes:
__slots__ = {
"mutable_default": "A mutable default value",
}

mutable_default: 'list[int]' = []
immutable_annotation: 'Sequence[int]'= []
without_annotation = []
class_variable: 'ClassVar[list[int]]' = []
final_variable: 'Final[list[int]]' = []
class_variable_without_subscript: 'ClassVar' = []
final_variable_without_subscript: 'Final' = []
16 changes: 16 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF012_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Lint should account for deferred annotations
# See https://github.com/astral-sh/ruff/issues/15857

from __future__ import annotations

import typing


class Example():
"""Class that uses ClassVar."""

options: ClassVar[dict[str, str]] = {}


if typing.TYPE_CHECKING:
from typing import ClassVar
16 changes: 16 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
Rule::AsyncioDanglingTask,
Rule::BadStaticmethodArgument,
Rule::BuiltinAttributeShadowing,
Rule::FunctionCallInDataclassDefaultArgument,
Rule::GlobalVariableNotAssigned,
Rule::ImportPrivateName,
Rule::ImportShadowedByLoopVar,
Rule::InvalidFirstArgumentNameForClassMethod,
Rule::InvalidFirstArgumentNameForMethod,
Rule::MutableClassDefault,
Rule::MutableDataclassDefault,
Rule::NoSelfUse,
Rule::RedefinedArgumentFromLocal,
Rule::RedefinedWhileUnused,
Expand Down Expand Up @@ -380,6 +383,19 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
&mut diagnostics,
);
}
if checker.enabled(Rule::FunctionCallInDataclassDefaultArgument) {
ruff::rules::function_call_in_dataclass_default(
checker,
class_def,
&mut diagnostics,
);
}
if checker.enabled(Rule::MutableClassDefault) {
ruff::rules::mutable_class_default(checker, class_def, &mut diagnostics);
}
if checker.enabled(Rule::MutableDataclassDefault) {
ruff::rules::mutable_dataclass_default(checker, class_def, &mut diagnostics);
}
}

if matches!(scope.kind, ScopeKind::Function(_) | ScopeKind::Lambda(_)) {
Expand Down
9 changes: 0 additions & 9 deletions crates/ruff_linter/src/checkers/ast/analyze/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,15 +512,6 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if checker.enabled(Rule::NonUniqueEnums) {
flake8_pie::rules::non_unique_enums(checker, stmt, body);
}
if checker.enabled(Rule::MutableClassDefault) {
ruff::rules::mutable_class_default(checker, class_def);
}
if checker.enabled(Rule::MutableDataclassDefault) {
ruff::rules::mutable_dataclass_default(checker, class_def);
}
if checker.enabled(Rule::FunctionCallInDataclassDefaultArgument) {
ruff::rules::function_call_in_dataclass_default(checker, class_def);
}
if checker.enabled(Rule::FStringDocstring) {
flake8_bugbear::rules::f_string_docstring(checker, body);
}
Expand Down
6 changes: 6 additions & 0 deletions crates/ruff_linter/src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mod tests {
#[test_case(Rule::ZipInsteadOfPairwise, Path::new("RUF007.py"))]
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008.py"))]
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008_attrs.py"))]
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008_deferred.py"))]
#[test_case(Rule::FunctionCallInDataclassDefaultArgument, Path::new("RUF009.py"))]
#[test_case(
Rule::FunctionCallInDataclassDefaultArgument,
Expand All @@ -39,8 +40,13 @@ mod tests {
Rule::FunctionCallInDataclassDefaultArgument,
Path::new("RUF009_attrs_auto_attribs.py")
)]
#[test_case(
Rule::FunctionCallInDataclassDefaultArgument,
Path::new("RUF009_deferred.py")
)]
#[test_case(Rule::ExplicitFStringTypeConversion, Path::new("RUF010.py"))]
#[test_case(Rule::MutableClassDefault, Path::new("RUF012.py"))]
#[test_case(Rule::MutableClassDefault, Path::new("RUF012_deferred.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_0.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_1.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_2.py"))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ impl Violation for FunctionCallInDataclassDefaultArgument {

/// RUF009
pub(crate) fn function_call_in_dataclass_default(
checker: &mut Checker,
checker: &Checker,
class_def: &ast::StmtClassDef,
diagnostics: &mut Vec<Diagnostic>,
) {
let semantic = checker.semantic();

Expand Down Expand Up @@ -152,7 +153,7 @@ pub(crate) fn function_call_in_dataclass_default(
};
let diagnostic = Diagnostic::new(kind, expr.range());

checker.diagnostics.push(diagnostic);
diagnostics.push(diagnostic);
}
}

Expand Down
14 changes: 7 additions & 7 deletions crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ impl Violation for MutableClassDefault {
}

/// RUF012
pub(crate) fn mutable_class_default(checker: &mut Checker, class_def: &ast::StmtClassDef) {
pub(crate) fn mutable_class_default(
checker: &Checker,
class_def: &ast::StmtClassDef,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in &class_def.body {
match statement {
Stmt::AnnAssign(ast::StmtAnnAssign {
Expand All @@ -75,9 +79,7 @@ pub(crate) fn mutable_class_default(checker: &mut Checker, class_def: &ast::Stmt
return;
}

checker
.diagnostics
.push(Diagnostic::new(MutableClassDefault, value.range()));
diagnostics.push(Diagnostic::new(MutableClassDefault, value.range()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unrelated to this PR: It's sort of annoying that you had to change the method signature just because you can only get a read-only Checker here. I think we should start refactoring Checker so that:

  • Add a Checker::push_diagnostic or report_diagnostic method
  • Maybe: Add a Checker::extend_diagnostics or report_diagnostics method
  • Then, change Checker::diagnostics to a RefCell<Vec<Diagnostic>>

Doing so has a few advantages:

  • It is no longer required to take a &mut Checker only to push diagnostics
  • We have a central place to perform some operation on diagnostics. E.g. we could adopt Red Knot's approach to filter out suppressed diagnostics when they're emitted instead of filtering them out at the very end.

Obviously, this isn't something for this PR but maybe a fun refactor for another day ;)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, please. Every single time I've run into lifetime issues or borrow checker complaints, it was because of a mutable borrow of Checker. This also would allow us to pass the checker directly into some of the helper functions, instead of having to pass five separate arguments that are all just borrowed from the checker.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sounds like fun I'll give it a shot!

}
}
Stmt::Assign(ast::StmtAssign { value, targets, .. }) => {
Expand All @@ -89,9 +91,7 @@ pub(crate) fn mutable_class_default(checker: &mut Checker, class_def: &ast::Stmt
return;
}

checker
.diagnostics
.push(Diagnostic::new(MutableClassDefault, value.range()));
diagnostics.push(Diagnostic::new(MutableClassDefault, value.range()));
}
}
_ => (),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ impl Violation for MutableDataclassDefault {
}

/// RUF008
pub(crate) fn mutable_dataclass_default(checker: &mut Checker, class_def: &ast::StmtClassDef) {
pub(crate) fn mutable_dataclass_default(
checker: &Checker,
class_def: &ast::StmtClassDef,
diagnostics: &mut Vec<Diagnostic>,
) {
let semantic = checker.semantic();

if dataclass_kind(class_def, semantic).is_none() {
Expand All @@ -88,7 +92,7 @@ pub(crate) fn mutable_dataclass_default(checker: &mut Checker, class_def: &ast::
{
let diagnostic = Diagnostic::new(MutableDataclassDefault, value.range());

checker.diagnostics.push(diagnostic);
diagnostics.push(diagnostic);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
snapshot_kind: text
---
RUF008.py:10:34: RUF008 Do not use mutable default values for dataclass attributes
|
Expand All @@ -21,3 +20,31 @@ RUF008.py:20:34: RUF008 Do not use mutable default values for dataclass attribut
21 | immutable_annotation: Sequence[int] = []
22 | without_annotation = []
|

RUF008.py:31:36: RUF008 Do not use mutable default values for dataclass attributes
|
29 | @dataclass
30 | class AWithQuotes:
31 | mutable_default: 'list[int]' = []
| ^^ RUF008
32 | immutable_annotation: 'typing.Sequence[int]' = []
33 | without_annotation = []
|

RUF008.py:32:52: RUF008 Do not use mutable default values for dataclass attributes
|
30 | class AWithQuotes:
31 | mutable_default: 'list[int]' = []
32 | immutable_annotation: 'typing.Sequence[int]' = []
| ^^ RUF008
33 | without_annotation = []
34 | correct_code: 'list[int]' = KNOWINGLY_MUTABLE_DEFAULT
|

RUF008.py:36:51: RUF008 Do not use mutable default values for dataclass attributes
|
34 | correct_code: 'list[int]' = KNOWINGLY_MUTABLE_DEFAULT
35 | perfectly_fine: 'list[int]' = field(default_factory=list)
36 | class_variable: 'typing.ClassVar[list[int]]'= []
| ^^ RUF008
|
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF009_deferred.py:10:41: RUF009 Do not perform function call `default_function` in dataclass defaults
|
8 | @dataclass()
9 | class A:
10 | hidden_mutable_default: list[int] = default_function()
| ^^^^^^^^^^^^^^^^^^ RUF009
11 | class_variable: typing.ClassVar[list[int]] = default_function()
12 | another_class_var: ClassVar[list[int]] = default_function()
|

RUF009_deferred.py:11:50: RUF009 Do not perform function call `default_function` in dataclass defaults
|
9 | class A:
10 | hidden_mutable_default: list[int] = default_function()
11 | class_variable: typing.ClassVar[list[int]] = default_function()
| ^^^^^^^^^^^^^^^^^^ RUF009
12 | another_class_var: ClassVar[list[int]] = default_function()
|
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,69 @@ RUF012.py:89:38: RUF012 Mutable class attributes should be annotated with `typin
90 |
91 | from sqlmodel import SQLModel
|

RUF012.py:114:36: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
112 | }
113 |
114 | mutable_default: 'list[int]' = []
| ^^ RUF012
115 | immutable_annotation: 'Sequence[int]'= []
116 | without_annotation = []
|

RUF012.py:115:44: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
114 | mutable_default: 'list[int]' = []
115 | immutable_annotation: 'Sequence[int]'= []
| ^^ RUF012
116 | without_annotation = []
117 | class_variable: 'ClassVar[list[int]]' = []
|

RUF012.py:116:26: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
114 | mutable_default: 'list[int]' = []
115 | immutable_annotation: 'Sequence[int]'= []
116 | without_annotation = []
| ^^ RUF012
117 | class_variable: 'ClassVar[list[int]]' = []
118 | final_variable: 'Final[list[int]]' = []
|

RUF012.py:117:45: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
115 | immutable_annotation: 'Sequence[int]'= []
116 | without_annotation = []
117 | class_variable: 'ClassVar[list[int]]' = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, why are we emitting an error here? It looks like it already is annotated with ClassVar?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Somehow I forgot to actually check that stringized annotations were working... and they aren't!

It turns out that fixing that is gonna require other changes (and I noticed a few other affected rules). So I'm gonna leave that as a followup PR and just solve the problem in the linked issue for this PR. I did move one other related rule to deferred_scopes.

@AlexWaygood AlexWaygood Feb 5, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ahh, and I see this isn't a regression; we also have this false negative on main. Feel free to merge, in that case!

Probably a lot more rules should be using this helper function when they inspect annotations:

/// Apply a test to an annotation expression,
/// abstracting over the fact that the annotation expression might be "stringized".
///
/// A stringized annotation is one enclosed in string quotes:
/// `foo: "typing.Any"` means the same thing to a type checker as `foo: typing.Any`.
pub(crate) fn match_maybe_stringized_annotation(
&self,
expr: &ast::Expr,
match_fn: impl FnOnce(&ast::Expr) -> bool,
) -> bool {
if let ast::Expr::StringLiteral(string_annotation) = expr {
let Some(parsed_annotation) = self.parse_type_annotation(string_annotation).ok() else {
return false;
};
match_fn(parsed_annotation.expression())
} else {
match_fn(expr)
}
}

(I introduced the helper in #12951 ;)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes exactly! Originally I started to use that helpful helper, but then noticed the number of places I had to put it was sort of exploding. So I wanted to take a step back in a separate PR and either see if there's a better way or if that failed then at least separate that change from the one in this PR.

| ^^ RUF012
118 | final_variable: 'Final[list[int]]' = []
119 | class_variable_without_subscript: 'ClassVar' = []
|

RUF012.py:118:42: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
116 | without_annotation = []
117 | class_variable: 'ClassVar[list[int]]' = []
118 | final_variable: 'Final[list[int]]' = []
| ^^ RUF012
119 | class_variable_without_subscript: 'ClassVar' = []
120 | final_variable_without_subscript: 'Final' = []
|

RUF012.py:119:52: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
117 | class_variable: 'ClassVar[list[int]]' = []
118 | final_variable: 'Final[list[int]]' = []
119 | class_variable_without_subscript: 'ClassVar' = []
| ^^ RUF012
120 | final_variable_without_subscript: 'Final' = []
|

RUF012.py:120:49: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
|
118 | final_variable: 'Final[list[int]]' = []
119 | class_variable_without_subscript: 'ClassVar' = []
120 | final_variable_without_subscript: 'Final' = []
| ^^ RUF012
|
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---