diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF045.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF045.py new file mode 100644 index 0000000000..fed9c6ba33 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF045.py @@ -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] diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 3d11635edd..0d75fb127a 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -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) { diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 8891336f9b..0d7ffe4e29 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -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), diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 118f1982ae..8422a3fb95 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -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__{}_{}", diff --git a/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs b/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs new file mode 100644 index 0000000000..545ecf7e39 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs @@ -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 { + 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); + } +} diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 18bc32cec5..5a603b6bc3 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -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::*; @@ -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; diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF045_RUF045.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF045_RUF045.py.snap new file mode 100644 index 0000000000..5e1aa23b88 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF045_RUF045.py.snap @@ -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[...]` diff --git a/ruff.schema.json b/ruff.schema.json index 97dec43e9f..b3b1c995fc 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3947,6 +3947,7 @@ "RUF040", "RUF041", "RUF043", + "RUF045", "RUF046", "RUF047", "RUF048",