Skip to content

Commit 25bafd2

Browse files
Restrict builtin-attribute-shadowing to actual shadowed references (#9462)
## Summary This PR attempts to improve `builtin-attribute-shadowing` (`A003`), a rule which has been repeatedly criticized, but _does_ have value (just not in the current form). Historically, this rule would flag cases like: ```python class Class: id: int ``` This led to an increasing number of exceptions and special-cases to the rule over time to try and improve it's specificity (e.g., ignore `TypedDict`, ignore `@override`). The crux of the issue is that given the above, referencing `id` will never resolve to `Class.id`, so the shadowing is actually fine. There's one exception, however: ```python class Class: id: int def do_thing() -> id: pass ``` Here, `id` actually resolves to the `id` attribute on the class, not the `id` builtin. So this PR completely reworks the rule around this _much_ more targeted case, which will almost always be a mistake: when you reference a class member from within the class, and that member shadows a builtin. Closes #6524. Closes #7806.
1 parent 7fc51d2 commit 25bafd2

File tree

8 files changed

+160
-265
lines changed

8 files changed

+160
-265
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_builtins/A003.py

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,14 @@ def __init__(self):
88
self.id = 10
99
self.dir = "."
1010

11-
def str(self):
11+
def int(self):
1212
pass
1313

14-
15-
from typing import TypedDict
16-
17-
18-
class MyClass(TypedDict):
19-
id: int
20-
21-
22-
from threading import Event
23-
24-
25-
class CustomEvent(Event):
26-
def set(self) -> None:
27-
...
28-
29-
def str(self) -> None:
30-
...
31-
32-
33-
from logging import Filter, LogRecord
34-
35-
36-
class CustomFilter(Filter):
37-
def filter(self, record: LogRecord) -> bool:
38-
...
39-
40-
def str(self) -> None:
41-
...
42-
43-
44-
from typing_extensions import override
45-
46-
47-
class MyClass:
48-
@override
4914
def str(self):
5015
pass
5116

52-
def int(self):
17+
def method_usage(self) -> str:
18+
pass
19+
20+
def attribute_usage(self) -> id:
5321
pass

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use crate::checkers::ast::Checker;
77
use crate::codes::Rule;
88
use crate::fix;
99
use crate::rules::{
10-
flake8_pyi, flake8_type_checking, flake8_unused_arguments, pyflakes, pylint, ruff,
10+
flake8_builtins, flake8_pyi, flake8_type_checking, flake8_unused_arguments, pyflakes, pylint,
11+
ruff,
1112
};
1213

1314
/// Run lint rules over all deferred scopes in the [`SemanticModel`].
@@ -27,6 +28,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
2728
Rule::UndefinedLocal,
2829
Rule::UnusedAnnotation,
2930
Rule::UnusedClassMethodArgument,
31+
Rule::BuiltinAttributeShadowing,
3032
Rule::UnusedFunctionArgument,
3133
Rule::UnusedImport,
3234
Rule::UnusedLambdaArgument,
@@ -297,6 +299,18 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
297299
ruff::rules::asyncio_dangling_binding(scope, &checker.semantic, &mut diagnostics);
298300
}
299301

302+
if let Some(class_def) = scope.kind.as_class() {
303+
if checker.enabled(Rule::BuiltinAttributeShadowing) {
304+
flake8_builtins::rules::builtin_attribute_shadowing(
305+
checker,
306+
scope_id,
307+
scope,
308+
class_def,
309+
&mut diagnostics,
310+
);
311+
}
312+
}
313+
300314
if matches!(scope.kind, ScopeKind::Function(_) | ScopeKind::Lambda(_)) {
301315
if checker.enabled(Rule::UnusedVariable) {
302316
pyflakes::rules::unused_variable(checker, scope, &mut diagnostics);

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
242242
checker.diagnostics.push(diagnostic);
243243
}
244244
}
245-
if let ScopeKind::Class(class_def) = checker.semantic.current_scope().kind {
246-
if checker.enabled(Rule::BuiltinAttributeShadowing) {
247-
flake8_builtins::rules::builtin_attribute_shadowing(
248-
checker, class_def, id, *range,
249-
);
250-
}
251-
} else {
245+
if !checker.semantic.current_scope().kind.is_class() {
252246
if checker.enabled(Rule::BuiltinVariableShadowing) {
253247
flake8_builtins::rules::builtin_variable_shadowing(checker, id, *range);
254248
}

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

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -347,17 +347,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
347347
if checker.enabled(Rule::FStringDocstring) {
348348
flake8_bugbear::rules::f_string_docstring(checker, body);
349349
}
350-
if let ScopeKind::Class(class_def) = checker.semantic.current_scope().kind {
351-
if checker.enabled(Rule::BuiltinAttributeShadowing) {
352-
flake8_builtins::rules::builtin_method_shadowing(
353-
checker,
354-
class_def,
355-
name,
356-
decorator_list,
357-
name.range(),
358-
);
359-
}
360-
} else {
350+
if !checker.semantic.current_scope().kind.is_class() {
361351
if checker.enabled(Rule::BuiltinVariableShadowing) {
362352
flake8_builtins::rules::builtin_variable_shadowing(checker, name, name.range());
363353
}
Lines changed: 102 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
use ruff_python_ast as ast;
2-
use ruff_python_ast::{Arguments, Decorator};
3-
use ruff_text_size::TextRange;
4-
51
use ruff_diagnostics::Diagnostic;
62
use ruff_diagnostics::Violation;
73
use ruff_macros::{derive_message_formats, violation};
8-
use ruff_python_semantic::SemanticModel;
4+
use ruff_python_ast as ast;
5+
use ruff_python_semantic::{BindingKind, Scope, ScopeId};
6+
use ruff_source_file::SourceRow;
7+
use ruff_text_size::Ranged;
98

109
use crate::checkers::ast::Checker;
1110
use crate::rules::flake8_builtins::helpers::shadows_builtin;
@@ -20,6 +19,23 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin;
2019
/// non-obvious errors, as readers may mistake the attribute for the
2120
/// builtin and vice versa.
2221
///
22+
/// Since methods and class attributes typically cannot be referenced directly
23+
/// from outside the class scope, this rule only applies to those methods
24+
/// and attributes that both shadow a builtin _and_ are referenced from within
25+
/// the class scope, as in the following example, where the `list[int]` return
26+
/// type annotation resolves to the `list` method, rather than the builtin:
27+
///
28+
/// ```python
29+
/// class Class:
30+
/// @staticmethod
31+
/// def list() -> None:
32+
/// pass
33+
///
34+
/// @staticmethod
35+
/// def repeat(value: int, times: int) -> list[int]:
36+
/// return [value] * times
37+
/// ```
38+
///
2339
/// Builtins can be marked as exceptions to this rule via the
2440
/// [`flake8-builtins.builtins-ignorelist`] configuration option, or
2541
/// converted to the appropriate dunder method. Methods decorated with
@@ -28,135 +44,112 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin;
2844
///
2945
/// ## Example
3046
/// ```python
31-
/// class Shadow:
32-
/// def int():
33-
/// return 0
34-
/// ```
35-
///
36-
/// Use instead:
37-
/// ```python
38-
/// class Shadow:
39-
/// def to_int():
40-
/// return 0
41-
/// ```
47+
/// class Class:
48+
/// @staticmethod
49+
/// def list() -> None:
50+
/// pass
4251
///
43-
/// Or:
44-
/// ```python
45-
/// class Shadow:
46-
/// # Callable as `int(shadow)`
47-
/// def __int__():
48-
/// return 0
52+
/// @staticmethod
53+
/// def repeat(value: int, times: int) -> list[int]:
54+
/// return [value] * times
4955
/// ```
5056
///
5157
/// ## Options
5258
/// - `flake8-builtins.builtins-ignorelist`
53-
///
54-
/// ## References
55-
/// - [_Is it bad practice to use a built-in function name as an attribute or method identifier?_](https://stackoverflow.com/questions/9109333/is-it-bad-practice-to-use-a-built-in-function-name-as-an-attribute-or-method-ide)
56-
/// - [_Why is it a bad idea to name a variable `id` in Python?_](https://stackoverflow.com/questions/77552/id-is-a-bad-variable-name-in-python)
5759
#[violation]
5860
pub struct BuiltinAttributeShadowing {
61+
kind: Kind,
5962
name: String,
63+
row: SourceRow,
6064
}
6165

6266
impl Violation for BuiltinAttributeShadowing {
6367
#[derive_message_formats]
6468
fn message(&self) -> String {
65-
let BuiltinAttributeShadowing { name } = self;
66-
format!("Class attribute `{name}` is shadowing a Python builtin")
69+
let BuiltinAttributeShadowing { kind, name, row } = self;
70+
match kind {
71+
Kind::Attribute => {
72+
format!("Python builtin is shadowed by class attribute `{name}` from {row}")
73+
}
74+
Kind::Method => {
75+
format!("Python builtin is shadowed by method `{name}` from {row}")
76+
}
77+
}
6778
}
6879
}
6980

7081
/// A003
7182
pub(crate) fn builtin_attribute_shadowing(
72-
checker: &mut Checker,
83+
checker: &Checker,
84+
scope_id: ScopeId,
85+
scope: &Scope,
7386
class_def: &ast::StmtClassDef,
74-
name: &str,
75-
range: TextRange,
87+
diagnostics: &mut Vec<Diagnostic>,
7688
) {
77-
if shadows_builtin(
78-
name,
79-
&checker.settings.flake8_builtins.builtins_ignorelist,
80-
checker.source_type,
81-
) {
82-
// Ignore shadowing within `TypedDict` definitions, since these are only accessible through
83-
// subscripting and not through attribute access.
84-
if class_def
85-
.bases()
86-
.iter()
87-
.any(|base| checker.semantic().match_typing_expr(base, "TypedDict"))
88-
{
89-
return;
90-
}
89+
for (name, binding_id) in scope.all_bindings() {
90+
let binding = checker.semantic().binding(binding_id);
9191

92-
checker.diagnostics.push(Diagnostic::new(
93-
BuiltinAttributeShadowing {
94-
name: name.to_string(),
95-
},
96-
range,
97-
));
98-
}
99-
}
92+
// We only care about methods and attributes.
93+
let kind = match binding.kind {
94+
BindingKind::Assignment | BindingKind::Annotation => Kind::Attribute,
95+
BindingKind::FunctionDefinition(_) => Kind::Method,
96+
_ => continue,
97+
};
10098

101-
/// A003
102-
pub(crate) fn builtin_method_shadowing(
103-
checker: &mut Checker,
104-
class_def: &ast::StmtClassDef,
105-
name: &str,
106-
decorator_list: &[Decorator],
107-
range: TextRange,
108-
) {
109-
if shadows_builtin(
110-
name,
111-
&checker.settings.flake8_builtins.builtins_ignorelist,
112-
checker.source_type,
113-
) {
114-
// Ignore some standard-library methods. Ideally, we'd ignore all overridden methods, since
115-
// those should be flagged on the superclass, but that's more difficult.
116-
if is_standard_library_override(name, class_def, checker.semantic()) {
117-
return;
118-
}
99+
if shadows_builtin(
100+
name,
101+
&checker.settings.flake8_builtins.builtins_ignorelist,
102+
checker.source_type,
103+
) {
104+
// Ignore explicit overrides.
105+
if class_def.decorator_list.iter().any(|decorator| {
106+
checker
107+
.semantic()
108+
.match_typing_expr(&decorator.expression, "override")
109+
}) {
110+
return;
111+
}
119112

120-
// Ignore explicit overrides.
121-
if decorator_list.iter().any(|decorator| {
122-
checker
123-
.semantic()
124-
.match_typing_expr(&decorator.expression, "override")
125-
}) {
126-
return;
113+
// Class scopes are special, in that you can only reference a binding defined in a
114+
// class scope from within the class scope itself. As such, we can safely ignore
115+
// methods that weren't referenced from within the class scope. In other words, we're
116+
// only trying to identify shadowing as in:
117+
// ```python
118+
// class Class:
119+
// @staticmethod
120+
// def list() -> None:
121+
// pass
122+
//
123+
// @staticmethod
124+
// def repeat(value: int, times: int) -> list[int]:
125+
// return [value] * times
126+
// ```
127+
for reference in binding
128+
.references
129+
.iter()
130+
.map(|reference_id| checker.semantic().reference(*reference_id))
131+
.filter(|reference| {
132+
checker
133+
.semantic()
134+
.first_non_type_parent_scope_id(reference.scope_id())
135+
== Some(scope_id)
136+
})
137+
{
138+
diagnostics.push(Diagnostic::new(
139+
BuiltinAttributeShadowing {
140+
kind,
141+
name: name.to_string(),
142+
row: checker.compute_source_row(binding.start()),
143+
},
144+
reference.range(),
145+
));
146+
}
127147
}
128-
129-
checker.diagnostics.push(Diagnostic::new(
130-
BuiltinAttributeShadowing {
131-
name: name.to_string(),
132-
},
133-
range,
134-
));
135148
}
136149
}
137150

138-
/// Return `true` if an attribute appears to be an override of a standard-library method.
139-
fn is_standard_library_override(
140-
name: &str,
141-
class_def: &ast::StmtClassDef,
142-
semantic: &SemanticModel,
143-
) -> bool {
144-
let Some(Arguments { args: bases, .. }) = class_def.arguments.as_deref() else {
145-
return false;
146-
};
147-
match name {
148-
// Ex) `Event.set`
149-
"set" => bases.iter().any(|base| {
150-
semantic
151-
.resolve_call_path(base)
152-
.is_some_and(|call_path| matches!(call_path.as_slice(), ["threading", "Event"]))
153-
}),
154-
// Ex) `Filter.filter`
155-
"filter" => bases.iter().any(|base| {
156-
semantic
157-
.resolve_call_path(base)
158-
.is_some_and(|call_path| matches!(call_path.as_slice(), ["logging", "Filter"]))
159-
}),
160-
_ => false,
161-
}
151+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
152+
enum Kind {
153+
Attribute,
154+
Method,
162155
}

0 commit comments

Comments
 (0)