Skip to content

Commit 9f01ac3

Browse files
authored
[pylint] Implement invalid-length-returned (E0303) (#10963)
Add pylint rule invalid-length-returned (PLE0303) See #970 for rules Test Plan: `cargo test` TBD: from the description: "Strictly speaking `bool` is a subclass of `int`, thus returning `True`/`False` is valid. To be consistent with other rules (e.g. [PLE0305](#10962) invalid-index-returned), ruff will raise, compared to pylint which will not raise."
1 parent b23414e commit 9f01ac3

File tree

8 files changed

+242
-0
lines changed

8 files changed

+242
-0
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# These testcases should raise errors
2+
3+
4+
class Bool:
5+
def __len__(self):
6+
return True # [invalid-length-return]
7+
8+
9+
class Float:
10+
def __len__(self):
11+
return 3.05 # [invalid-length-return]
12+
13+
14+
class Str:
15+
def __len__(self):
16+
return "ruff" # [invalid-length-return]
17+
18+
19+
class LengthNoReturn:
20+
def __len__(self):
21+
print("ruff") # [invalid-length-return]
22+
23+
24+
class LengthNegative:
25+
def __len__(self):
26+
return -42 # [invalid-length-return]
27+
28+
29+
class LengthWrongRaise:
30+
def __len__(self):
31+
print("raise some error")
32+
raise NotImplementedError # [invalid-length-return]
33+
34+
35+
# TODO: Once Ruff has better type checking
36+
def return_int():
37+
return "3"
38+
39+
40+
class ComplexReturn:
41+
def __len__(self):
42+
return return_int() # [invalid-length-return]
43+
44+
45+
# These testcases should NOT raise errors
46+
47+
48+
class Length:
49+
def __len__(self):
50+
return 42
51+
52+
53+
class Length2:
54+
def __len__(self):
55+
x = 42
56+
return x
57+
58+
59+
class Length3:
60+
def __len__(self): ...
61+
62+
63+
class Length4:
64+
def __len__(self):
65+
pass
66+
67+
68+
class Length5:
69+
def __len__(self):
70+
raise NotImplementedError

crates/ruff_linter/src/checkers/ast/analyze/statement.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
9797
if checker.enabled(Rule::InvalidBoolReturnType) {
9898
pylint::rules::invalid_bool_return(checker, name, body);
9999
}
100+
if checker.enabled(Rule::InvalidLengthReturnType) {
101+
pylint::rules::invalid_length_return(checker, function_def);
102+
}
100103
if checker.enabled(Rule::InvalidBytesReturnType) {
101104
pylint::rules::invalid_bytes_return(checker, function_def);
102105
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
241241
(Pylint, "E0237") => (RuleGroup::Stable, rules::pylint::rules::NonSlotAssignment),
242242
(Pylint, "E0241") => (RuleGroup::Stable, rules::pylint::rules::DuplicateBases),
243243
(Pylint, "E0302") => (RuleGroup::Stable, rules::pylint::rules::UnexpectedSpecialMethodSignature),
244+
(Pylint, "E0303") => (RuleGroup::Preview, rules::pylint::rules::InvalidLengthReturnType),
244245
(Pylint, "E0304") => (RuleGroup::Preview, rules::pylint::rules::InvalidBoolReturnType),
245246
(Pylint, "E0307") => (RuleGroup::Stable, rules::pylint::rules::InvalidStrReturnType),
246247
(Pylint, "E0308") => (RuleGroup::Preview, rules::pylint::rules::InvalidBytesReturnType),

crates/ruff_linter/src/rules/pylint/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ mod tests {
7777
#[test_case(Rule::InvalidAllFormat, Path::new("invalid_all_format.py"))]
7878
#[test_case(Rule::InvalidAllObject, Path::new("invalid_all_object.py"))]
7979
#[test_case(Rule::InvalidBoolReturnType, Path::new("invalid_return_type_bool.py"))]
80+
#[test_case(
81+
Rule::InvalidLengthReturnType,
82+
Path::new("invalid_return_type_length.py")
83+
)]
8084
#[test_case(
8185
Rule::InvalidBytesReturnType,
8286
Path::new("invalid_return_type_bytes.py")
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
use ruff_diagnostics::{Diagnostic, Violation};
2+
use ruff_macros::{derive_message_formats, violation};
3+
use ruff_python_ast::helpers::ReturnStatementVisitor;
4+
use ruff_python_ast::identifier::Identifier;
5+
use ruff_python_ast::visitor::Visitor;
6+
use ruff_python_ast::{self as ast, Expr};
7+
use ruff_python_semantic::analyze::function_type::is_stub;
8+
use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
9+
use ruff_text_size::Ranged;
10+
11+
use crate::checkers::ast::Checker;
12+
13+
/// ## What it does
14+
/// Checks for `__len__` implementations that return values other than a non-negative
15+
/// `integer`.
16+
///
17+
/// ## Why is this bad?
18+
/// The `__len__` method should return a non-negative `integer`. Returning a different
19+
/// value may cause unexpected behavior.
20+
///
21+
/// Note: `bool` is a subclass of `int`, so it's technically valid for `__len__` to
22+
/// return `True` or `False`. However, for consistency with other rules, Ruff will
23+
/// still raise when `__len__` returns a `bool`.
24+
///
25+
/// ## Example
26+
/// ```python
27+
/// class Foo:
28+
/// def __len__(self):
29+
/// return "2"
30+
/// ```
31+
///
32+
/// Use instead:
33+
/// ```python
34+
/// class Foo:
35+
/// def __len__(self):
36+
/// return 2
37+
/// ```
38+
///
39+
///
40+
/// ## References
41+
/// - [Python documentation: The `__len__` method](https://docs.python.org/3/reference/datamodel.html#object.__len__)
42+
#[violation]
43+
pub struct InvalidLengthReturnType;
44+
45+
impl Violation for InvalidLengthReturnType {
46+
#[derive_message_formats]
47+
fn message(&self) -> String {
48+
format!("`__len__` does not return a non-negative integer")
49+
}
50+
}
51+
52+
/// E0303
53+
pub(crate) fn invalid_length_return(checker: &mut Checker, function_def: &ast::StmtFunctionDef) {
54+
if function_def.name.as_str() != "__len__" {
55+
return;
56+
}
57+
58+
if !checker.semantic().current_scope().kind.is_class() {
59+
return;
60+
}
61+
62+
if is_stub(function_def, checker.semantic()) {
63+
return;
64+
}
65+
66+
let returns = {
67+
let mut visitor = ReturnStatementVisitor::default();
68+
visitor.visit_body(&function_def.body);
69+
visitor.returns
70+
};
71+
72+
if returns.is_empty() {
73+
checker.diagnostics.push(Diagnostic::new(
74+
InvalidLengthReturnType,
75+
function_def.identifier(),
76+
));
77+
}
78+
79+
for stmt in returns {
80+
if let Some(value) = stmt.value.as_deref() {
81+
if is_negative_integer(value)
82+
|| !matches!(
83+
ResolvedPythonType::from(value),
84+
ResolvedPythonType::Unknown
85+
| ResolvedPythonType::Atom(PythonType::Number(NumberLike::Integer))
86+
)
87+
{
88+
checker
89+
.diagnostics
90+
.push(Diagnostic::new(InvalidLengthReturnType, value.range()));
91+
}
92+
} else {
93+
// Disallow implicit `None`.
94+
checker
95+
.diagnostics
96+
.push(Diagnostic::new(InvalidLengthReturnType, stmt.range()));
97+
}
98+
}
99+
}
100+
101+
/// Returns `true` if the given expression is a negative integer.
102+
fn is_negative_integer(value: &Expr) -> bool {
103+
matches!(
104+
value,
105+
Expr::UnaryOp(ast::ExprUnaryOp {
106+
op: ast::UnaryOp::USub,
107+
..
108+
})
109+
)
110+
}

crates/ruff_linter/src/rules/pylint/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub(crate) use invalid_bool_return::*;
3131
pub(crate) use invalid_bytes_return::*;
3232
pub(crate) use invalid_envvar_default::*;
3333
pub(crate) use invalid_envvar_value::*;
34+
pub(crate) use invalid_length_return::*;
3435
pub(crate) use invalid_str_return::*;
3536
pub(crate) use invalid_string_characters::*;
3637
pub(crate) use iteration_over_set::*;
@@ -130,6 +131,7 @@ mod invalid_bool_return;
130131
mod invalid_bytes_return;
131132
mod invalid_envvar_default;
132133
mod invalid_envvar_value;
134+
mod invalid_length_return;
133135
mod invalid_str_return;
134136
mod invalid_string_characters;
135137
mod iteration_over_set;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pylint/mod.rs
3+
---
4+
invalid_return_type_length.py:6:16: PLE0303 `__len__` does not return a non-negative integer
5+
|
6+
4 | class Bool:
7+
5 | def __len__(self):
8+
6 | return True # [invalid-length-return]
9+
| ^^^^ PLE0303
10+
|
11+
12+
invalid_return_type_length.py:11:16: PLE0303 `__len__` does not return a non-negative integer
13+
|
14+
9 | class Float:
15+
10 | def __len__(self):
16+
11 | return 3.05 # [invalid-length-return]
17+
| ^^^^ PLE0303
18+
|
19+
20+
invalid_return_type_length.py:16:16: PLE0303 `__len__` does not return a non-negative integer
21+
|
22+
14 | class Str:
23+
15 | def __len__(self):
24+
16 | return "ruff" # [invalid-length-return]
25+
| ^^^^^^ PLE0303
26+
|
27+
28+
invalid_return_type_length.py:20:9: PLE0303 `__len__` does not return a non-negative integer
29+
|
30+
19 | class LengthNoReturn:
31+
20 | def __len__(self):
32+
| ^^^^^^^ PLE0303
33+
21 | print("ruff") # [invalid-length-return]
34+
|
35+
36+
invalid_return_type_length.py:26:16: PLE0303 `__len__` does not return a non-negative integer
37+
|
38+
24 | class LengthNegative:
39+
25 | def __len__(self):
40+
26 | return -42 # [invalid-length-return]
41+
| ^^^ PLE0303
42+
|
43+
44+
invalid_return_type_length.py:30:9: PLE0303 `__len__` does not return a non-negative integer
45+
|
46+
29 | class LengthWrongRaise:
47+
30 | def __len__(self):
48+
| ^^^^^^^ PLE0303
49+
31 | print("raise some error")
50+
32 | raise NotImplementedError # [invalid-length-return]
51+
|

ruff.schema.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)