Skip to content

Commit e2a1d1a

Browse files
mikeleppanentBre
andauthored
[ruff] Catch more dummy variable uses (RUF052) (#19799)
## Summary Extends the `used-dummy-variable` rule ([RUF052](https://docs.astral.sh/ruff/rules/used-dummy-variable/)) to detect dummy variables that are used within list comprehensions, dict comprehensions, set comprehensions, and generator expressions, not just regular for loops and function assignments. ### Problem Previously, RUF052 only flagged dummy variables (variables with leading underscores) that were used in function scopes via assignments or regular for loops. It missed cases where dummy variables were used within comprehensions: ```python def example(): my_list = [{"foo": 1}, {"foo": 2}] # These were not detected before: [_item["foo"] for _item in my_list] # Should warn: _item is used {_item["key"]: _item["val"] for _item in my_list} # Should warn: _item is used (_item["foo"] for _item in my_list) # Should warn: _item is used ``` ### Solution - Extended scope checking to include all generator scopes () with any (list/dict/set comprehensions and generator expressions) `ScopeKind::Generator``GeneratorKind` - Added support for bindings, which cover loop variables in both regular for loops and comprehensions `BindingKind::LoopVar` - Refactored the scope validation logic for better readability with a descriptive variable `is_allowed_scope` [ISSUE](#19732) ## Test Plan ```bash cargo test ``` --------- Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
1 parent 040b482 commit e2a1d1a

8 files changed

+706
-67
lines changed

crates/ruff_linter/resources/test/fixtures/ruff/RUF052.py renamed to crates/ruff_linter/resources/test/fixtures/ruff/RUF052_0.py

File renamed without changes.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Correct usage in loop and comprehension
2+
def process_data():
3+
return 42
4+
def test_correct_dummy_usage():
5+
my_list = [{"foo": 1}, {"foo": 2}]
6+
7+
# Should NOT detect - dummy variable is not used
8+
[process_data() for _ in my_list] # OK: `_` is ignored by rule
9+
10+
# Should NOT detect - dummy variable is not used
11+
[item["foo"] for item in my_list] # OK: not a dummy variable name
12+
13+
# Should NOT detect - dummy variable is not used
14+
[42 for _unused in my_list] # OK: `_unused` is not accessed
15+
16+
# Regular For Loops
17+
def test_for_loops():
18+
my_list = [{"foo": 1}, {"foo": 2}]
19+
20+
# Should detect used dummy variable
21+
for _item in my_list:
22+
print(_item["foo"]) # RUF052: Local dummy variable `_item` is accessed
23+
24+
# Should detect used dummy variable
25+
for _index, _value in enumerate(my_list):
26+
result = _index + _value["foo"] # RUF052: Both `_index` and `_value` are accessed
27+
28+
# List Comprehensions
29+
def test_list_comprehensions():
30+
my_list = [{"foo": 1}, {"foo": 2}]
31+
32+
# Should detect used dummy variable
33+
result = [_item["foo"] for _item in my_list] # RUF052: Local dummy variable `_item` is accessed
34+
35+
# Should detect used dummy variable in nested comprehension
36+
nested = [[_item["foo"] for _item in _sublist] for _sublist in [my_list, my_list]]
37+
# RUF052: Both `_item` and `_sublist` are accessed
38+
39+
# Should detect with conditions
40+
filtered = [_item["foo"] for _item in my_list if _item["foo"] > 0]
41+
# RUF052: Local dummy variable `_item` is accessed
42+
43+
# Dict Comprehensions
44+
def test_dict_comprehensions():
45+
my_list = [{"key": "a", "value": 1}, {"key": "b", "value": 2}]
46+
47+
# Should detect used dummy variable
48+
result = {_item["key"]: _item["value"] for _item in my_list}
49+
# RUF052: Local dummy variable `_item` is accessed
50+
51+
# Should detect with enumerate
52+
indexed = {_index: _item["value"] for _index, _item in enumerate(my_list)}
53+
# RUF052: Both `_index` and `_item` are accessed
54+
55+
# Should detect in nested dict comprehension
56+
nested = {_outer: {_inner["key"]: _inner["value"] for _inner in sublist}
57+
for _outer, sublist in enumerate([my_list])}
58+
# RUF052: `_outer`, `_inner` are accessed
59+
60+
# Set Comprehensions
61+
def test_set_comprehensions():
62+
my_list = [{"foo": 1}, {"foo": 2}, {"foo": 1}] # Note: duplicate values
63+
64+
# Should detect used dummy variable
65+
unique_values = {_item["foo"] for _item in my_list}
66+
# RUF052: Local dummy variable `_item` is accessed
67+
68+
# Should detect with conditions
69+
filtered_set = {_item["foo"] for _item in my_list if _item["foo"] > 0}
70+
# RUF052: Local dummy variable `_item` is accessed
71+
72+
# Should detect with complex expression
73+
processed = {_item["foo"] * 2 for _item in my_list}
74+
# RUF052: Local dummy variable `_item` is accessed
75+
76+
# Generator Expressions
77+
def test_generator_expressions():
78+
my_list = [{"foo": 1}, {"foo": 2}]
79+
80+
# Should detect used dummy variable
81+
gen = (_item["foo"] for _item in my_list)
82+
# RUF052: Local dummy variable `_item` is accessed
83+
84+
# Should detect when passed to function
85+
total = sum(_item["foo"] for _item in my_list)
86+
# RUF052: Local dummy variable `_item` is accessed
87+
88+
# Should detect with multiple generators
89+
pairs = ((_x, _y) for _x in range(3) for _y in range(3) if _x != _y)
90+
# RUF052: Both `_x` and `_y` are accessed
91+
92+
# Should detect in nested generator
93+
nested_gen = (sum(_inner["foo"] for _inner in sublist) for _sublist in [my_list] for sublist in _sublist)
94+
# RUF052: `_inner` and `_sublist` are accessed
95+
96+
# Complex Examples with Multiple Comprehension Types
97+
def test_mixed_comprehensions():
98+
data = [{"items": [1, 2, 3]}, {"items": [4, 5, 6]}]
99+
100+
# Should detect in mixed comprehensions
101+
result = [
102+
{_key: [_val * 2 for _val in _record["items"]] for _key in ["doubled"]}
103+
for _record in data
104+
]
105+
# RUF052: `_key`, `_val`, and `_record` are all accessed
106+
107+
# Should detect in generator passed to list constructor
108+
gen_list = list(_item["items"][0] for _item in data)
109+
# RUF052: Local dummy variable `_item` is accessed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ mod tests {
9797
#[test_case(Rule::MapIntVersionParsing, Path::new("RUF048_1.py"))]
9898
#[test_case(Rule::DataclassEnum, Path::new("RUF049.py"))]
9999
#[test_case(Rule::IfKeyInDictDel, Path::new("RUF051.py"))]
100-
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052.py"))]
100+
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052_0.py"))]
101+
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052_1.py"))]
101102
#[test_case(Rule::ClassWithMixedTypeVars, Path::new("RUF053.py"))]
102103
#[test_case(Rule::FalsyDictGetFallback, Path::new("RUF056.py"))]
103104
#[test_case(Rule::UnnecessaryRound, Path::new("RUF057.py"))]
@@ -621,8 +622,8 @@ mod tests {
621622
Ok(())
622623
}
623624

624-
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052.py"), r"^_+", 1)]
625-
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052.py"), r"", 2)]
625+
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052_0.py"), r"^_+", 1)]
626+
#[test_case(Rule::UsedDummyVariable, Path::new("RUF052_0.py"), r"", 2)]
626627
fn custom_regexp_preset(
627628
rule_code: Rule,
628629
path: &Path,

crates/ruff_linter/src/rules/ruff/rules/used_dummy_variable.rs

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use ruff_macros::{ViolationMetadata, derive_message_formats};
22
use ruff_python_ast::helpers::is_dunder;
3-
use ruff_python_semantic::{Binding, BindingId};
3+
use ruff_python_semantic::{Binding, BindingId, BindingKind, ScopeKind};
44
use ruff_python_stdlib::identifiers::is_identifier;
55
use ruff_text_size::Ranged;
66

@@ -111,7 +111,7 @@ pub(crate) fn used_dummy_variable(checker: &Checker, binding: &Binding, binding_
111111
return;
112112
}
113113

114-
// We only emit the lint on variables defined via assignments.
114+
// We only emit the lint on local variables.
115115
//
116116
// ## Why not also emit the lint on function parameters?
117117
//
@@ -127,8 +127,30 @@ pub(crate) fn used_dummy_variable(checker: &Checker, binding: &Binding, binding_
127127
// autofixing the diagnostic for assignments. See:
128128
// - <https://github.com/astral-sh/ruff/issues/14790>
129129
// - <https://github.com/astral-sh/ruff/issues/14799>
130-
if !binding.kind.is_assignment() {
131-
return;
130+
match binding.kind {
131+
BindingKind::Annotation
132+
| BindingKind::Argument
133+
| BindingKind::NamedExprAssignment
134+
| BindingKind::Assignment
135+
| BindingKind::LoopVar
136+
| BindingKind::WithItemVar
137+
| BindingKind::BoundException
138+
| BindingKind::UnboundException(_) => {}
139+
140+
BindingKind::TypeParam
141+
| BindingKind::Global(_)
142+
| BindingKind::Nonlocal(_, _)
143+
| BindingKind::Builtin
144+
| BindingKind::ClassDefinition(_)
145+
| BindingKind::FunctionDefinition(_)
146+
| BindingKind::Export(_)
147+
| BindingKind::FutureImport
148+
| BindingKind::Import(_)
149+
| BindingKind::FromImport(_)
150+
| BindingKind::SubmoduleImport(_)
151+
| BindingKind::Deletion
152+
| BindingKind::ConditionalDeletion(_)
153+
| BindingKind::DunderClassCell => return,
132154
}
133155

134156
// This excludes `global` and `nonlocal` variables.
@@ -138,9 +160,12 @@ pub(crate) fn used_dummy_variable(checker: &Checker, binding: &Binding, binding_
138160

139161
let semantic = checker.semantic();
140162

141-
// Only variables defined in function scopes
163+
// Only variables defined in function and generator scopes
142164
let scope = &semantic.scopes[binding.scope];
143-
if !scope.kind.is_function() {
165+
if !matches!(
166+
scope.kind,
167+
ScopeKind::Function(_) | ScopeKind::Generator { .. }
168+
) {
144169
return;
145170
}
146171

crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF052_RUF052.py.snap renamed to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF052_RUF052_0.py.snap

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
source: crates/ruff_linter/src/rules/ruff/mod.rs
33
---
44
RUF052 [*] Local dummy variable `_var` is accessed
5-
--> RUF052.py:92:9
5+
--> RUF052_0.py:92:9
66
|
77
90 | class Class_:
88
91 | def fun(self):
@@ -24,7 +24,7 @@ help: Remove leading underscores
2424
note: This is an unsafe fix and may change runtime behavior
2525

2626
RUF052 [*] Local dummy variable `_list` is accessed
27-
--> RUF052.py:99:5
27+
--> RUF052_0.py:99:5
2828
|
2929
98 | def fun():
3030
99 | _list = "built-in" # [RUF052]
@@ -45,7 +45,7 @@ help: Prefer using trailing underscores to avoid shadowing a built-in
4545
note: This is an unsafe fix and may change runtime behavior
4646

4747
RUF052 [*] Local dummy variable `_x` is accessed
48-
--> RUF052.py:106:5
48+
--> RUF052_0.py:106:5
4949
|
5050
104 | def fun():
5151
105 | global x
@@ -67,7 +67,7 @@ help: Prefer using trailing underscores to avoid shadowing a variable
6767
note: This is an unsafe fix and may change runtime behavior
6868

6969
RUF052 [*] Local dummy variable `_x` is accessed
70-
--> RUF052.py:113:5
70+
--> RUF052_0.py:113:5
7171
|
7272
111 | def bar():
7373
112 | nonlocal x
@@ -90,7 +90,7 @@ help: Prefer using trailing underscores to avoid shadowing a variable
9090
note: This is an unsafe fix and may change runtime behavior
9191

9292
RUF052 [*] Local dummy variable `_x` is accessed
93-
--> RUF052.py:120:5
93+
--> RUF052_0.py:120:5
9494
|
9595
118 | def fun():
9696
119 | x = "local"
@@ -112,7 +112,7 @@ help: Prefer using trailing underscores to avoid shadowing a variable
112112
note: This is an unsafe fix and may change runtime behavior
113113

114114
RUF052 Local dummy variable `_GLOBAL_1` is accessed
115-
--> RUF052.py:128:5
115+
--> RUF052_0.py:128:5
116116
|
117117
127 | def unfixables():
118118
128 | _GLOBAL_1 = "foo"
@@ -123,7 +123,7 @@ RUF052 Local dummy variable `_GLOBAL_1` is accessed
123123
help: Prefer using trailing underscores to avoid shadowing a variable
124124

125125
RUF052 Local dummy variable `_local` is accessed
126-
--> RUF052.py:136:5
126+
--> RUF052_0.py:136:5
127127
|
128128
135 | # unfixable because the rename would shadow a local variable
129129
136 | _local = "local3" # [RUF052]
@@ -133,7 +133,7 @@ RUF052 Local dummy variable `_local` is accessed
133133
help: Prefer using trailing underscores to avoid shadowing a variable
134134

135135
RUF052 Local dummy variable `_GLOBAL_1` is accessed
136-
--> RUF052.py:140:9
136+
--> RUF052_0.py:140:9
137137
|
138138
139 | def nested():
139139
140 | _GLOBAL_1 = "foo"
@@ -144,7 +144,7 @@ RUF052 Local dummy variable `_GLOBAL_1` is accessed
144144
help: Prefer using trailing underscores to avoid shadowing a variable
145145

146146
RUF052 Local dummy variable `_local` is accessed
147-
--> RUF052.py:145:9
147+
--> RUF052_0.py:145:9
148148
|
149149
144 | # unfixable because the rename would shadow a variable from the outer function
150150
145 | _local = "local4"
@@ -154,7 +154,7 @@ RUF052 Local dummy variable `_local` is accessed
154154
help: Prefer using trailing underscores to avoid shadowing a variable
155155

156156
RUF052 [*] Local dummy variable `_P` is accessed
157-
--> RUF052.py:153:5
157+
--> RUF052_0.py:153:5
158158
|
159159
151 | from collections import namedtuple
160160
152 |
@@ -184,7 +184,7 @@ help: Remove leading underscores
184184
note: This is an unsafe fix and may change runtime behavior
185185

186186
RUF052 [*] Local dummy variable `_T` is accessed
187-
--> RUF052.py:154:5
187+
--> RUF052_0.py:154:5
188188
|
189189
153 | _P = ParamSpec("_P")
190190
154 | _T = TypeVar(name="_T", covariant=True, bound=int|str)
@@ -213,7 +213,7 @@ help: Remove leading underscores
213213
note: This is an unsafe fix and may change runtime behavior
214214

215215
RUF052 [*] Local dummy variable `_NT` is accessed
216-
--> RUF052.py:155:5
216+
--> RUF052_0.py:155:5
217217
|
218218
153 | _P = ParamSpec("_P")
219219
154 | _T = TypeVar(name="_T", covariant=True, bound=int|str)
@@ -242,7 +242,7 @@ help: Remove leading underscores
242242
note: This is an unsafe fix and may change runtime behavior
243243

244244
RUF052 [*] Local dummy variable `_E` is accessed
245-
--> RUF052.py:156:5
245+
--> RUF052_0.py:156:5
246246
|
247247
154 | _T = TypeVar(name="_T", covariant=True, bound=int|str)
248248
155 | _NT = NamedTuple("_NT", [("foo", int)])
@@ -270,7 +270,7 @@ help: Remove leading underscores
270270
note: This is an unsafe fix and may change runtime behavior
271271

272272
RUF052 [*] Local dummy variable `_NT2` is accessed
273-
--> RUF052.py:157:5
273+
--> RUF052_0.py:157:5
274274
|
275275
155 | _NT = NamedTuple("_NT", [("foo", int)])
276276
156 | _E = Enum("_E", ["a", "b", "c"])
@@ -297,7 +297,7 @@ help: Remove leading underscores
297297
note: This is an unsafe fix and may change runtime behavior
298298

299299
RUF052 [*] Local dummy variable `_NT3` is accessed
300-
--> RUF052.py:158:5
300+
--> RUF052_0.py:158:5
301301
|
302302
156 | _E = Enum("_E", ["a", "b", "c"])
303303
157 | _NT2 = namedtuple("_NT2", ['x', 'y', 'z'])
@@ -323,7 +323,7 @@ help: Remove leading underscores
323323
note: This is an unsafe fix and may change runtime behavior
324324

325325
RUF052 [*] Local dummy variable `_DynamicClass` is accessed
326-
--> RUF052.py:159:5
326+
--> RUF052_0.py:159:5
327327
|
328328
157 | _NT2 = namedtuple("_NT2", ['x', 'y', 'z'])
329329
158 | _NT3 = namedtuple(typename="_NT3", field_names=['x', 'y', 'z'])
@@ -347,7 +347,7 @@ help: Remove leading underscores
347347
note: This is an unsafe fix and may change runtime behavior
348348

349349
RUF052 [*] Local dummy variable `_NotADynamicClass` is accessed
350-
--> RUF052.py:160:5
350+
--> RUF052_0.py:160:5
351351
|
352352
158 | _NT3 = namedtuple(typename="_NT3", field_names=['x', 'y', 'z'])
353353
159 | _DynamicClass = type("_DynamicClass", (), {})
@@ -371,7 +371,7 @@ help: Remove leading underscores
371371
note: This is an unsafe fix and may change runtime behavior
372372

373373
RUF052 [*] Local dummy variable `_dummy_var` is accessed
374-
--> RUF052.py:182:5
374+
--> RUF052_0.py:182:5
375375
|
376376
181 | def foo():
377377
182 | _dummy_var = 42
@@ -396,7 +396,7 @@ help: Prefer using trailing underscores to avoid shadowing a variable
396396
note: This is an unsafe fix and may change runtime behavior
397397

398398
RUF052 Local dummy variable `_dummy_var` is accessed
399-
--> RUF052.py:192:5
399+
--> RUF052_0.py:192:5
400400
|
401401
190 | # Unfixable because both possible candidates for the new name are shadowed
402402
191 | # in the scope of one of the references to the variable

0 commit comments

Comments
 (0)