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

[ruff] Implicit class variable in dataclass (RUF045) #14349

Merged
merged 11 commits into from
Feb 15, 2025
29 changes: 29 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF045.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from dataclasses import InitVar, KW_ONLY, MISSING, dataclass, field
from typing import ClassVar


@dataclass
class C:
# Errors
no_annotation = r"foo"
missing = MISSING
field = field()

# No errors
__slots__ = ("foo", "bar")
__radd__ = __add__
_private_attr = 100

with_annotation: str
with_annotation_and_default: int = 42
with_annotation_and_field_specifier: bytes = field()

class_var_no_arguments: ClassVar = 42
class_var_with_arguments: ClassVar[int] = 42

init_var_no_arguments: InitVar = "lorem"
init_var_with_arguments: InitVar[str] = "ipsum"

kw_only: KW_ONLY
tu, ple, [unp, ack, ing] = (0, 1, 2, [3, 4, 5])
mul, [ti, ple] = (a, ssign), ment = {1: b"3", "2": 4}, [6j, 5]
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if checker.enabled(Rule::ClassWithMixedTypeVars) {
ruff::rules::class_with_mixed_type_vars(checker, class_def);
}
if checker.enabled(Rule::ImplicitClassVarInDataclass) {
ruff::rules::implicit_class_var_in_dataclass(checker, class_def);
}
}
Stmt::Import(ast::StmtImport { names, range: _ }) => {
if checker.enabled(Rule::MultipleImportsOnOneLine) {
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "040") => (RuleGroup::Preview, rules::ruff::rules::InvalidAssertMessageLiteralArgument),
(Ruff, "041") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryNestedLiteral),
(Ruff, "043") => (RuleGroup::Preview, rules::ruff::rules::PytestRaisesAmbiguousPattern),
(Ruff, "045") => (RuleGroup::Preview, rules::ruff::rules::ImplicitClassVarInDataclass),
(Ruff, "046") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryCastToInt),
(Ruff, "047") => (RuleGroup::Preview, rules::ruff::rules::NeedlessElse),
(Ruff, "048") => (RuleGroup::Preview, rules::ruff::rules::MapIntVersionParsing),
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ mod tests {
#[test_case(Rule::StarmapZip, Path::new("RUF058_1.py"))]
#[test_case(Rule::ClassWithMixedTypeVars, Path::new("RUF053.py"))]
#[test_case(Rule::IndentedFormFeed, Path::new("RUF054.py"))]
#[test_case(Rule::ImplicitClassVarInDataclass, Path::new("RUF045.py"))]
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"preview__{}_{}",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::helpers::is_dunder;
use ruff_python_ast::{Expr, ExprName, Stmt, StmtAssign, StmtClassDef};
use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
use crate::rules::ruff::rules::helpers::{dataclass_kind, DataclassKind};

/// ## What it does
/// Checks for implicit class variables in dataclasses.
///
/// Variables matching the [`lint.dummy-variable-rgx`] are excluded
/// from this rule.
///
/// ## Why is this bad?
/// Class variables are shared between all instances of that class.
/// In dataclasses, fields with no annotations at all
/// are implicitly considered class variables, and a `TypeError` is
/// raised if a user attempts to initialize an instance of the class
/// with this field.
///
///
/// ```python
/// @dataclass
/// class C:
/// a = 1
/// b: str = ""
///
/// C(a = 42) # TypeError: C.__init__() got an unexpected keyword argument 'a'
/// ```
///
/// ## Example
///
/// ```python
/// @dataclass
/// class C:
/// a = 1
/// ```
///
/// Use instead:
///
/// ```python
/// from typing import ClassVar
///
///
/// @dataclass
/// class C:
/// a: ClassVar[int] = 1
/// ```
///
/// ## Options
/// - [`lint.dummy-variable-rgx`]
#[derive(ViolationMetadata)]
pub(crate) struct ImplicitClassVarInDataclass;

impl Violation for ImplicitClassVarInDataclass {
#[derive_message_formats]
fn message(&self) -> String {
"Assignment without annotation found in dataclass body".to_string()
}

fn fix_title(&self) -> Option<String> {
Some("Use `ClassVar[...]`".to_string())
}
}

/// RUF045
pub(crate) fn implicit_class_var_in_dataclass(checker: &mut Checker, class_def: &StmtClassDef) {
let dataclass_kind = dataclass_kind(class_def, checker.semantic());

if !matches!(dataclass_kind, Some((DataclassKind::Stdlib, _))) {
return;
};

for statement in &class_def.body {
let Stmt::Assign(StmtAssign { targets, .. }) = statement else {
continue;
};

if targets.len() > 1 {
continue;
}

let target = targets.first().unwrap();
let Expr::Name(ExprName { id, .. }) = target else {
continue;
};

if checker.settings.dummy_variable_rgx.is_match(id.as_str()) {
continue;
}

if is_dunder(id.as_str()) {
continue;
}

let diagnostic = Diagnostic::new(ImplicitClassVarInDataclass, target.range());

checker.report_diagnostic(diagnostic);
}
}
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/ruff/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub(crate) use explicit_f_string_type_conversion::*;
pub(crate) use falsy_dict_get_fallback::*;
pub(crate) use function_call_in_dataclass_default::*;
pub(crate) use if_key_in_dict_del::*;
pub(crate) use implicit_classvar_in_dataclass::*;
pub(crate) use implicit_optional::*;
pub(crate) use incorrectly_parenthesized_tuple_in_subscript::*;
pub(crate) use indented_form_feed::*;
Expand Down Expand Up @@ -68,6 +69,7 @@ mod falsy_dict_get_fallback;
mod function_call_in_dataclass_default;
mod helpers;
mod if_key_in_dict_del;
mod implicit_classvar_in_dataclass;
mod implicit_optional;
mod incorrectly_parenthesized_tuple_in_subscript;
mod indented_form_feed;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF045.py:8:5: RUF045 Assignment without annotation found in dataclass body
|
6 | class C:
7 | # Errors
8 | no_annotation = r"foo"
| ^^^^^^^^^^^^^ RUF045
9 | missing = MISSING
10 | field = field()
|
= help: Use `ClassVar[...]`

RUF045.py:9:5: RUF045 Assignment without annotation found in dataclass body
|
7 | # Errors
8 | no_annotation = r"foo"
9 | missing = MISSING
| ^^^^^^^ RUF045
10 | field = field()
|
= help: Use `ClassVar[...]`

RUF045.py:10:5: RUF045 Assignment without annotation found in dataclass body
|
8 | no_annotation = r"foo"
9 | missing = MISSING
10 | field = field()
| ^^^^^ RUF045
11 |
12 | # No errors
|
= help: Use `ClassVar[...]`
1 change: 1 addition & 0 deletions ruff.schema.json

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

Loading