diff --git a/crates/ruff/resources/test/fixtures/flake8_bugbear/B027_extended.py b/crates/ruff/resources/test/fixtures/flake8_bugbear/B027_extended.py new file mode 100644 index 0000000000..42ce3a6636 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_bugbear/B027_extended.py @@ -0,0 +1,39 @@ +""" +Should emit: +B027 - on lines 13, 16, 19, 23 +""" +from abc import ABC + + +class AbstractClass(ABC): + def empty_1(self): # error + ... + + def empty_2(self): # error + pass + + def body_1(self): + print("foo") + ... + + def body_2(self): + self.body_1() + + +def foo(): + class InnerAbstractClass(ABC): + def empty_1(self): # error + ... + + def empty_2(self): # error + pass + + def body_1(self): + print("foo") + ... + + def body_2(self): + self.body_1() + + return InnerAbstractClass + diff --git a/crates/ruff/src/rules/flake8_bugbear/mod.rs b/crates/ruff/src/rules/flake8_bugbear/mod.rs index a25369f284..2a090ae515 100644 --- a/crates/ruff/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff/src/rules/flake8_bugbear/mod.rs @@ -42,6 +42,7 @@ mod tests { #[test_case(Rule::StarArgUnpackingAfterKeywordArg, Path::new("B026.py"); "B026")] #[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.py"); "B027")] #[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.pyi"); "B027_pyi")] + #[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027_extended.py"); "B027_extended")] #[test_case(Rule::NoExplicitStacklevel, Path::new("B028.py"); "B028")] #[test_case(Rule::ExceptWithEmptyTuple, Path::new("B029.py"); "B029")] #[test_case(Rule::ExceptWithNonExceptionClasses, Path::new("B030.py"); "B030")] diff --git a/crates/ruff/src/rules/flake8_bugbear/rules/abstract_base_class.rs b/crates/ruff/src/rules/flake8_bugbear/rules/abstract_base_class.rs index 967afa1d86..f49db94cee 100644 --- a/crates/ruff/src/rules/flake8_bugbear/rules/abstract_base_class.rs +++ b/crates/ruff/src/rules/flake8_bugbear/rules/abstract_base_class.rs @@ -1,10 +1,16 @@ +use anyhow::{anyhow, Result}; use rustpython_parser::ast::{Constant, Expr, ExprKind, Keyword, Stmt, StmtKind}; -use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Edit, Fix, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::source_code::{Locator, Stylist}; +use ruff_python_ast::whitespace::indentation; use ruff_python_semantic::analyze::visibility::{is_abstract, is_overload}; +use ruff_python_semantic::context::Context; +use crate::autofix::actions::get_or_import_symbol; use crate::checkers::ast::Checker; +use crate::importer::Importer; use crate::registry::Rule; #[violation] @@ -19,12 +25,13 @@ impl Violation for AbstractBaseClassWithoutAbstractMethod { format!("`{name}` is an abstract base class, but it has no abstract methods") } } + #[violation] pub struct EmptyMethodWithoutAbstractDecorator { pub name: String, } -impl Violation for EmptyMethodWithoutAbstractDecorator { +impl AlwaysAutofixableViolation for EmptyMethodWithoutAbstractDecorator { #[derive_message_formats] fn message(&self) -> String { let EmptyMethodWithoutAbstractDecorator { name } = self; @@ -32,24 +39,26 @@ impl Violation for EmptyMethodWithoutAbstractDecorator { "`{name}` is an empty method in an abstract base class, but has no abstract decorator" ) } + + fn autofix_title(&self) -> String { + "Add the `@abstractmethod` decorator".to_string() + } } -fn is_abc_class(checker: &Checker, bases: &[Expr], keywords: &[Keyword]) -> bool { +fn is_abc_class(context: &Context, bases: &[Expr], keywords: &[Keyword]) -> bool { keywords.iter().any(|keyword| { keyword .node .arg .as_ref() .map_or(false, |arg| arg == "metaclass") - && checker - .ctx + && context .resolve_call_path(&keyword.node.value) .map_or(false, |call_path| { call_path.as_slice() == ["abc", "ABCMeta"] }) }) || bases.iter().any(|base| { - checker - .ctx + context .resolve_call_path(base) .map_or(false, |call_path| call_path.as_slice() == ["abc", "ABC"]) }) @@ -68,6 +77,28 @@ fn is_empty_body(body: &[Stmt]) -> bool { }) } +fn fix_abstractmethod_missing( + context: &Context, + importer: &Importer, + locator: &Locator, + stylist: &Stylist, + stmt: &Stmt, +) -> Result { + let indent = indentation(locator, stmt).ok_or(anyhow!("Unable to detect indentation"))?; + let (import_edit, binding) = + get_or_import_symbol("abc", "abstractmethod", context, importer, locator)?; + let reference_edit = Edit::insertion( + format!( + "@{binding}{line_ending}{indent}", + line_ending = stylist.line_ending().as_str(), + ), + stmt.range().start(), + ); + Ok(Fix::from_iter([import_edit, reference_edit])) +} + +/// B024 +/// B027 pub fn abstract_base_class( checker: &mut Checker, stmt: &Stmt, @@ -79,7 +110,7 @@ pub fn abstract_base_class( if bases.len() + keywords.len() != 1 { return; } - if !is_abc_class(checker, bases, keywords) { + if !is_abc_class(&checker.ctx, bases, keywords) { return; } @@ -123,12 +154,24 @@ pub fn abstract_base_class( && is_empty_body(body) && !is_overload(&checker.ctx, decorator_list) { - checker.diagnostics.push(Diagnostic::new( + let mut diagnostic = Diagnostic::new( EmptyMethodWithoutAbstractDecorator { name: format!("{name}.{method_name}"), }, stmt.range(), - )); + ); + if checker.patch(Rule::EmptyMethodWithoutAbstractDecorator) { + diagnostic.try_set_fix(|| { + fix_abstractmethod_missing( + &checker.ctx, + &checker.importer, + checker.locator, + checker.stylist, + stmt, + ) + }); + } + checker.diagnostics.push(diagnostic); } } if checker diff --git a/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027.py.snap b/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027.py.snap index 0385d12459..31830c9e92 100644 --- a/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027.py.snap +++ b/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027.py.snap @@ -1,7 +1,7 @@ --- source: crates/ruff/src/rules/flake8_bugbear/mod.rs --- -B027.py:13:5: B027 `AbstractClass.empty_1` is an empty method in an abstract base class, but has no abstract decorator +B027.py:13:5: B027 [*] `AbstractClass.empty_1` is an empty method in an abstract base class, but has no abstract decorator | 13 | class AbstractClass(ABC): 14 | def empty_1(self): # error @@ -11,8 +11,18 @@ B027.py:13:5: B027 `AbstractClass.empty_1` is an empty method in an abstract bas 16 | 17 | def empty_2(self): # error | + = help: Add the `@abstractmethod` decorator -B027.py:16:5: B027 `AbstractClass.empty_2` is an empty method in an abstract base class, but has no abstract decorator +ℹ Suggested fix +10 10 | +11 11 | +12 12 | class AbstractClass(ABC): + 13 |+ @notabstract +13 14 | def empty_1(self): # error +14 15 | ... +15 16 | + +B027.py:16:5: B027 [*] `AbstractClass.empty_2` is an empty method in an abstract base class, but has no abstract decorator | 16 | ... 17 | @@ -23,8 +33,18 @@ B027.py:16:5: B027 `AbstractClass.empty_2` is an empty method in an abstract bas 20 | 21 | def empty_3(self): # error | + = help: Add the `@abstractmethod` decorator -B027.py:19:5: B027 `AbstractClass.empty_3` is an empty method in an abstract base class, but has no abstract decorator +ℹ Suggested fix +13 13 | def empty_1(self): # error +14 14 | ... +15 15 | + 16 |+ @notabstract +16 17 | def empty_2(self): # error +17 18 | pass +18 19 | + +B027.py:19:5: B027 [*] `AbstractClass.empty_3` is an empty method in an abstract base class, but has no abstract decorator | 19 | pass 20 | @@ -36,8 +56,18 @@ B027.py:19:5: B027 `AbstractClass.empty_3` is an empty method in an abstract bas 24 | 25 | def empty_4(self): # error | + = help: Add the `@abstractmethod` decorator -B027.py:23:5: B027 `AbstractClass.empty_4` is an empty method in an abstract base class, but has no abstract decorator +ℹ Suggested fix +16 16 | def empty_2(self): # error +17 17 | pass +18 18 | + 19 |+ @notabstract +19 20 | def empty_3(self): # error +20 21 | """docstring""" +21 22 | ... + +B027.py:23:5: B027 [*] `AbstractClass.empty_4` is an empty method in an abstract base class, but has no abstract decorator | 23 | ... 24 | @@ -52,5 +82,15 @@ B027.py:23:5: B027 `AbstractClass.empty_4` is an empty method in an abstract bas 31 | 32 | @notabstract | + = help: Add the `@abstractmethod` decorator + +ℹ Suggested fix +20 20 | """docstring""" +21 21 | ... +22 22 | + 23 |+ @notabstract +23 24 | def empty_4(self): # error +24 25 | """multiple ellipsis/pass""" +25 26 | ... diff --git a/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027_extended.py.snap b/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027_extended.py.snap new file mode 100644 index 0000000000..2ee081c6f5 --- /dev/null +++ b/crates/ruff/src/rules/flake8_bugbear/snapshots/ruff__rules__flake8_bugbear__tests__B027_B027_extended.py.snap @@ -0,0 +1,122 @@ +--- +source: crates/ruff/src/rules/flake8_bugbear/mod.rs +--- +B027_extended.py:9:5: B027 [*] `AbstractClass.empty_1` is an empty method in an abstract base class, but has no abstract decorator + | + 9 | class AbstractClass(ABC): +10 | def empty_1(self): # error + | _____^ +11 | | ... + | |___________^ B027 +12 | +13 | def empty_2(self): # error + | + = help: Add the `@abstractmethod` decorator + +ℹ Suggested fix +2 2 | Should emit: +3 3 | B027 - on lines 13, 16, 19, 23 +4 4 | """ +5 |-from abc import ABC + 5 |+from abc import ABC, abstractmethod +6 6 | +7 7 | +8 8 | class AbstractClass(ABC): + 9 |+ @abstractmethod +9 10 | def empty_1(self): # error +10 11 | ... +11 12 | + +B027_extended.py:12:5: B027 [*] `AbstractClass.empty_2` is an empty method in an abstract base class, but has no abstract decorator + | +12 | ... +13 | +14 | def empty_2(self): # error + | _____^ +15 | | pass + | |____________^ B027 +16 | +17 | def body_1(self): + | + = help: Add the `@abstractmethod` decorator + +ℹ Suggested fix +2 2 | Should emit: +3 3 | B027 - on lines 13, 16, 19, 23 +4 4 | """ +5 |-from abc import ABC + 5 |+from abc import ABC, abstractmethod +6 6 | +7 7 | +8 8 | class AbstractClass(ABC): +9 9 | def empty_1(self): # error +10 10 | ... +11 11 | + 12 |+ @abstractmethod +12 13 | def empty_2(self): # error +13 14 | pass +14 15 | + +B027_extended.py:25:9: B027 [*] `InnerAbstractClass.empty_1` is an empty method in an abstract base class, but has no abstract decorator + | +25 | def foo(): +26 | class InnerAbstractClass(ABC): +27 | def empty_1(self): # error + | _________^ +28 | | ... + | |_______________^ B027 +29 | +30 | def empty_2(self): # error + | + = help: Add the `@abstractmethod` decorator + +ℹ Suggested fix +2 2 | Should emit: +3 3 | B027 - on lines 13, 16, 19, 23 +4 4 | """ +5 |-from abc import ABC + 5 |+from abc import ABC, abstractmethod +6 6 | +7 7 | +8 8 | class AbstractClass(ABC): +-------------------------------------------------------------------------------- +22 22 | +23 23 | def foo(): +24 24 | class InnerAbstractClass(ABC): + 25 |+ @abstractmethod +25 26 | def empty_1(self): # error +26 27 | ... +27 28 | + +B027_extended.py:28:9: B027 [*] `InnerAbstractClass.empty_2` is an empty method in an abstract base class, but has no abstract decorator + | +28 | ... +29 | +30 | def empty_2(self): # error + | _________^ +31 | | pass + | |________________^ B027 +32 | +33 | def body_1(self): + | + = help: Add the `@abstractmethod` decorator + +ℹ Suggested fix +2 2 | Should emit: +3 3 | B027 - on lines 13, 16, 19, 23 +4 4 | """ +5 |-from abc import ABC + 5 |+from abc import ABC, abstractmethod +6 6 | +7 7 | +8 8 | class AbstractClass(ABC): +-------------------------------------------------------------------------------- +25 25 | def empty_1(self): # error +26 26 | ... +27 27 | + 28 |+ @abstractmethod +28 29 | def empty_2(self): # error +29 30 | pass +30 31 | + +