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

Respect __str__ definitions from super classes #9338

Merged
merged 1 commit into from
Dec 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/flake8_django/DJ008.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,19 @@ def my_brand_new_property(self):

def my_beautiful_method(self):
return 2


# Subclass with its own __str__
class SubclassTestModel1(TestModel1):
def __str__(self):
return self.new_field


# Subclass with inherited __str__
class SubclassTestModel2(TestModel4):
pass


# Subclass without __str__
class SubclassTestModel3(TestModel1):
pass
4 changes: 2 additions & 2 deletions crates/ruff_linter/src/rules/flake8_django/rules/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ use ruff_python_semantic::{analyze, SemanticModel};

/// Return `true` if a Python class appears to be a Django model, based on its base classes.
pub(super) fn is_model(class_def: &ast::StmtClassDef, semantic: &SemanticModel) -> bool {
analyze::class::any_over_body(class_def, semantic, &|call_path| {
analyze::class::any_call_path(class_def, semantic, &|call_path| {
matches!(call_path.as_slice(), ["django", "db", "models", "Model"])
})
}

/// Return `true` if a Python class appears to be a Django model form, based on its base classes.
pub(super) fn is_model_form(class_def: &ast::StmtClassDef, semantic: &SemanticModel) -> bool {
analyze::class::any_over_body(class_def, semantic, &|call_path| {
analyze::class::any_call_path(class_def, semantic, &|call_path| {
matches!(
call_path.as_slice(),
["django", "forms", "ModelForm"] | ["django", "forms", "models", "ModelForm"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::helpers::is_const_true;
use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::{analyze, SemanticModel};

use crate::checkers::ast::Checker;

Expand Down Expand Up @@ -55,20 +55,24 @@ pub(crate) fn model_without_dunder_str(checker: &mut Checker, class_def: &ast::S
if !is_non_abstract_model(class_def, checker.semantic()) {
return;
}
if has_dunder_method(class_def) {

if has_dunder_method(class_def, checker.semantic()) {
return;
}

checker.diagnostics.push(Diagnostic::new(
DjangoModelWithoutDunderStr,
class_def.identifier(),
));
}

/// Returns `true` if the class has `__str__` method.
fn has_dunder_method(class_def: &ast::StmtClassDef) -> bool {
class_def.body.iter().any(|val| match val {
Stmt::FunctionDef(ast::StmtFunctionDef { name, .. }) => name == "__str__",
_ => false,
fn has_dunder_method(class_def: &ast::StmtClassDef, semantic: &SemanticModel) -> bool {
analyze::class::any_super_class(class_def, semantic, &|class_def| {
class_def.body.iter().any(|val| match val {
Stmt::FunctionDef(ast::StmtFunctionDef { name, .. }) => name == "__str__",
_ => false,
})
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use ruff_python_ast::{self as ast, Arguments, Expr, Stmt};
use ruff_python_ast::{self as ast, Expr, Stmt};

use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::helpers::is_const_true;
use ruff_python_semantic::SemanticModel;
use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
Expand Down Expand Up @@ -48,7 +49,7 @@ impl Violation for DjangoNullableModelStringField {
#[derive_message_formats]
fn message(&self) -> String {
let DjangoNullableModelStringField { field_name } = self;
format!("Avoid using `null=True` on string-based fields such as {field_name}")
format!("Avoid using `null=True` on string-based fields such as `{field_name}`")
}
}

Expand All @@ -58,7 +59,7 @@ pub(crate) fn nullable_model_string_field(checker: &mut Checker, body: &[Stmt])
let Stmt::Assign(ast::StmtAssign { value, .. }) = statement else {
continue;
};
if let Some(field_name) = is_nullable_field(checker, value) {
if let Some(field_name) = is_nullable_field(value, checker.semantic()) {
checker.diagnostics.push(Diagnostic::new(
DjangoNullableModelStringField {
field_name: field_name.to_string(),
Expand All @@ -69,22 +70,12 @@ pub(crate) fn nullable_model_string_field(checker: &mut Checker, body: &[Stmt])
}
}

fn is_nullable_field<'a>(checker: &'a Checker, value: &'a Expr) -> Option<&'a str> {
let Expr::Call(ast::ExprCall {
func,
arguments: Arguments { keywords, .. },
..
}) = value
else {
return None;
};

let Some(valid_field_name) = helpers::get_model_field_name(func, checker.semantic()) else {
return None;
};
fn is_nullable_field<'a>(value: &'a Expr, semantic: &'a SemanticModel) -> Option<&'a str> {
let call = value.as_call_expr()?;

let field_name = helpers::get_model_field_name(&call.func, semantic)?;
if !matches!(
valid_field_name,
field_name,
"CharField" | "TextField" | "SlugField" | "EmailField" | "FilePathField" | "URLField"
) {
return None;
Expand All @@ -93,7 +84,7 @@ fn is_nullable_field<'a>(checker: &'a Checker, value: &'a Expr) -> Option<&'a st
let mut null_key = false;
let mut blank_key = false;
let mut unique_key = false;
for keyword in keywords {
for keyword in &call.arguments.keywords {
let Some(argument) = &keyword.arg else {
continue;
};
Expand All @@ -113,5 +104,6 @@ fn is_nullable_field<'a>(checker: &'a Checker, value: &'a Expr) -> Option<&'a st
if !null_key {
return None;
}
Some(valid_field_name)

Some(field_name)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
source: crates/ruff_linter/src/rules/flake8_django/mod.rs
---
DJ001.py:7:17: DJ001 Avoid using `null=True` on string-based fields such as CharField
DJ001.py:7:17: DJ001 Avoid using `null=True` on string-based fields such as `CharField`
|
6 | class IncorrectModel(models.Model):
7 | charfield = models.CharField(max_length=255, null=True)
Expand All @@ -10,7 +10,7 @@ DJ001.py:7:17: DJ001 Avoid using `null=True` on string-based fields such as Char
9 | slugfield = models.SlugField(max_length=255, null=True)
|

DJ001.py:8:17: DJ001 Avoid using `null=True` on string-based fields such as TextField
DJ001.py:8:17: DJ001 Avoid using `null=True` on string-based fields such as `TextField`
|
6 | class IncorrectModel(models.Model):
7 | charfield = models.CharField(max_length=255, null=True)
Expand All @@ -20,7 +20,7 @@ DJ001.py:8:17: DJ001 Avoid using `null=True` on string-based fields such as Text
10 | emailfield = models.EmailField(max_length=255, null=True)
|

DJ001.py:9:17: DJ001 Avoid using `null=True` on string-based fields such as SlugField
DJ001.py:9:17: DJ001 Avoid using `null=True` on string-based fields such as `SlugField`
|
7 | charfield = models.CharField(max_length=255, null=True)
8 | textfield = models.TextField(max_length=255, null=True)
Expand All @@ -30,7 +30,7 @@ DJ001.py:9:17: DJ001 Avoid using `null=True` on string-based fields such as Slug
11 | filepathfield = models.FilePathField(max_length=255, null=True)
|

DJ001.py:10:18: DJ001 Avoid using `null=True` on string-based fields such as EmailField
DJ001.py:10:18: DJ001 Avoid using `null=True` on string-based fields such as `EmailField`
|
8 | textfield = models.TextField(max_length=255, null=True)
9 | slugfield = models.SlugField(max_length=255, null=True)
Expand All @@ -40,7 +40,7 @@ DJ001.py:10:18: DJ001 Avoid using `null=True` on string-based fields such as Ema
12 | urlfield = models.URLField(max_length=255, null=True)
|

DJ001.py:11:21: DJ001 Avoid using `null=True` on string-based fields such as FilePathField
DJ001.py:11:21: DJ001 Avoid using `null=True` on string-based fields such as `FilePathField`
|
9 | slugfield = models.SlugField(max_length=255, null=True)
10 | emailfield = models.EmailField(max_length=255, null=True)
Expand All @@ -49,15 +49,15 @@ DJ001.py:11:21: DJ001 Avoid using `null=True` on string-based fields such as Fil
12 | urlfield = models.URLField(max_length=255, null=True)
|

DJ001.py:12:16: DJ001 Avoid using `null=True` on string-based fields such as URLField
DJ001.py:12:16: DJ001 Avoid using `null=True` on string-based fields such as `URLField`
|
10 | emailfield = models.EmailField(max_length=255, null=True)
11 | filepathfield = models.FilePathField(max_length=255, null=True)
12 | urlfield = models.URLField(max_length=255, null=True)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DJ001
|

DJ001.py:16:17: DJ001 Avoid using `null=True` on string-based fields such as CharField
DJ001.py:16:17: DJ001 Avoid using `null=True` on string-based fields such as `CharField`
|
15 | class IncorrectModelWithAlias(DjangoModel):
16 | charfield = DjangoModel.CharField(max_length=255, null=True)
Expand All @@ -66,7 +66,7 @@ DJ001.py:16:17: DJ001 Avoid using `null=True` on string-based fields such as Cha
18 | slugfield = models.SlugField(max_length=255, null=True)
|

DJ001.py:17:17: DJ001 Avoid using `null=True` on string-based fields such as CharField
DJ001.py:17:17: DJ001 Avoid using `null=True` on string-based fields such as `CharField`
|
15 | class IncorrectModelWithAlias(DjangoModel):
16 | charfield = DjangoModel.CharField(max_length=255, null=True)
Expand All @@ -76,7 +76,7 @@ DJ001.py:17:17: DJ001 Avoid using `null=True` on string-based fields such as Cha
19 | emailfield = models.EmailField(max_length=255, null=True)
|

DJ001.py:18:17: DJ001 Avoid using `null=True` on string-based fields such as SlugField
DJ001.py:18:17: DJ001 Avoid using `null=True` on string-based fields such as `SlugField`
|
16 | charfield = DjangoModel.CharField(max_length=255, null=True)
17 | textfield = SmthCharField(max_length=255, null=True)
Expand All @@ -86,7 +86,7 @@ DJ001.py:18:17: DJ001 Avoid using `null=True` on string-based fields such as Slu
20 | filepathfield = models.FilePathField(max_length=255, null=True)
|

DJ001.py:19:18: DJ001 Avoid using `null=True` on string-based fields such as EmailField
DJ001.py:19:18: DJ001 Avoid using `null=True` on string-based fields such as `EmailField`
|
17 | textfield = SmthCharField(max_length=255, null=True)
18 | slugfield = models.SlugField(max_length=255, null=True)
Expand All @@ -96,7 +96,7 @@ DJ001.py:19:18: DJ001 Avoid using `null=True` on string-based fields such as Ema
21 | urlfield = models.URLField(max_length=255, null=True)
|

DJ001.py:20:21: DJ001 Avoid using `null=True` on string-based fields such as FilePathField
DJ001.py:20:21: DJ001 Avoid using `null=True` on string-based fields such as `FilePathField`
|
18 | slugfield = models.SlugField(max_length=255, null=True)
19 | emailfield = models.EmailField(max_length=255, null=True)
Expand All @@ -105,15 +105,15 @@ DJ001.py:20:21: DJ001 Avoid using `null=True` on string-based fields such as Fil
21 | urlfield = models.URLField(max_length=255, null=True)
|

DJ001.py:21:16: DJ001 Avoid using `null=True` on string-based fields such as URLField
DJ001.py:21:16: DJ001 Avoid using `null=True` on string-based fields such as `URLField`
|
19 | emailfield = models.EmailField(max_length=255, null=True)
20 | filepathfield = models.FilePathField(max_length=255, null=True)
21 | urlfield = models.URLField(max_length=255, null=True)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DJ001
|

DJ001.py:25:17: DJ001 Avoid using `null=True` on string-based fields such as CharField
DJ001.py:25:17: DJ001 Avoid using `null=True` on string-based fields such as `CharField`
|
24 | class IncorrectModelWithoutSuperclass:
25 | charfield = DjangoModel.CharField(max_length=255, null=True)
Expand All @@ -122,7 +122,7 @@ DJ001.py:25:17: DJ001 Avoid using `null=True` on string-based fields such as Cha
27 | slugfield = models.SlugField(max_length=255, null=True)
|

DJ001.py:26:17: DJ001 Avoid using `null=True` on string-based fields such as CharField
DJ001.py:26:17: DJ001 Avoid using `null=True` on string-based fields such as `CharField`
|
24 | class IncorrectModelWithoutSuperclass:
25 | charfield = DjangoModel.CharField(max_length=255, null=True)
Expand All @@ -132,7 +132,7 @@ DJ001.py:26:17: DJ001 Avoid using `null=True` on string-based fields such as Cha
28 | emailfield = models.EmailField(max_length=255, null=True)
|

DJ001.py:27:17: DJ001 Avoid using `null=True` on string-based fields such as SlugField
DJ001.py:27:17: DJ001 Avoid using `null=True` on string-based fields such as `SlugField`
|
25 | charfield = DjangoModel.CharField(max_length=255, null=True)
26 | textfield = SmthCharField(max_length=255, null=True)
Expand All @@ -142,7 +142,7 @@ DJ001.py:27:17: DJ001 Avoid using `null=True` on string-based fields such as Slu
29 | filepathfield = models.FilePathField(max_length=255, null=True)
|

DJ001.py:28:18: DJ001 Avoid using `null=True` on string-based fields such as EmailField
DJ001.py:28:18: DJ001 Avoid using `null=True` on string-based fields such as `EmailField`
|
26 | textfield = SmthCharField(max_length=255, null=True)
27 | slugfield = models.SlugField(max_length=255, null=True)
Expand All @@ -152,7 +152,7 @@ DJ001.py:28:18: DJ001 Avoid using `null=True` on string-based fields such as Ema
30 | urlfield = models.URLField(max_length=255, null=True)
|

DJ001.py:29:21: DJ001 Avoid using `null=True` on string-based fields such as FilePathField
DJ001.py:29:21: DJ001 Avoid using `null=True` on string-based fields such as `FilePathField`
|
27 | slugfield = models.SlugField(max_length=255, null=True)
28 | emailfield = models.EmailField(max_length=255, null=True)
Expand All @@ -161,7 +161,7 @@ DJ001.py:29:21: DJ001 Avoid using `null=True` on string-based fields such as Fil
30 | urlfield = models.URLField(max_length=255, null=True)
|

DJ001.py:30:16: DJ001 Avoid using `null=True` on string-based fields such as URLField
DJ001.py:30:16: DJ001 Avoid using `null=True` on string-based fields such as `URLField`
|
28 | emailfield = models.EmailField(max_length=255, null=True)
29 | filepathfield = models.FilePathField(max_length=255, null=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,12 @@ DJ008.py:36:7: DJ008 Model does not define `__str__` method
37 | new_field = models.CharField(max_length=10)
|

DJ008.py:182:7: DJ008 Model does not define `__str__` method
|
181 | # Subclass without __str__
182 | class SubclassTestModel3(TestModel1):
| ^^^^^^^^^^^^^^^^^^ DJ008
183 | pass
|


Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ fn runtime_required_base_class(
base_classes: &[String],
semantic: &SemanticModel,
) -> bool {
analyze::class::any_over_body(class_def, semantic, &|call_path| {
analyze::class::any_call_path(class_def, semantic, &|call_path| {
base_classes
.iter()
.any(|base_class| from_qualified_name(base_class) == call_path)
Expand Down
2 changes: 1 addition & 1 deletion crates/ruff_linter/src/rules/ruff/rules/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub(super) fn has_default_copy_semantics(
class_def: &ast::StmtClassDef,
semantic: &SemanticModel,
) -> bool {
analyze::class::any_over_body(class_def, semantic, &|call_path| {
analyze::class::any_call_path(class_def, semantic, &|call_path| {
matches!(
call_path.as_slice(),
["pydantic", "BaseModel" | "BaseSettings"]
Expand Down
46 changes: 44 additions & 2 deletions crates/ruff_python_semantic/src/analyze/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use ruff_python_ast::helpers::map_subscript;

use crate::{BindingId, SemanticModel};

/// Return `true` if any base class of a class definition matches a predicate.
pub fn any_over_body(
/// Return `true` if any base class matches a [`CallPath`] predicate.
pub fn any_call_path(
class_def: &ast::StmtClassDef,
semantic: &SemanticModel,
func: &dyn Fn(CallPath) -> bool,
Expand Down Expand Up @@ -55,3 +55,45 @@ pub fn any_over_body(

inner(class_def, semantic, func, &mut FxHashSet::default())
}

/// Return `true` if any base class matches an [`ast::StmtClassDef`] predicate.
pub fn any_super_class(
class_def: &ast::StmtClassDef,
semantic: &SemanticModel,
func: &dyn Fn(&ast::StmtClassDef) -> bool,
) -> bool {
fn inner(
class_def: &ast::StmtClassDef,
semantic: &SemanticModel,
func: &dyn Fn(&ast::StmtClassDef) -> bool,
seen: &mut FxHashSet<BindingId>,
) -> bool {
// If the function itself matches the pattern, then this does too.
if func(class_def) {
return true;
}

// Otherwise, check every base class.
class_def.bases().iter().any(|expr| {
// If the base class extends a class that matches the pattern, then this does too.
if let Some(id) = semantic.lookup_attribute(map_subscript(expr)) {
if seen.insert(id) {
let binding = semantic.binding(id);
if let Some(base_class) = binding
.kind
.as_class_definition()
.map(|id| &semantic.scopes[*id])
.and_then(|scope| scope.kind.as_class())
{
if inner(base_class, semantic, func, seen) {
return true;
}
}
}
}
false
})
}

inner(class_def, semantic, func, &mut FxHashSet::default())
}