Skip to content

Commit 0e83107

Browse files
committed
Make SLF diagnostics robust to non-self-named variables
1 parent d11fd4b commit 0e83107

8 files changed

+262
-7
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
class C:
2+
def non_self_named_method_receiver(this):
3+
this._x = 0 # stable: fine, preview: fine
4+
5+
@classmethod
6+
def non_self_named_classmethod_receiver(that):
7+
return that._x # stable: fine, preview: fine
8+
9+
def non_receiver_named_self_parameter(this, self):
10+
self._x = 1 # stable: fine, preview: error
11+
12+
@classmethod
13+
def classmethod_named_self(self):
14+
return self._x # stable: fine, preview: fine
15+
16+
@staticmethod
17+
def staticmethod_named_self(self):
18+
return self._x # stable: fine, preview: error
19+
20+
21+
def top_level_self_parameter(self):
22+
return self._x # stable: fine, preview: error
23+
24+
25+
def local_self_binding():
26+
self = C()
27+
return self._x # stable: fine, preview: error
28+
29+
30+
self = C()
31+
32+
33+
def global_self_binding():
34+
return self._x # stable: fine, preview: error
35+
36+
37+
def top_level_cls_parameter(cls):
38+
return cls._x # stable: fine, preview: error
39+
40+
41+
def local_mcs_binding():
42+
mcs = C
43+
return mcs._x # stable: fine, preview: error
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
def custom_classmethod(func):
2+
return classmethod(func)
3+
4+
5+
def custom_staticmethod(func):
6+
return staticmethod(func)
7+
8+
9+
class C:
10+
def ok(this):
11+
return this._x # fine
12+
13+
@custom_classmethod
14+
def ok_classmethod(this):
15+
return this._x # fine
16+
17+
@custom_staticmethod
18+
def bad_staticmethod(self):
19+
return self._x # error

crates/ruff_linter/src/preview.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ pub(crate) const fn is_refined_submodule_import_match_enabled(settings: &LinterS
218218
settings.preview.is_enabled()
219219
}
220220

221+
// https://github.com/astral-sh/ruff/issues/24275
222+
pub(crate) const fn is_slf001_self_cls_mcs_enforcement_enabled(settings: &LinterSettings) -> bool {
223+
settings.preview.is_enabled()
224+
}
225+
221226
// https://github.com/astral-sh/ruff/pull/20660
222227
pub(crate) const fn is_type_var_default_enabled(settings: &LinterSettings) -> bool {
223228
settings.preview.is_enabled()

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod tests {
88

99
use crate::registry::Rule;
1010
use crate::rules::flake8_self;
11+
use crate::settings::types::PreviewMode;
1112
use crate::test::test_path;
1213
use crate::{assert_diagnostics, settings};
1314
use anyhow::Result;
@@ -16,6 +17,7 @@ mod tests {
1617

1718
#[test_case(Rule::PrivateMemberAccess, Path::new("SLF001.py"))]
1819
#[test_case(Rule::PrivateMemberAccess, Path::new("SLF001_1.py"))]
20+
#[test_case(Rule::PrivateMemberAccess, Path::new("SLF001_2.py"))]
1921
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
2022
let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy());
2123
let diagnostics = test_path(
@@ -26,6 +28,20 @@ mod tests {
2628
Ok(())
2729
}
2830

31+
#[test_case(Rule::PrivateMemberAccess, Path::new("SLF001_2.py"))]
32+
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
33+
let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy());
34+
let diagnostics = test_path(
35+
Path::new("flake8_self").join(path).as_path(),
36+
&settings::LinterSettings {
37+
preview: PreviewMode::Enabled,
38+
..settings::LinterSettings::for_rule(rule_code)
39+
},
40+
)?;
41+
assert_diagnostics!(snapshot, diagnostics);
42+
Ok(())
43+
}
44+
2945
#[test]
3046
fn ignore_names() -> Result<()> {
3147
let diagnostics = test_path(
@@ -40,4 +56,22 @@ mod tests {
4056
assert_diagnostics!(diagnostics);
4157
Ok(())
4258
}
59+
60+
#[test]
61+
fn custom_method_decorators() -> Result<()> {
62+
let diagnostics = test_path(
63+
Path::new("flake8_self/SLF001_custom_decorators.py"),
64+
&settings::LinterSettings {
65+
preview: PreviewMode::Enabled,
66+
pep8_naming: crate::rules::pep8_naming::settings::Settings {
67+
classmethod_decorators: vec!["custom_classmethod".to_string()],
68+
staticmethod_decorators: vec!["custom_staticmethod".to_string()],
69+
..Default::default()
70+
},
71+
..settings::LinterSettings::for_rule(Rule::PrivateMemberAccess)
72+
},
73+
)?;
74+
assert_diagnostics!(diagnostics);
75+
Ok(())
76+
}
4377
}

crates/ruff_linter/src/rules/flake8_self/rules/private_member_access.rs

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ use ruff_macros::{ViolationMetadata, derive_message_formats};
22
use ruff_python_ast::helpers::{is_dunder, is_sunder};
33
use ruff_python_ast::name::UnqualifiedName;
44
use ruff_python_ast::{self as ast, Expr};
5+
use ruff_python_semantic::analyze::function_type;
56
use ruff_python_semantic::analyze::typing;
67
use ruff_python_semantic::analyze::typing::TypeChecker;
78
use ruff_python_semantic::{BindingKind, ScopeKind, SemanticModel};
89
use ruff_text_size::Ranged;
910

1011
use crate::Violation;
1112
use crate::checkers::ast::Checker;
13+
use crate::preview::is_slf001_self_cls_mcs_enforcement_enabled;
1214
use crate::rules::pylint::helpers::is_dunder_operator_method;
1315

1416
/// ## What it does
@@ -76,6 +78,7 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
7678

7779
let semantic = checker.semantic();
7880
let current_scope = semantic.current_scope();
81+
let enforce_self_cls_mcs = is_slf001_self_cls_mcs_enforcement_enabled(checker.settings());
7982

8083
if semantic.in_annotation() {
8184
return;
@@ -117,11 +120,11 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
117120
}
118121
}
119122

120-
if let Some(name) = UnqualifiedName::from_expr(value) {
121-
// Ignore `self` and `cls` accesses.
122-
if matches!(name.segments(), ["self" | "cls" | "mcs"]) {
123-
return;
124-
}
123+
if !enforce_self_cls_mcs
124+
&& let Some(name) = UnqualifiedName::from_expr(value)
125+
&& matches!(name.segments(), ["self" | "cls" | "mcs"])
126+
{
127+
return;
125128
}
126129

127130
if let Expr::Name(name) = value.as_ref() {
@@ -140,7 +143,12 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
140143
return;
141144
}
142145

143-
if is_same_class_instance(name, semantic) {
146+
if is_same_class_instance(
147+
name,
148+
semantic,
149+
&checker.settings().pep8_naming.classmethod_decorators,
150+
&checker.settings().pep8_naming.staticmethod_decorators,
151+
) {
144152
return;
145153
}
146154
}
@@ -177,7 +185,21 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
177185
///
178186
/// This function is intentionally naive and does not handle more complex cases.
179187
/// It is expected to be expanded overtime, possibly when type-aware APIs are available.
180-
fn is_same_class_instance(name: &ast::ExprName, semantic: &SemanticModel) -> bool {
188+
fn is_same_class_instance(
189+
name: &ast::ExprName,
190+
semantic: &SemanticModel,
191+
classmethod_decorators: &[String],
192+
staticmethod_decorators: &[String],
193+
) -> bool {
194+
if is_method_receiver(
195+
name,
196+
semantic,
197+
classmethod_decorators,
198+
staticmethod_decorators,
199+
) {
200+
return true;
201+
}
202+
181203
let Some(binding_id) = semantic.resolve_name(name) else {
182204
return false;
183205
};
@@ -186,6 +208,60 @@ fn is_same_class_instance(name: &ast::ExprName, semantic: &SemanticModel) -> boo
186208
typing::check_type::<SameClassInstanceChecker>(binding, semantic)
187209
}
188210

211+
/// Return `true` if `name` resolves to the first parameter of a syntactic
212+
/// method receiver, including class methods and `__new__`.
213+
fn is_method_receiver(
214+
name: &ast::ExprName,
215+
semantic: &SemanticModel,
216+
classmethod_decorators: &[String],
217+
staticmethod_decorators: &[String],
218+
) -> bool {
219+
let Some(binding_id) = semantic.resolve_name(name) else {
220+
return false;
221+
};
222+
let binding = semantic.binding(binding_id);
223+
224+
if !matches!(binding.kind, BindingKind::Argument) {
225+
return false;
226+
}
227+
228+
let Some(ast::Stmt::FunctionDef(function)) = binding.statement(semantic) else {
229+
return false;
230+
};
231+
232+
let Some(first_parameter) = function
233+
.parameters
234+
.posonlyargs
235+
.first()
236+
.or_else(|| function.parameters.args.first())
237+
else {
238+
return false;
239+
};
240+
241+
if binding.range != first_parameter.parameter.name.range() {
242+
return false;
243+
}
244+
245+
let scope = &semantic.scopes[binding.scope];
246+
let Some(parent_scope) = semantic.first_non_type_parent_scope(scope) else {
247+
return false;
248+
};
249+
250+
matches!(
251+
function_type::classify(
252+
&function.name,
253+
&function.decorator_list,
254+
parent_scope,
255+
semantic,
256+
classmethod_decorators,
257+
staticmethod_decorators,
258+
),
259+
function_type::FunctionType::Method
260+
| function_type::FunctionType::ClassMethod
261+
| function_type::FunctionType::NewMethod
262+
)
263+
}
264+
189265
struct SameClassInstanceChecker;
190266

191267
impl SameClassInstanceChecker {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_self/mod.rs
3+
---
4+
SLF001 Private member accessed: `_x`
5+
--> SLF001_custom_decorators.py:19:16
6+
|
7+
17 | @custom_staticmethod
8+
18 | def bad_staticmethod(self):
9+
19 | return self._x # error
10+
| ^^^^^^^
11+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_self/mod.rs
3+
---
4+
SLF001 Private member accessed: `_x`
5+
--> SLF001_2.py:10:9
6+
|
7+
9 | def non_receiver_named_self_parameter(this, self):
8+
10 | self._x = 1 # stable: fine, preview: error
9+
| ^^^^^^^
10+
11 |
11+
12 | @classmethod
12+
|
13+
14+
SLF001 Private member accessed: `_x`
15+
--> SLF001_2.py:18:16
16+
|
17+
16 | @staticmethod
18+
17 | def staticmethod_named_self(self):
19+
18 | return self._x # stable: fine, preview: error
20+
| ^^^^^^^
21+
|
22+
23+
SLF001 Private member accessed: `_x`
24+
--> SLF001_2.py:22:12
25+
|
26+
21 | def top_level_self_parameter(self):
27+
22 | return self._x # stable: fine, preview: error
28+
| ^^^^^^^
29+
|
30+
31+
SLF001 Private member accessed: `_x`
32+
--> SLF001_2.py:27:12
33+
|
34+
25 | def local_self_binding():
35+
26 | self = C()
36+
27 | return self._x # stable: fine, preview: error
37+
| ^^^^^^^
38+
|
39+
40+
SLF001 Private member accessed: `_x`
41+
--> SLF001_2.py:34:12
42+
|
43+
33 | def global_self_binding():
44+
34 | return self._x # stable: fine, preview: error
45+
| ^^^^^^^
46+
|
47+
48+
SLF001 Private member accessed: `_x`
49+
--> SLF001_2.py:38:12
50+
|
51+
37 | def top_level_cls_parameter(cls):
52+
38 | return cls._x # stable: fine, preview: error
53+
| ^^^^^^
54+
|
55+
56+
SLF001 Private member accessed: `_x`
57+
--> SLF001_2.py:43:12
58+
|
59+
41 | def local_mcs_binding():
60+
42 | mcs = C
61+
43 | return mcs._x # stable: fine, preview: error
62+
| ^^^^^^
63+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_self/mod.rs
3+
---
4+

0 commit comments

Comments
 (0)