Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement B903 and B907 flake8-bugbear rules #13600

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
26 changes: 26 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class Point:
def __init__(self, x, y):
self.x = x
self.y = y


class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height


class Circle:
def __init__(self, radius):
self.radius = radius


class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height


class Square:
def __init__(self, side):
self.side = side
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
foo = "bar"
f"'{foo}'"

baz = "qux"
f"'{baz}'"

quux = "corge"
f"'{quux}'"
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept),
(Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict),
(Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation),
(Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses),
(Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes),

// flake8-blind-except
(Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept),
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ mod tests {
#[test_case(Rule::ReturnInGenerator, Path::new("B901.py"))]
#[test_case(Rule::LoopIteratorMutation, Path::new("B909.py"))]
#[test_case(Rule::MutableContextvarDefault, Path::new("B039.py"))]
#[test_case(Rule::UseDataclassesForDataClasses, Path::new("B903.py"))]
#[test_case(Rule::FStringSingleQuotes, Path::new("B907.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
let diagnostics = test_path(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
use crate::registry::Rule;

/// ## What it does
/// Checks for f-strings that contain single quotes and suggests replacing them
/// with `!r` conversion.
///
/// ## Why is this bad?
/// Using `!r` conversion in f-strings is both easier to read and will escape
/// quotes inside the string if they appear.
///
/// ## Example
/// ```python
/// f"'{foo}'"
/// ```
///
/// Use instead:
/// ```python
/// f"{foo!r}"
/// ```
///
/// ## References
/// - [Python documentation: Formatted string literals](https://docs.python.org/3/reference/lexical_analysis.html#f-strings)
#[violation]
pub struct FStringSingleQuotes;

impl Violation for FStringSingleQuotes {
#[derive_message_formats]
fn message(&self) -> String {
format!("Consider replacing f\"'{{foo}}'\" with f\"{{foo!r}}\" which is both easier to read and will escape quotes inside foo if that would appear")
}
}

/// B907
pub(crate) fn f_string_single_quotes(checker: &mut Checker, expr: &Expr) {
if let Expr::FString(ast::ExprFString { values, .. }) = expr {
for value in values {
if let Expr::FormattedValue(ast::ExprFormattedValue { value, .. }) = value {
if let Expr::Constant(ast::ExprConstant {
value: ast::Constant::Str(s),
..
}) = value.as_ref()
{
if s.contains('\'') {
checker.diagnostics.push(Diagnostic::new(
FStringSingleQuotes,
expr.range(),
));
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{self as ast, Arguments, Expr, Stmt};
use ruff_python_semantic::SemanticModel;
use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
use crate::registry::Rule;

/// ## What it does
/// Checks for data classes that only set attributes in an `__init__` method.
///
/// ## Why is this bad?
/// Data classes that only set attributes in an `__init__` method can be
/// replaced with `dataclasses` for better readability and maintainability.
///
/// ## Example
/// ```python
/// class Point:
/// def __init__(self, x, y):
/// self.x = x
/// self.y = y
/// ```
///
/// Use instead:
/// ```python
/// from dataclasses import dataclass
///
/// @dataclass
/// class Point:
/// x: int
/// y: int
/// ```
///
/// ## References
/// - [Python documentation: `dataclasses`](https://docs.python.org/3/library/dataclasses.html)
#[violation]
pub struct UseDataclassesForDataClasses;

impl Violation for UseDataclassesForDataClasses {
#[derive_message_formats]
fn message(&self) -> String {
format!("Use `dataclasses` for data classes that only set attributes in an `__init__` method")
}
}

/// B903
pub(crate) fn use_dataclasses_for_data_classes(checker: &mut Checker, stmt: &Stmt) {
let Stmt::ClassDef(ast::StmtClassDef { body, .. }) = stmt else {
return;
};

for stmt in body {
let Stmt::FunctionDef(ast::StmtFunctionDef {
name,
parameters,
body,
..
}) = stmt
else {
continue;
};

if name.id != "__init__" {
continue;
}

let mut has_only_attribute_assignments = true;
for stmt in body {
if let Stmt::Assign(ast::StmtAssign { targets, .. }) = stmt {
if targets.len() != 1 {
has_only_attribute_assignments = false;
break;
}

let Expr::Attribute(ast::ExprAttribute { value, .. }) = &targets[0] else {
has_only_attribute_assignments = false;
break;
};

if !matches!(value.as_ref(), Expr::Name(_)) {
has_only_attribute_assignments = false;
break;
}
} else {
has_only_attribute_assignments = false;
break;
}
}

if has_only_attribute_assignments {
checker.diagnostics.push(Diagnostic::new(
UseDataclassesForDataClasses,
stmt.range(),
));
}
}
}
138 changes: 138 additions & 0 deletions crates/ruff_linter/src/rules/flake8_bugbear/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use std::path::Path;

use anyhow::Result;
use test_case::test_case;

use crate::assert_messages;
use crate::registry::Rule;

use crate::settings::LinterSettings;
use crate::test::test_path;

#[test_case(Rule::AbstractBaseClassWithoutAbstractMethod, Path::new("B024.py"))]
#[test_case(Rule::AssertFalse, Path::new("B011.py"))]
#[test_case(Rule::AssertRaisesException, Path::new("B017.py"))]
#[test_case(Rule::AssignmentToOsEnviron, Path::new("B003.py"))]
#[test_case(Rule::CachedInstanceMethod, Path::new("B019.py"))]
#[test_case(Rule::DuplicateHandlerException, Path::new("B014.py"))]
#[test_case(Rule::DuplicateTryBlockException, Path::new("B025.py"))]
#[test_case(Rule::DuplicateValue, Path::new("B033.py"))]
#[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.py"))]
#[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.pyi"))]
#[test_case(Rule::ExceptWithEmptyTuple, Path::new("B029.py"))]
#[test_case(Rule::ExceptWithNonExceptionClasses, Path::new("B030.py"))]
#[test_case(Rule::FStringDocstring, Path::new("B021.py"))]
#[test_case(Rule::FunctionCallInDefaultArgument, Path::new("B006_B008.py"))]
#[test_case(Rule::FunctionUsesLoopVariable, Path::new("B023.py"))]
#[test_case(Rule::GetAttrWithConstant, Path::new("B009_B010.py"))]
#[test_case(Rule::JumpStatementInFinally, Path::new("B012.py"))]
#[test_case(Rule::LoopVariableOverridesIterator, Path::new("B020.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_1.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_2.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_3.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_4.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_5.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_6.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_7.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_8.py"))]
#[test_case(Rule::MutableArgumentDefault, Path::new("B006_B008.py"))]
#[test_case(Rule::NoExplicitStacklevel, Path::new("B028.py"))]
#[test_case(Rule::RaiseLiteral, Path::new("B016.py"))]
#[test_case(Rule::RaiseWithoutFromInsideExcept, Path::new("B904.py"))]
#[test_case(Rule::ReSubPositionalArgs, Path::new("B034.py"))]
#[test_case(Rule::RedundantTupleInExceptionHandler, Path::new("B013.py"))]
#[test_case(Rule::ReuseOfGroupbyGenerator, Path::new("B031.py"))]
#[test_case(Rule::SetAttrWithConstant, Path::new("B009_B010.py"))]
#[test_case(Rule::StarArgUnpackingAfterKeywordArg, Path::new("B026.py"))]
#[test_case(Rule::StaticKeyDictComprehension, Path::new("B035.py"))]
#[test_case(Rule::StripWithMultiCharacters, Path::new("B005.py"))]
#[test_case(Rule::UnaryPrefixIncrementDecrement, Path::new("B002.py"))]
#[test_case(Rule::UnintentionalTypeAnnotation, Path::new("B032.py"))]
#[test_case(Rule::UnreliableCallableCheck, Path::new("B004.py"))]
#[test_case(Rule::UnusedLoopControlVariable, Path::new("B007.py"))]
#[test_case(Rule::UselessComparison, Path::new("B015.ipynb"))]
#[test_case(Rule::UselessComparison, Path::new("B015.py"))]
#[test_case(Rule::UselessContextlibSuppress, Path::new("B022.py"))]
#[test_case(Rule::UselessExpression, Path::new("B018.ipynb"))]
#[test_case(Rule::UselessExpression, Path::new("B018.py"))]
#[test_case(Rule::ReturnInGenerator, Path::new("B901.py"))]
#[test_case(Rule::LoopIteratorMutation, Path::new("B909.py"))]
#[test_case(Rule::MutableContextvarDefault, Path::new("B039.py"))]
#[test_case(Rule::UseDataclassesForDataClasses, Path::new("B903.py"))]
#[test_case(Rule::FStringSingleQuotes, Path::new("B907.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
let diagnostics = test_path(
Path::new("flake8_bugbear").join(path).as_path(),
&LinterSettings::for_rule(rule_code),
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test]
fn zip_without_explicit_strict() -> Result<()> {
let snapshot = "B905.py";
let diagnostics = test_path(
Path::new("flake8_bugbear").join(snapshot).as_path(),
&LinterSettings::for_rule(Rule::ZipWithoutExplicitStrict),
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test]
fn extend_immutable_calls_arg_annotation() -> Result<()> {
let snapshot = "extend_immutable_calls_arg_annotation".to_string();
let diagnostics = test_path(
Path::new("flake8_bugbear/B006_extended.py"),
&LinterSettings {
flake8_bugbear: super::settings::Settings {
extend_immutable_calls: vec![
"custom.ImmutableTypeA".to_string(),
"custom.ImmutableTypeB".to_string(),
],
},
..LinterSettings::for_rule(Rule::MutableArgumentDefault)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test]
fn extend_immutable_calls_arg_default() -> Result<()> {
let snapshot = "extend_immutable_calls_arg_default".to_string();
let diagnostics = test_path(
Path::new("flake8_bugbear/B008_extended.py"),
&LinterSettings {
flake8_bugbear: super::settings::Settings {
extend_immutable_calls: vec![
"fastapi.Depends".to_string(),
"fastapi.Query".to_string(),
"custom.ImmutableTypeA".to_string(),
"B008_extended.Class".to_string(),
],
},
..LinterSettings::for_rule(Rule::FunctionCallInDefaultArgument)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test]
fn extend_mutable_contextvar_default() -> Result<()> {
let snapshot = "extend_mutable_contextvar_default".to_string();
let diagnostics = test_path(
Path::new("flake8_bugbear/B039_extended.py"),
&LinterSettings {
flake8_bugbear: super::settings::Settings {
extend_immutable_calls: vec!["fastapi.Query".to_string()],
},
..LinterSettings::for_rule(Rule::MutableContextvarDefault)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}
5 changes: 5 additions & 0 deletions crates/ruff_linter/src/rules/ruff/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub(crate) use collection_literal_concatenation::*;
pub(crate) use decimal_from_float_literal::*;
pub(crate) use default_factory_kwarg::*;
pub(crate) use explicit_f_string_type_conversion::*;
pub(crate) use f_string_single_quotes::*;
pub(crate) use function_call_in_dataclass_default::*;
pub(crate) use implicit_optional::*;
pub(crate) use incorrectly_parenthesized_tuple_in_subscript::*;
Expand All @@ -29,9 +30,11 @@ pub(crate) use unnecessary_iterable_allocation_for_first_element::*;
pub(crate) use unnecessary_key_check::*;
pub(crate) use unused_async::*;
pub(crate) use unused_noqa::*;
pub(crate) use use_dataclasses_for_data_classes::*;
pub(crate) use useless_if_else::*;
pub(crate) use zip_instead_of_pairwise::*;


mod ambiguous_unicode_character;
mod assert_with_print_message;
mod assignment_in_assert;
Expand All @@ -41,6 +44,7 @@ mod confusables;
mod decimal_from_float_literal;
mod default_factory_kwarg;
mod explicit_f_string_type_conversion;
mod f_string_single_quotes;
mod function_call_in_dataclass_default;
mod helpers;
mod implicit_optional;
Expand All @@ -67,6 +71,7 @@ mod unnecessary_iterable_allocation_for_first_element;
mod unnecessary_key_check;
mod unused_async;
mod unused_noqa;
mod use_dataclasses_for_data_classes;
mod useless_if_else;
mod zip_instead_of_pairwise;

Expand Down
Loading