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

## Summary

Implement lint rule to flag un-annotated variable assignments in dataclass definitions.

Resolves #12877.

---------

Co-authored-by: dylwil3 <dylwil3@gmail.com>
This commit is contained in:
InSync 2025-02-15 22:08:13 +07:00 committed by GitHub
parent 171facd960
commit 3c69b685ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 173 additions and 0 deletions

View file

@ -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]

View file

@ -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) {

View file

@ -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),

View file

@ -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__{}_{}",

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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[...]`