diff --git a/crates/ruff/resources/test/fixtures/pylint/bad_dunder_method_name.py b/crates/ruff/resources/test/fixtures/pylint/bad_dunder_method_name.py new file mode 100644 index 0000000000000..46d8be249bc95 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/pylint/bad_dunder_method_name.py @@ -0,0 +1,46 @@ +class Apples: + def _init_(self): # [bad-dunder-name] + pass + + def __hello__(self): # [bad-dunder-name] + print("hello") + + def __init_(self): # [bad-dunder-name] + # author likely unintentionally misspelled the correct init dunder. + pass + + def _init_(self): # [bad-dunder-name] + # author likely unintentionally misspelled the correct init dunder. + pass + + def ___neg__(self): # [bad-dunder-name] + # author likely accidentally added an additional `_` + pass + + def __inv__(self): # [bad-dunder-name] + # author likely meant to call the invert dunder method + pass + + def hello(self): + print("hello") + + def __init__(self): + pass + + def init(self): + # valid name even though someone could accidentally mean __init__ + pass + + def _protected_method(self): + print("Protected") + + def __private_method(self): + print("Private") + + @property + def __doc__(self): + return "Docstring" + + +def __foo_bar__(): # this is not checked by the [bad-dunder-name] rule + ... diff --git a/crates/ruff/src/checkers/ast/analyze/statement.rs b/crates/ruff/src/checkers/ast/analyze/statement.rs index 366f324511b47..25b96d7126e29 100644 --- a/crates/ruff/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff/src/checkers/ast/analyze/statement.rs @@ -509,6 +509,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.enabled(Rule::SingleStringSlots) { pylint::rules::single_string_slots(checker, class_def); } + if checker.enabled(Rule::BadDunderMethodName) { + pylint::rules::bad_dunder_method_name(checker, body); + } } Stmt::Import(ast::StmtImport { names, range: _ }) => { if checker.enabled(Rule::MultipleImportsOnOneLine) { diff --git a/crates/ruff/src/codes.rs b/crates/ruff/src/codes.rs index f7e3499fc8c8f..d7bb7cf7a5f0b 100644 --- a/crates/ruff/src/codes.rs +++ b/crates/ruff/src/codes.rs @@ -229,6 +229,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Pylint, "W1510") => (RuleGroup::Unspecified, rules::pylint::rules::SubprocessRunWithoutCheck), (Pylint, "W1641") => (RuleGroup::Nursery, rules::pylint::rules::EqWithoutHash), (Pylint, "W2901") => (RuleGroup::Unspecified, rules::pylint::rules::RedefinedLoopName), + (Pylint, "W3201") => (RuleGroup::Nursery, rules::pylint::rules::BadDunderMethodName), (Pylint, "W3301") => (RuleGroup::Unspecified, rules::pylint::rules::NestedMinMax), // flake8-async diff --git a/crates/ruff/src/rules/pylint/mod.rs b/crates/ruff/src/rules/pylint/mod.rs index d1dbf8990147f..7611f18fba27d 100644 --- a/crates/ruff/src/rules/pylint/mod.rs +++ b/crates/ruff/src/rules/pylint/mod.rs @@ -130,6 +130,7 @@ mod tests { Rule::SubprocessRunWithoutCheck, Path::new("subprocess_run_without_check.py") )] + #[test_case(Rule::BadDunderMethodName, Path::new("bad_dunder_method_name.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff/src/rules/pylint/rules/bad_dunder_method_name.rs b/crates/ruff/src/rules/pylint/rules/bad_dunder_method_name.rs new file mode 100644 index 0000000000000..3887ab252af9c --- /dev/null +++ b/crates/ruff/src/rules/pylint/rules/bad_dunder_method_name.rs @@ -0,0 +1,192 @@ +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::identifier::Identifier; +use ruff_python_ast::Stmt; + +use crate::checkers::ast::Checker; + +/// ## What it does +/// Checks for any misspelled dunder name method and for any method +/// defined with `__...__` that's not one of the pre-defined methods. +/// +/// The pre-defined methods encompass all of Python's standard dunder +/// methods. +/// +/// ## Why is this bad? +/// Misspelled dunder name methods may cause your code to not function +/// as expected. +/// +/// Since dunder methods are associated with customizing the behavior +/// of a class in Python, introducing a dunder method such as `__foo__` +/// that diverges from standard Python dunder methods could potentially +/// confuse someone reading the code. +/// +/// ## Example +/// ```python +/// class Foo: +/// def __init_(self): +/// ... +/// ``` +/// +/// Use instead: +/// ```python +/// class Foo: +/// def __init__(self): +/// ... +/// ``` +#[violation] +pub struct BadDunderMethodName { + name: String, +} + +impl Violation for BadDunderMethodName { + #[derive_message_formats] + fn message(&self) -> String { + let BadDunderMethodName { name } = self; + format!("Bad or misspelled dunder method name `{name}`. (bad-dunder-name)") + } +} + +/// PLW3201 +pub(crate) fn bad_dunder_method_name(checker: &mut Checker, class_body: &[Stmt]) { + for method in class_body + .iter() + .filter_map(ruff_python_ast::Stmt::as_function_def_stmt) + .filter(|method| { + if is_known_dunder_method(&method.name) { + return false; + } + method.name.starts_with('_') && method.name.ends_with('_') + }) + { + checker.diagnostics.push(Diagnostic::new( + BadDunderMethodName { + name: method.name.to_string(), + }, + method.identifier(), + )); + } +} + +/// Returns `true` if a method is a known dunder method. +fn is_known_dunder_method(method: &str) -> bool { + matches!( + method, + "__abs__" + | "__add__" + | "__aenter__" + | "__aexit__" + | "__aiter__" + | "__and__" + | "__anext__" + | "__await__" + | "__bool__" + | "__bytes__" + | "__call__" + | "__ceil__" + | "__class__" + | "__class_getitem__" + | "__complex__" + | "__contains__" + | "__copy__" + | "__deepcopy__" + | "__del__" + | "__delattr__" + | "__delete__" + | "__delitem__" + | "__dict__" + | "__dir__" + | "__divmod__" + | "__doc__" + | "__enter__" + | "__eq__" + | "__exit__" + | "__float__" + | "__floor__" + | "__floordiv__" + | "__format__" + | "__fspath__" + | "__ge__" + | "__get__" + | "__getattr__" + | "__getattribute__" + | "__getitem__" + | "__getnewargs__" + | "__getnewargs_ex__" + | "__getstate__" + | "__gt__" + | "__hash__" + | "__iadd__" + | "__iand__" + | "__ifloordiv__" + | "__ilshift__" + | "__imatmul__" + | "__imod__" + | "__imul__" + | "__init__" + | "__init_subclass__" + | "__instancecheck__" + | "__int__" + | "__invert__" + | "__ior__" + | "__ipow__" + | "__irshift__" + | "__isub__" + | "__iter__" + | "__itruediv__" + | "__ixor__" + | "__le__" + | "__len__" + | "__length_hint__" + | "__lshift__" + | "__lt__" + | "__matmul__" + | "__missing__" + | "__mod__" + | "__module__" + | "__mul__" + | "__ne__" + | "__neg__" + | "__new__" + | "__next__" + | "__or__" + | "__pos__" + | "__post_init__" + | "__pow__" + | "__radd__" + | "__rand__" + | "__rdivmod__" + | "__reduce__" + | "__reduce_ex__" + | "__repr__" + | "__reversed__" + | "__rfloordiv__" + | "__rlshift__" + | "__rmatmul__" + | "__rmod__" + | "__rmul__" + | "__ror__" + | "__round__" + | "__rpow__" + | "__rrshift__" + | "__rshift__" + | "__rsub__" + | "__rtruediv__" + | "__rxor__" + | "__set__" + | "__set_name__" + | "__setattr__" + | "__setitem__" + | "__setstate__" + | "__sizeof__" + | "__str__" + | "__sub__" + | "__subclasscheck__" + | "__subclasses__" + | "__subclasshook__" + | "__truediv__" + | "__trunc__" + | "__weakref__" + | "__xor__" + ) +} diff --git a/crates/ruff/src/rules/pylint/rules/mod.rs b/crates/ruff/src/rules/pylint/rules/mod.rs index c84dd03b3b86f..a2a498cb87100 100644 --- a/crates/ruff/src/rules/pylint/rules/mod.rs +++ b/crates/ruff/src/rules/pylint/rules/mod.rs @@ -1,5 +1,6 @@ pub(crate) use assert_on_string_literal::*; pub(crate) use await_outside_async::*; +pub(crate) use bad_dunder_method_name::*; pub(crate) use bad_str_strip_call::*; pub(crate) use bad_string_format_character::BadStringFormatCharacter; pub(crate) use bad_string_format_type::*; @@ -56,6 +57,7 @@ pub(crate) use yield_in_init::*; mod assert_on_string_literal; mod await_outside_async; +mod bad_dunder_method_name; mod bad_str_strip_call; pub(crate) mod bad_string_format_character; mod bad_string_format_type; diff --git a/crates/ruff/src/rules/pylint/snapshots/ruff__rules__pylint__tests__PLW3201_bad_dunder_method_name.py.snap b/crates/ruff/src/rules/pylint/snapshots/ruff__rules__pylint__tests__PLW3201_bad_dunder_method_name.py.snap new file mode 100644 index 0000000000000..f3c6b8166115c --- /dev/null +++ b/crates/ruff/src/rules/pylint/snapshots/ruff__rules__pylint__tests__PLW3201_bad_dunder_method_name.py.snap @@ -0,0 +1,61 @@ +--- +source: crates/ruff/src/rules/pylint/mod.rs +--- +bad_dunder_method_name.py:2:9: PLW3201 Bad or misspelled dunder method name `_init_`. (bad-dunder-name) + | +1 | class Apples: +2 | def _init_(self): # [bad-dunder-name] + | ^^^^^^ PLW3201 +3 | pass + | + +bad_dunder_method_name.py:5:9: PLW3201 Bad or misspelled dunder method name `__hello__`. (bad-dunder-name) + | +3 | pass +4 | +5 | def __hello__(self): # [bad-dunder-name] + | ^^^^^^^^^ PLW3201 +6 | print("hello") + | + +bad_dunder_method_name.py:8:9: PLW3201 Bad or misspelled dunder method name `__init_`. (bad-dunder-name) + | + 6 | print("hello") + 7 | + 8 | def __init_(self): # [bad-dunder-name] + | ^^^^^^^ PLW3201 + 9 | # author likely unintentionally misspelled the correct init dunder. +10 | pass + | + +bad_dunder_method_name.py:12:9: PLW3201 Bad or misspelled dunder method name `_init_`. (bad-dunder-name) + | +10 | pass +11 | +12 | def _init_(self): # [bad-dunder-name] + | ^^^^^^ PLW3201 +13 | # author likely unintentionally misspelled the correct init dunder. +14 | pass + | + +bad_dunder_method_name.py:16:9: PLW3201 Bad or misspelled dunder method name `___neg__`. (bad-dunder-name) + | +14 | pass +15 | +16 | def ___neg__(self): # [bad-dunder-name] + | ^^^^^^^^ PLW3201 +17 | # author likely accidentally added an additional `_` +18 | pass + | + +bad_dunder_method_name.py:20:9: PLW3201 Bad or misspelled dunder method name `__inv__`. (bad-dunder-name) + | +18 | pass +19 | +20 | def __inv__(self): # [bad-dunder-name] + | ^^^^^^^ PLW3201 +21 | # author likely meant to call the invert dunder method +22 | pass + | + + diff --git a/ruff.schema.json b/ruff.schema.json index ac9a08c559274..d28a8b49d5459 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2291,6 +2291,7 @@ "PLW290", "PLW2901", "PLW3", + "PLW3201", "PLW33", "PLW330", "PLW3301",