Skip to content

Commit adf63d9

Browse files
authored
[pylint] Implement invalid-hash-returned (PLE0309) (#10961)
Add pylint rule invalid-hash-returned (PLE0309) 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 5d3c9f2 commit adf63d9

File tree

8 files changed

+218
-4
lines changed

8 files changed

+218
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# These testcases should raise errors
2+
3+
4+
class Bool:
5+
def __hash__(self):
6+
return True # [invalid-hash-return]
7+
8+
9+
class Float:
10+
def __hash__(self):
11+
return 3.05 # [invalid-hash-return]
12+
13+
14+
class Str:
15+
def __hash__(self):
16+
return "ruff" # [invalid-hash-return]
17+
18+
19+
class HashNoReturn:
20+
def __hash__(self):
21+
print("ruff") # [invalid-hash-return]
22+
23+
24+
# TODO: Once Ruff has better type checking
25+
def return_int():
26+
return "3"
27+
28+
29+
class ComplexReturn:
30+
def __hash__(self):
31+
return return_int() # [invalid-hash-return]
32+
33+
34+
# These testcases should NOT raise errors
35+
36+
37+
class Hash:
38+
def __hash__(self):
39+
return 7741
40+
41+
42+
class Hash2:
43+
def __hash__(self):
44+
x = 7741
45+
return x
46+
47+
48+
class Hash3:
49+
def __hash__(self): ...
50+
51+
52+
class Has4:
53+
def __hash__(self):
54+
pass
55+
56+
57+
class Hash5:
58+
def __hash__(self):
59+
raise NotImplementedError
60+
61+
62+
class HashWrong6:
63+
def __hash__(self):
64+
print("raise some error")
65+
raise NotImplementedError

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

+3
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
103103
if checker.enabled(Rule::InvalidBytesReturnType) {
104104
pylint::rules::invalid_bytes_return(checker, function_def);
105105
}
106+
if checker.enabled(Rule::InvalidHashReturnType) {
107+
pylint::rules::invalid_hash_return(checker, function_def);
108+
}
106109
if checker.enabled(Rule::InvalidStrReturnType) {
107110
pylint::rules::invalid_str_return(checker, function_def);
108111
}

crates/ruff_linter/src/codes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
245245
(Pylint, "E0304") => (RuleGroup::Preview, rules::pylint::rules::InvalidBoolReturnType),
246246
(Pylint, "E0307") => (RuleGroup::Stable, rules::pylint::rules::InvalidStrReturnType),
247247
(Pylint, "E0308") => (RuleGroup::Preview, rules::pylint::rules::InvalidBytesReturnType),
248+
(Pylint, "E0309") => (RuleGroup::Preview, rules::pylint::rules::InvalidHashReturnType),
248249
(Pylint, "E0604") => (RuleGroup::Stable, rules::pylint::rules::InvalidAllObject),
249250
(Pylint, "E0605") => (RuleGroup::Stable, rules::pylint::rules::InvalidAllFormat),
250251
(Pylint, "E0643") => (RuleGroup::Preview, rules::pylint::rules::PotentialIndexError),

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

+5-4
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,15 @@ 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-
)]
8480
#[test_case(
8581
Rule::InvalidBytesReturnType,
8682
Path::new("invalid_return_type_bytes.py")
8783
)]
84+
#[test_case(Rule::InvalidHashReturnType, Path::new("invalid_return_type_hash.py"))]
85+
#[test_case(
86+
Rule::InvalidLengthReturnType,
87+
Path::new("invalid_return_type_length.py")
88+
)]
8889
#[test_case(Rule::InvalidStrReturnType, Path::new("invalid_return_type_str.py"))]
8990
#[test_case(Rule::DuplicateBases, Path::new("duplicate_bases.py"))]
9091
#[test_case(Rule::InvalidCharacterBackspace, Path::new("invalid_characters.py"))]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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};
7+
use ruff_python_semantic::analyze::function_type::is_stub;
8+
use ruff_python_semantic::analyze::terminal::Terminal;
9+
use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
10+
use ruff_text_size::Ranged;
11+
12+
use crate::checkers::ast::Checker;
13+
14+
/// ## What it does
15+
/// Checks for `__hash__` implementations that return a type other than `integer`.
16+
///
17+
/// ## Why is this bad?
18+
/// The `__hash__` method should return an `integer`. Returning a different
19+
/// type may cause unexpected behavior.
20+
///
21+
/// Note: `bool` is a subclass of `int`, so it's technically valid for `__hash__` to
22+
/// return `True` or `False`. However, for consistency with other rules, Ruff will
23+
/// still raise when `__hash__` returns a `bool`.
24+
///
25+
/// ## Example
26+
/// ```python
27+
/// class Foo:
28+
/// def __hash__(self):
29+
/// return "2"
30+
/// ```
31+
///
32+
/// Use instead:
33+
/// ```python
34+
/// class Foo:
35+
/// def __hash__(self):
36+
/// return 2
37+
/// ```
38+
///
39+
///
40+
/// ## References
41+
/// - [Python documentation: The `__hash__` method](https://docs.python.org/3/reference/datamodel.html#object.__hash__)
42+
#[violation]
43+
pub struct InvalidHashReturnType;
44+
45+
impl Violation for InvalidHashReturnType {
46+
#[derive_message_formats]
47+
fn message(&self) -> String {
48+
format!("`__hash__` does not return an integer")
49+
}
50+
}
51+
52+
/// E0309
53+
pub(crate) fn invalid_hash_return(checker: &mut Checker, function_def: &ast::StmtFunctionDef) {
54+
if function_def.name.as_str() != "__hash__" {
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+
// Determine the terminal behavior (i.e., implicit return, no return, etc.).
67+
let terminal = Terminal::from_function(function_def);
68+
69+
// If every control flow path raises an exception, ignore the function.
70+
if terminal == Terminal::Raise {
71+
return;
72+
}
73+
74+
// If there are no return statements, add a diagnostic.
75+
if terminal == Terminal::Implicit {
76+
checker.diagnostics.push(Diagnostic::new(
77+
InvalidHashReturnType,
78+
function_def.identifier(),
79+
));
80+
return;
81+
}
82+
83+
let returns = {
84+
let mut visitor = ReturnStatementVisitor::default();
85+
visitor.visit_body(&function_def.body);
86+
visitor.returns
87+
};
88+
89+
for stmt in returns {
90+
if let Some(value) = stmt.value.as_deref() {
91+
if !matches!(
92+
ResolvedPythonType::from(value),
93+
ResolvedPythonType::Unknown
94+
| ResolvedPythonType::Atom(PythonType::Number(NumberLike::Integer))
95+
) {
96+
checker
97+
.diagnostics
98+
.push(Diagnostic::new(InvalidHashReturnType, value.range()));
99+
}
100+
} else {
101+
// Disallow implicit `None`.
102+
checker
103+
.diagnostics
104+
.push(Diagnostic::new(InvalidHashReturnType, stmt.range()));
105+
}
106+
}
107+
}

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

+2
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_hash_return::*;
3435
pub(crate) use invalid_length_return::*;
3536
pub(crate) use invalid_str_return::*;
3637
pub(crate) use invalid_string_characters::*;
@@ -131,6 +132,7 @@ mod invalid_bool_return;
131132
mod invalid_bytes_return;
132133
mod invalid_envvar_default;
133134
mod invalid_envvar_value;
135+
mod invalid_hash_return;
134136
mod invalid_length_return;
135137
mod invalid_str_return;
136138
mod invalid_string_characters;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pylint/mod.rs
3+
---
4+
invalid_return_type_hash.py:6:16: PLE0309 `__hash__` does not return an integer
5+
|
6+
4 | class Bool:
7+
5 | def __hash__(self):
8+
6 | return True # [invalid-hash-return]
9+
| ^^^^ PLE0309
10+
|
11+
12+
invalid_return_type_hash.py:11:16: PLE0309 `__hash__` does not return an integer
13+
|
14+
9 | class Float:
15+
10 | def __hash__(self):
16+
11 | return 3.05 # [invalid-hash-return]
17+
| ^^^^ PLE0309
18+
|
19+
20+
invalid_return_type_hash.py:16:16: PLE0309 `__hash__` does not return an integer
21+
|
22+
14 | class Str:
23+
15 | def __hash__(self):
24+
16 | return "ruff" # [invalid-hash-return]
25+
| ^^^^^^ PLE0309
26+
|
27+
28+
invalid_return_type_hash.py:20:9: PLE0309 `__hash__` does not return an integer
29+
|
30+
19 | class HashNoReturn:
31+
20 | def __hash__(self):
32+
| ^^^^^^^^ PLE0309
33+
21 | print("ruff") # [invalid-hash-return]
34+
|

ruff.schema.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)