Add rule to detect unnecessary class properties (#21535)

Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
Co-authored-by: Amethyst Reese <amethyst@n7.gg>
Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Shahar Naveh 2025-11-26 10:31:22 +02:00 committed by GitHub
parent 5364256190
commit 33713a7e2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 246 additions and 22 deletions

View File

@ -0,0 +1,70 @@
import abc
import typing
class User: # Test normal class properties
@property
def name(self): # ERROR: No return
f"{self.first_name} {self.last_name}"
@property
def age(self): # OK: Returning something
return 100
def method(self): # OK: Not a property
x = 1
@property
def nested(self): # ERROR: Property itself doesn't return
def inner():
return 0
@property
def stub(self): ... # OK: A stub; doesn't return anything
class UserMeta(metaclass=abc.ABCMeta): # Test properies inside of an ABC class
@property
@abc.abstractmethod
def abstr_prop1(self): ... # OK: Abstract methods doesn't need to return anything
@property
@abc.abstractmethod
def abstr_prop2(self): # OK: Abstract methods doesn't need to return anything
"""
A cool docstring
"""
@property
def prop1(self): # OK: Returning a value
return 1
@property
def prop2(self): # ERROR: Not returning something (even when we are inside an ABC)
50
def method(self): # OK: Not a property
x = 1
def func(): # OK: Not a property
x = 1
class Proto(typing.Protocol): # Tests for a Protocol class
@property
def prop1(self) -> int: ... # OK: A stub property
class File: # Extra tests for things like yield/yield from/raise
@property
def stream1(self): # OK: Yields something
yield
@property
def stream2(self): # OK: Yields from something
yield from self.stream1
@property
def children(self): # OK: Raises
raise ValueError("File does not have children")

View File

@ -347,6 +347,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if checker.is_rule_enabled(Rule::InvalidArgumentName) { if checker.is_rule_enabled(Rule::InvalidArgumentName) {
pep8_naming::rules::invalid_argument_name_function(checker, function_def); pep8_naming::rules::invalid_argument_name_function(checker, function_def);
} }
if checker.is_rule_enabled(Rule::PropertyWithoutReturn) {
ruff::rules::property_without_return(checker, function_def);
}
} }
Stmt::Return(_) => { Stmt::Return(_) => {
if checker.is_rule_enabled(Rule::ReturnInInit) { if checker.is_rule_enabled(Rule::ReturnInInit) {

View File

@ -1058,6 +1058,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "063") => rules::ruff::rules::AccessAnnotationsFromClassDict, (Ruff, "063") => rules::ruff::rules::AccessAnnotationsFromClassDict,
(Ruff, "064") => rules::ruff::rules::NonOctalPermissions, (Ruff, "064") => rules::ruff::rules::NonOctalPermissions,
(Ruff, "065") => rules::ruff::rules::LoggingEagerConversion, (Ruff, "065") => rules::ruff::rules::LoggingEagerConversion,
(Ruff, "066") => rules::ruff::rules::PropertyWithoutReturn,
(Ruff, "100") => rules::ruff::rules::UnusedNOQA, (Ruff, "100") => rules::ruff::rules::UnusedNOQA,
(Ruff, "101") => rules::ruff::rules::RedirectedNOQA, (Ruff, "101") => rules::ruff::rules::RedirectedNOQA,

View File

@ -1,8 +1,6 @@
use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::helpers::map_subscript;
use ruff_python_ast::whitespace::trailing_comment_start_offset; use ruff_python_ast::whitespace::trailing_comment_start_offset;
use ruff_python_ast::{Expr, ExprStringLiteral, Stmt, StmtExpr}; use ruff_python_ast::{Expr, ExprStringLiteral, Stmt, StmtExpr};
use ruff_python_semantic::{ScopeKind, SemanticModel};
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
use crate::checkers::ast::Checker; use crate::checkers::ast::Checker;
@ -101,7 +99,7 @@ pub(crate) fn unnecessary_placeholder(checker: &Checker, body: &[Stmt]) {
// Ellipses are significant in protocol methods and abstract methods. // Ellipses are significant in protocol methods and abstract methods.
// Specifically, Pyright uses the presence of an ellipsis to indicate that // Specifically, Pyright uses the presence of an ellipsis to indicate that
// a method is a stub, rather than a default implementation. // a method is a stub, rather than a default implementation.
if in_protocol_or_abstract_method(checker.semantic()) { if checker.semantic().in_protocol_or_abstract_method() {
return; return;
} }
Placeholder::Ellipsis Placeholder::Ellipsis
@ -163,21 +161,3 @@ impl std::fmt::Display for Placeholder {
} }
} }
} }
/// Return `true` if the [`SemanticModel`] is in a `typing.Protocol` subclass or an abstract
/// method.
fn in_protocol_or_abstract_method(semantic: &SemanticModel) -> bool {
semantic.current_scopes().any(|scope| match scope.kind {
ScopeKind::Class(class_def) => class_def
.bases()
.iter()
.any(|base| semantic.match_typing_expr(map_subscript(base), "Protocol")),
ScopeKind::Function(function_def) => {
ruff_python_semantic::analyze::visibility::is_abstract(
&function_def.decorator_list,
semantic,
)
}
_ => false,
})
}

View File

@ -115,6 +115,7 @@ mod tests {
#[test_case(Rule::NonOctalPermissions, Path::new("RUF064.py"))] #[test_case(Rule::NonOctalPermissions, Path::new("RUF064.py"))]
#[test_case(Rule::LoggingEagerConversion, Path::new("RUF065_0.py"))] #[test_case(Rule::LoggingEagerConversion, Path::new("RUF065_0.py"))]
#[test_case(Rule::LoggingEagerConversion, Path::new("RUF065_1.py"))] #[test_case(Rule::LoggingEagerConversion, Path::new("RUF065_1.py"))]
#[test_case(Rule::PropertyWithoutReturn, Path::new("RUF066.py"))]
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_0.py"))] #[test_case(Rule::RedirectedNOQA, Path::new("RUF101_0.py"))]
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_1.py"))] #[test_case(Rule::RedirectedNOQA, Path::new("RUF101_1.py"))]
#[test_case(Rule::InvalidRuleCode, Path::new("RUF102.py"))] #[test_case(Rule::InvalidRuleCode, Path::new("RUF102.py"))]

View File

@ -35,6 +35,7 @@ pub(crate) use non_octal_permissions::*;
pub(crate) use none_not_at_end_of_union::*; pub(crate) use none_not_at_end_of_union::*;
pub(crate) use parenthesize_chained_operators::*; pub(crate) use parenthesize_chained_operators::*;
pub(crate) use post_init_default::*; pub(crate) use post_init_default::*;
pub(crate) use property_without_return::*;
pub(crate) use pytest_raises_ambiguous_pattern::*; pub(crate) use pytest_raises_ambiguous_pattern::*;
pub(crate) use quadratic_list_summation::*; pub(crate) use quadratic_list_summation::*;
pub(crate) use redirected_noqa::*; pub(crate) use redirected_noqa::*;
@ -99,6 +100,7 @@ mod non_octal_permissions;
mod none_not_at_end_of_union; mod none_not_at_end_of_union;
mod parenthesize_chained_operators; mod parenthesize_chained_operators;
mod post_init_default; mod post_init_default;
mod property_without_return;
mod pytest_raises_ambiguous_pattern; mod pytest_raises_ambiguous_pattern;
mod quadratic_list_summation; mod quadratic_list_summation;
mod redirected_noqa; mod redirected_noqa;

View File

@ -0,0 +1,119 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
use ruff_python_ast::{Expr, Stmt, StmtFunctionDef};
use ruff_python_semantic::analyze::{function_type, visibility};
use crate::checkers::ast::Checker;
use crate::{FixAvailability, Violation};
/// ## What it does
/// Detects class `@property` methods that does not have a `return` statement.
///
/// ## Why is this bad?
/// Property methods are expected to return a computed value, a missing return in a property usually indicates an implementation mistake.
///
/// ## Example
/// ```python
/// class User:
/// @property
/// def full_name(self):
/// f"{self.first_name} {self.last_name}"
/// ```
///
/// Use instead:
/// ```python
/// class User:
/// @property
/// def full_name(self):
/// return f"{self.first_name} {self.last_name}"
/// ```
///
/// ## References
/// - [Python documentation: The property class](https://docs.python.org/3/library/functions.html#property)
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.7")]
pub(crate) struct PropertyWithoutReturn {
name: String,
}
impl Violation for PropertyWithoutReturn {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
#[derive_message_formats]
fn message(&self) -> String {
let Self { name } = self;
format!("`{name}` is a property without a `return` statement")
}
}
/// RUF066
pub(crate) fn property_without_return(checker: &Checker, function_def: &StmtFunctionDef) {
let semantic = checker.semantic();
if checker.source_type.is_stub() || semantic.in_protocol_or_abstract_method() {
return;
}
let StmtFunctionDef {
decorator_list,
body,
name,
..
} = function_def;
if !visibility::is_property(decorator_list, [], semantic)
|| visibility::is_overload(decorator_list, semantic)
|| function_type::is_stub(function_def, semantic)
{
return;
}
let mut visitor = PropertyVisitor::default();
visitor.visit_body(body);
if visitor.found {
return;
}
checker.report_diagnostic(
PropertyWithoutReturn {
name: name.to_string(),
},
function_def.identifier(),
);
}
#[derive(Default)]
struct PropertyVisitor {
found: bool,
}
// NOTE: We are actually searching for the presence of
// `yield`/`yield from`/`raise`/`return` statement/expression,
// as having one of those indicates that there's likely no implementation mistake
impl Visitor<'_> for PropertyVisitor {
fn visit_expr(&mut self, expr: &Expr) {
if self.found {
return;
}
match expr {
Expr::Yield(_) | Expr::YieldFrom(_) => self.found = true,
_ => walk_expr(self, expr),
}
}
fn visit_stmt(&mut self, stmt: &Stmt) {
if self.found {
return;
}
match stmt {
Stmt::Return(_) | Stmt::Raise(_) => self.found = true,
Stmt::FunctionDef(_) => {
// Do not recurse into nested functions; they're evaluated separately.
}
_ => walk_stmt(self, stmt),
}
}
}

View File

@ -0,0 +1,31 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF066 `name` is a property without a `return` statement
--> RUF066.py:7:9
|
5 | class User: # Test normal class properties
6 | @property
7 | def name(self): # ERROR: No return
| ^^^^
8 | f"{self.first_name} {self.last_name}"
|
RUF066 `nested` is a property without a `return` statement
--> RUF066.py:18:9
|
17 | @property
18 | def nested(self): # ERROR: Property itself doesn't return
| ^^^^^^
19 | def inner():
20 | return 0
|
RUF066 `prop2` is a property without a `return` statement
--> RUF066.py:43:9
|
42 | @property
43 | def prop2(self): # ERROR: Not returning something (even when we are inside an ABC)
| ^^^^^
44 | 50
|

View File

@ -3,12 +3,13 @@ use std::path::Path;
use bitflags::bitflags; use bitflags::bitflags;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use ruff_python_ast::helpers::from_relative_import; use ruff_python_ast::helpers::{from_relative_import, map_subscript};
use ruff_python_ast::name::{QualifiedName, UnqualifiedName}; use ruff_python_ast::name::{QualifiedName, UnqualifiedName};
use ruff_python_ast::{self as ast, Expr, ExprContext, PySourceType, Stmt}; use ruff_python_ast::{self as ast, Expr, ExprContext, PySourceType, Stmt};
use ruff_text_size::{Ranged, TextRange, TextSize}; use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::Imported; use crate::Imported;
use crate::analyze::visibility;
use crate::binding::{ use crate::binding::{
Binding, BindingFlags, BindingId, BindingKind, Bindings, Exceptions, FromImport, Import, Binding, BindingFlags, BindingId, BindingKind, Bindings, Exceptions, FromImport, Import,
SubmoduleImport, SubmoduleImport,
@ -2153,6 +2154,21 @@ impl<'a> SemanticModel<'a> {
function.range() == function_def.range() function.range() == function_def.range()
}) })
} }
/// Return `true` if the model is in a `typing.Protocol` subclass or an abstract
/// method.
pub fn in_protocol_or_abstract_method(&self) -> bool {
self.current_scopes().any(|scope| match scope.kind {
ScopeKind::Class(class_def) => class_def
.bases()
.iter()
.any(|base| self.match_typing_expr(map_subscript(base), "Protocol")),
ScopeKind::Function(function_def) => {
visibility::is_abstract(&function_def.decorator_list, self)
}
_ => false,
})
}
} }
pub struct ShadowedBinding { pub struct ShadowedBinding {

1
ruff.schema.json generated
View File

@ -4044,6 +4044,7 @@
"RUF063", "RUF063",
"RUF064", "RUF064",
"RUF065", "RUF065",
"RUF066",
"RUF1", "RUF1",
"RUF10", "RUF10",
"RUF100", "RUF100",