mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 02:38:25 +00:00
[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:
parent
171facd960
commit
3c69b685ee
8 changed files with 173 additions and 0 deletions
29
crates/ruff_linter/resources/test/fixtures/ruff/RUF045.py
vendored
Normal file
29
crates/ruff_linter/resources/test/fixtures/ruff/RUF045.py
vendored
Normal 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]
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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__{}_{}",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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[...]`
|
Loading…
Add table
Add a link
Reference in a new issue