Respect __str__ definitions from super classes (#9338)

Closes https://github.com/astral-sh/ruff/issues/9242.
This commit is contained in:
Charlie Marsh 2023-12-31 18:25:08 -04:00 committed by GitHub
parent cea2ec8dd0
commit 1f9353fed3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 111 additions and 49 deletions

View file

@ -165,3 +165,19 @@ class AbstractTestModel5(models.Model):
def my_beautiful_method(self): def my_beautiful_method(self):
return 2 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

View file

@ -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. /// 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 { 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"]) 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. /// 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 { 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!( matches!(
call_path.as_slice(), call_path.as_slice(),
["django", "forms", "ModelForm"] | ["django", "forms", "models", "ModelForm"] ["django", "forms", "ModelForm"] | ["django", "forms", "models", "ModelForm"]

View file

@ -3,7 +3,7 @@ use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::helpers::is_const_true; use ruff_python_ast::helpers::is_const_true;
use ruff_python_ast::identifier::Identifier; use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::{self as ast, Expr, Stmt}; 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; use crate::checkers::ast::Checker;
@ -55,9 +55,11 @@ pub(crate) fn model_without_dunder_str(checker: &mut Checker, class_def: &ast::S
if !is_non_abstract_model(class_def, checker.semantic()) { if !is_non_abstract_model(class_def, checker.semantic()) {
return; return;
} }
if has_dunder_method(class_def) {
if has_dunder_method(class_def, checker.semantic()) {
return; return;
} }
checker.diagnostics.push(Diagnostic::new( checker.diagnostics.push(Diagnostic::new(
DjangoModelWithoutDunderStr, DjangoModelWithoutDunderStr,
class_def.identifier(), class_def.identifier(),
@ -65,10 +67,12 @@ pub(crate) fn model_without_dunder_str(checker: &mut Checker, class_def: &ast::S
} }
/// Returns `true` if the class has `__str__` method. /// Returns `true` if the class has `__str__` method.
fn has_dunder_method(class_def: &ast::StmtClassDef) -> bool { fn has_dunder_method(class_def: &ast::StmtClassDef, semantic: &SemanticModel) -> bool {
class_def.body.iter().any(|val| match val { analyze::class::any_super_class(class_def, semantic, &|class_def| {
Stmt::FunctionDef(ast::StmtFunctionDef { name, .. }) => name == "__str__", class_def.body.iter().any(|val| match val {
_ => false, Stmt::FunctionDef(ast::StmtFunctionDef { name, .. }) => name == "__str__",
_ => false,
})
}) })
} }

View file

@ -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_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::helpers::is_const_true; use ruff_python_ast::helpers::is_const_true;
use ruff_python_semantic::SemanticModel;
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
use crate::checkers::ast::Checker; use crate::checkers::ast::Checker;
@ -48,7 +49,7 @@ impl Violation for DjangoNullableModelStringField {
#[derive_message_formats] #[derive_message_formats]
fn message(&self) -> String { fn message(&self) -> String {
let DjangoNullableModelStringField { field_name } = self; 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}`")
} }
} }
@ -58,7 +59,7 @@ pub(crate) fn nullable_model_string_field(checker: &mut Checker, body: &[Stmt])
let Stmt::Assign(ast::StmtAssign { value, .. }) = statement else { let Stmt::Assign(ast::StmtAssign { value, .. }) = statement else {
continue; 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( checker.diagnostics.push(Diagnostic::new(
DjangoNullableModelStringField { DjangoNullableModelStringField {
field_name: field_name.to_string(), field_name: field_name.to_string(),
@ -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> { fn is_nullable_field<'a>(value: &'a Expr, semantic: &'a SemanticModel) -> Option<&'a str> {
let Expr::Call(ast::ExprCall { let call = value.as_call_expr()?;
func,
arguments: Arguments { keywords, .. },
..
}) = value
else {
return None;
};
let Some(valid_field_name) = helpers::get_model_field_name(func, checker.semantic()) else {
return None;
};
let field_name = helpers::get_model_field_name(&call.func, semantic)?;
if !matches!( if !matches!(
valid_field_name, field_name,
"CharField" | "TextField" | "SlugField" | "EmailField" | "FilePathField" | "URLField" "CharField" | "TextField" | "SlugField" | "EmailField" | "FilePathField" | "URLField"
) { ) {
return None; return None;
@ -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 null_key = false;
let mut blank_key = false; let mut blank_key = false;
let mut unique_key = false; let mut unique_key = false;
for keyword in keywords { for keyword in &call.arguments.keywords {
let Some(argument) = &keyword.arg else { let Some(argument) = &keyword.arg else {
continue; continue;
}; };
@ -113,5 +104,6 @@ fn is_nullable_field<'a>(checker: &'a Checker, value: &'a Expr) -> Option<&'a st
if !null_key { if !null_key {
return None; return None;
} }
Some(valid_field_name)
Some(field_name)
} }

View file

@ -1,7 +1,7 @@
--- ---
source: crates/ruff_linter/src/rules/flake8_django/mod.rs 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): 6 | class IncorrectModel(models.Model):
7 | charfield = models.CharField(max_length=255, null=True) 7 | charfield = models.CharField(max_length=255, null=True)
@ -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) 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): 6 | class IncorrectModel(models.Model):
7 | charfield = models.CharField(max_length=255, null=True) 7 | charfield = models.CharField(max_length=255, null=True)
@ -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) 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) 7 | charfield = models.CharField(max_length=255, null=True)
8 | textfield = models.TextField(max_length=255, null=True) 8 | textfield = models.TextField(max_length=255, null=True)
@ -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) 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) 8 | textfield = models.TextField(max_length=255, null=True)
9 | slugfield = models.SlugField(max_length=255, null=True) 9 | slugfield = models.SlugField(max_length=255, null=True)
@ -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) 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) 9 | slugfield = models.SlugField(max_length=255, null=True)
10 | emailfield = models.EmailField(max_length=255, null=True) 10 | emailfield = models.EmailField(max_length=255, null=True)
@ -49,7 +49,7 @@ 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) 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) 10 | emailfield = models.EmailField(max_length=255, null=True)
11 | filepathfield = models.FilePathField(max_length=255, null=True) 11 | filepathfield = models.FilePathField(max_length=255, null=True)
@ -57,7 +57,7 @@ DJ001.py:12:16: DJ001 Avoid using `null=True` on string-based fields such as URL
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DJ001 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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): 15 | class IncorrectModelWithAlias(DjangoModel):
16 | charfield = DjangoModel.CharField(max_length=255, null=True) 16 | charfield = DjangoModel.CharField(max_length=255, null=True)
@ -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) 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): 15 | class IncorrectModelWithAlias(DjangoModel):
16 | charfield = DjangoModel.CharField(max_length=255, null=True) 16 | charfield = DjangoModel.CharField(max_length=255, null=True)
@ -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) 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) 16 | charfield = DjangoModel.CharField(max_length=255, null=True)
17 | textfield = SmthCharField(max_length=255, null=True) 17 | textfield = SmthCharField(max_length=255, null=True)
@ -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) 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) 17 | textfield = SmthCharField(max_length=255, null=True)
18 | slugfield = models.SlugField(max_length=255, null=True) 18 | slugfield = models.SlugField(max_length=255, null=True)
@ -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) 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) 18 | slugfield = models.SlugField(max_length=255, null=True)
19 | emailfield = models.EmailField(max_length=255, null=True) 19 | emailfield = models.EmailField(max_length=255, null=True)
@ -105,7 +105,7 @@ 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) 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) 19 | emailfield = models.EmailField(max_length=255, null=True)
20 | filepathfield = models.FilePathField(max_length=255, null=True) 20 | filepathfield = models.FilePathField(max_length=255, null=True)
@ -113,7 +113,7 @@ DJ001.py:21:16: DJ001 Avoid using `null=True` on string-based fields such as URL
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DJ001 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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: 24 | class IncorrectModelWithoutSuperclass:
25 | charfield = DjangoModel.CharField(max_length=255, null=True) 25 | charfield = DjangoModel.CharField(max_length=255, null=True)
@ -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) 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: 24 | class IncorrectModelWithoutSuperclass:
25 | charfield = DjangoModel.CharField(max_length=255, null=True) 25 | charfield = DjangoModel.CharField(max_length=255, null=True)
@ -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) 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) 25 | charfield = DjangoModel.CharField(max_length=255, null=True)
26 | textfield = SmthCharField(max_length=255, null=True) 26 | textfield = SmthCharField(max_length=255, null=True)
@ -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) 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) 26 | textfield = SmthCharField(max_length=255, null=True)
27 | slugfield = models.SlugField(max_length=255, null=True) 27 | slugfield = models.SlugField(max_length=255, null=True)
@ -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) 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) 27 | slugfield = models.SlugField(max_length=255, null=True)
28 | emailfield = models.EmailField(max_length=255, null=True) 28 | emailfield = models.EmailField(max_length=255, null=True)
@ -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) 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) 28 | emailfield = models.EmailField(max_length=255, null=True)
29 | filepathfield = models.FilePathField(max_length=255, null=True) 29 | filepathfield = models.FilePathField(max_length=255, null=True)

View file

@ -23,4 +23,12 @@ DJ008.py:36:7: DJ008 Model does not define `__str__` method
37 | new_field = models.CharField(max_length=10) 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
|

View file

@ -77,7 +77,7 @@ fn runtime_required_base_class(
base_classes: &[String], base_classes: &[String],
semantic: &SemanticModel, semantic: &SemanticModel,
) -> bool { ) -> bool {
analyze::class::any_over_body(class_def, semantic, &|call_path| { analyze::class::any_call_path(class_def, semantic, &|call_path| {
base_classes base_classes
.iter() .iter()
.any(|base_class| from_qualified_name(base_class) == call_path) .any(|base_class| from_qualified_name(base_class) == call_path)

View file

@ -56,7 +56,7 @@ pub(super) fn has_default_copy_semantics(
class_def: &ast::StmtClassDef, class_def: &ast::StmtClassDef,
semantic: &SemanticModel, semantic: &SemanticModel,
) -> bool { ) -> bool {
analyze::class::any_over_body(class_def, semantic, &|call_path| { analyze::class::any_call_path(class_def, semantic, &|call_path| {
matches!( matches!(
call_path.as_slice(), call_path.as_slice(),
["pydantic", "BaseModel" | "BaseSettings"] ["pydantic", "BaseModel" | "BaseSettings"]

View file

@ -6,8 +6,8 @@ use ruff_python_ast::helpers::map_subscript;
use crate::{BindingId, SemanticModel}; use crate::{BindingId, SemanticModel};
/// Return `true` if any base class of a class definition matches a predicate. /// Return `true` if any base class matches a [`CallPath`] predicate.
pub fn any_over_body( pub fn any_call_path(
class_def: &ast::StmtClassDef, class_def: &ast::StmtClassDef,
semantic: &SemanticModel, semantic: &SemanticModel,
func: &dyn Fn(CallPath) -> bool, func: &dyn Fn(CallPath) -> bool,
@ -55,3 +55,45 @@ pub fn any_over_body(
inner(class_def, semantic, func, &mut FxHashSet::default()) 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())
}