Skip to content

Commit 716688d

Browse files
[pylint] Implement modified-iterating-set (E4703) (#10473)
## Summary Implement `E4703` in the issue #970. Relevant pylint docs is here: https://pylint.readthedocs.io/en/stable/user_guide/messages/error/modified-iterating-set.html ## Test Plan I've written it in the `modified_iterating_set.py`.
1 parent 9ad9cea commit 716688d

File tree

8 files changed

+311
-0
lines changed

8 files changed

+311
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Errors
2+
3+
nums = {1, 2, 3}
4+
for num in nums:
5+
nums.add(num + 1)
6+
7+
animals = {"dog", "cat", "cow"}
8+
for animal in animals:
9+
animals.pop("cow")
10+
11+
fruits = {"apple", "orange", "grape"}
12+
for fruit in fruits:
13+
fruits.clear()
14+
15+
planets = {"mercury", "venus", "earth"}
16+
for planet in planets:
17+
planets.discard("mercury")
18+
19+
colors = {"red", "green", "blue"}
20+
for color in colors:
21+
colors.remove("red")
22+
23+
odds = {1, 3, 5}
24+
for num in odds:
25+
if num > 1:
26+
odds.add(num + 1)
27+
28+
# OK
29+
30+
nums = {1, 2, 3}
31+
for num in nums.copy():
32+
nums.add(nums + 3)
33+
34+
animals = {"dog", "cat", "cow"}
35+
for animal in animals:
36+
print(animals - {animal})
37+
38+
fruits = {"apple", "orange", "grape"}
39+
temp_fruits = set()
40+
for fruit in fruits:
41+
temp_fruits.add(fruit)
42+
temp_fruits.remove(fruit)
43+
temp_fruits.clear(fruit)
44+
45+
colors = {"red", "green", "blue"}
46+
47+
48+
def add_colors():
49+
colors = {"cyan", "magenta", "yellow"}
50+
for color in colors:
51+
52+
def add_color():
53+
global colors
54+
colors.add(color)
55+
56+
add_color()
57+
58+
59+
add_colors()
60+
print(colors)

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

+3
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
12931293
if checker.enabled(Rule::ManualDictComprehension) {
12941294
perflint::rules::manual_dict_comprehension(checker, target, body);
12951295
}
1296+
if checker.enabled(Rule::ModifiedIteratingSet) {
1297+
pylint::rules::modified_iterating_set(checker, for_stmt);
1298+
}
12961299
if checker.enabled(Rule::UnnecessaryListCast) {
12971300
perflint::rules::unnecessary_list_cast(checker, iter, body);
12981301
}

crates/ruff_linter/src/codes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
265265
(Pylint, "E2513") => (RuleGroup::Stable, rules::pylint::rules::InvalidCharacterEsc),
266266
(Pylint, "E2514") => (RuleGroup::Stable, rules::pylint::rules::InvalidCharacterNul),
267267
(Pylint, "E2515") => (RuleGroup::Stable, rules::pylint::rules::InvalidCharacterZeroWidthSpace),
268+
(Pylint, "E4703") => (RuleGroup::Preview, rules::pylint::rules::ModifiedIteratingSet),
268269
(Pylint, "R0124") => (RuleGroup::Stable, rules::pylint::rules::ComparisonWithItself),
269270
(Pylint, "R0133") => (RuleGroup::Stable, rules::pylint::rules::ComparisonOfConstant),
270271
(Pylint, "R0202") => (RuleGroup::Preview, rules::pylint::rules::NoClassmethodDecorator),

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

+1
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ mod tests {
9292
#[test_case(Rule::LoggingTooFewArgs, Path::new("logging_too_few_args.py"))]
9393
#[test_case(Rule::LoggingTooManyArgs, Path::new("logging_too_many_args.py"))]
9494
#[test_case(Rule::MagicValueComparison, Path::new("magic_value_comparison.py"))]
95+
#[test_case(Rule::ModifiedIteratingSet, Path::new("modified_iterating_set.py"))]
9596
#[test_case(
9697
Rule::NamedExprWithoutContext,
9798
Path::new("named_expr_without_context.py")

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

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub(crate) use logging::*;
3737
pub(crate) use magic_value_comparison::*;
3838
pub(crate) use manual_import_from::*;
3939
pub(crate) use misplaced_bare_raise::*;
40+
pub(crate) use modified_iterating_set::*;
4041
pub(crate) use named_expr_without_context::*;
4142
pub(crate) use nan_comparison::*;
4243
pub(crate) use nested_min_max::*;
@@ -130,6 +131,7 @@ mod logging;
130131
mod magic_value_comparison;
131132
mod manual_import_from;
132133
mod misplaced_bare_raise;
134+
mod modified_iterating_set;
133135
mod named_expr_without_context;
134136
mod nan_comparison;
135137
mod nested_min_max;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
2+
use ruff_macros::{derive_message_formats, violation};
3+
use ruff_python_ast::helpers::any_over_body;
4+
use ruff_python_ast::{self as ast, Expr, StmtFor};
5+
use ruff_python_semantic::analyze::typing::is_set;
6+
use ruff_text_size::Ranged;
7+
8+
use crate::checkers::ast::Checker;
9+
10+
/// ## What it does
11+
/// Checks for loops in which a `set` is modified during iteration.
12+
///
13+
/// ## Why is this bad?
14+
/// If a `set` is modified during iteration, it will cause a `RuntimeError`.
15+
///
16+
/// If you need to modify a `set` within a loop, consider iterating over a copy
17+
/// of the `set` instead.
18+
///
19+
/// ## Known problems
20+
/// This rule favors false negatives over false positives. Specifically, it
21+
/// will only detect variables that can be inferred to be a `set` type based on
22+
/// local type inference, and will only detect modifications that are made
23+
/// directly on the variable itself (e.g., `set.add()`), as opposed to
24+
/// modifications within other function calls (e.g., `some_function(set)`).
25+
///
26+
/// ## Example
27+
/// ```python
28+
/// nums = {1, 2, 3}
29+
/// for num in nums:
30+
/// nums.add(num + 5)
31+
/// ```
32+
///
33+
/// Use instead:
34+
/// ```python
35+
/// nums = {1, 2, 3}
36+
/// for num in nums.copy():
37+
/// nums.add(num + 5)
38+
/// ```
39+
///
40+
/// ## References
41+
/// - [Python documentation: `set`](https://docs.python.org/3/library/stdtypes.html#set)
42+
#[violation]
43+
pub struct ModifiedIteratingSet {
44+
name: String,
45+
}
46+
47+
impl AlwaysFixableViolation for ModifiedIteratingSet {
48+
#[derive_message_formats]
49+
fn message(&self) -> String {
50+
let ModifiedIteratingSet { name } = self;
51+
format!("Iterated set `{name}` is modified within the `for` loop",)
52+
}
53+
54+
fn fix_title(&self) -> String {
55+
let ModifiedIteratingSet { name } = self;
56+
format!("Iterate over a copy of `{name}`")
57+
}
58+
}
59+
60+
/// PLE4703
61+
pub(crate) fn modified_iterating_set(checker: &mut Checker, for_stmt: &StmtFor) {
62+
let Some(name) = for_stmt.iter.as_name_expr() else {
63+
return;
64+
};
65+
66+
let Some(binding_id) = checker.semantic().only_binding(name) else {
67+
return;
68+
};
69+
if !is_set(checker.semantic().binding(binding_id), checker.semantic()) {
70+
return;
71+
}
72+
73+
let is_modified = any_over_body(&for_stmt.body, &|expr| {
74+
let Some(func) = expr.as_call_expr().map(|call| &call.func) else {
75+
return false;
76+
};
77+
78+
let Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = func.as_ref() else {
79+
return false;
80+
};
81+
82+
let Some(value) = value.as_name_expr() else {
83+
return false;
84+
};
85+
86+
let Some(value_id) = checker.semantic().only_binding(value) else {
87+
return false;
88+
};
89+
90+
binding_id == value_id && modifies_set(attr.as_str())
91+
});
92+
93+
if is_modified {
94+
let mut diagnostic = Diagnostic::new(
95+
ModifiedIteratingSet {
96+
name: name.id.clone(),
97+
},
98+
for_stmt.range(),
99+
);
100+
diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(
101+
format!("{}.copy()", checker.locator().slice(name)),
102+
name.range(),
103+
)));
104+
checker.diagnostics.push(diagnostic);
105+
}
106+
}
107+
108+
/// Returns `true` if the method modifies the set.
109+
fn modifies_set(identifier: &str) -> bool {
110+
matches!(identifier, "add" | "clear" | "discard" | "pop" | "remove")
111+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pylint/mod.rs
3+
---
4+
modified_iterating_set.py:4:1: PLE4703 [*] Iterated set `nums` is modified within the `for` loop
5+
|
6+
3 | nums = {1, 2, 3}
7+
4 | / for num in nums:
8+
5 | | nums.add(num + 1)
9+
| |_____________________^ PLE4703
10+
6 |
11+
7 | animals = {"dog", "cat", "cow"}
12+
|
13+
= help: Iterate over a copy of `nums`
14+
15+
Unsafe fix
16+
1 1 | # Errors
17+
2 2 |
18+
3 3 | nums = {1, 2, 3}
19+
4 |-for num in nums:
20+
4 |+for num in nums.copy():
21+
5 5 | nums.add(num + 1)
22+
6 6 |
23+
7 7 | animals = {"dog", "cat", "cow"}
24+
25+
modified_iterating_set.py:8:1: PLE4703 [*] Iterated set `animals` is modified within the `for` loop
26+
|
27+
7 | animals = {"dog", "cat", "cow"}
28+
8 | / for animal in animals:
29+
9 | | animals.pop("cow")
30+
| |______________________^ PLE4703
31+
10 |
32+
11 | fruits = {"apple", "orange", "grape"}
33+
|
34+
= help: Iterate over a copy of `animals`
35+
36+
Unsafe fix
37+
5 5 | nums.add(num + 1)
38+
6 6 |
39+
7 7 | animals = {"dog", "cat", "cow"}
40+
8 |-for animal in animals:
41+
8 |+for animal in animals.copy():
42+
9 9 | animals.pop("cow")
43+
10 10 |
44+
11 11 | fruits = {"apple", "orange", "grape"}
45+
46+
modified_iterating_set.py:12:1: PLE4703 [*] Iterated set `fruits` is modified within the `for` loop
47+
|
48+
11 | fruits = {"apple", "orange", "grape"}
49+
12 | / for fruit in fruits:
50+
13 | | fruits.clear()
51+
| |__________________^ PLE4703
52+
14 |
53+
15 | planets = {"mercury", "venus", "earth"}
54+
|
55+
= help: Iterate over a copy of `fruits`
56+
57+
Unsafe fix
58+
9 9 | animals.pop("cow")
59+
10 10 |
60+
11 11 | fruits = {"apple", "orange", "grape"}
61+
12 |-for fruit in fruits:
62+
12 |+for fruit in fruits.copy():
63+
13 13 | fruits.clear()
64+
14 14 |
65+
15 15 | planets = {"mercury", "venus", "earth"}
66+
67+
modified_iterating_set.py:16:1: PLE4703 [*] Iterated set `planets` is modified within the `for` loop
68+
|
69+
15 | planets = {"mercury", "venus", "earth"}
70+
16 | / for planet in planets:
71+
17 | | planets.discard("mercury")
72+
| |______________________________^ PLE4703
73+
18 |
74+
19 | colors = {"red", "green", "blue"}
75+
|
76+
= help: Iterate over a copy of `planets`
77+
78+
Unsafe fix
79+
13 13 | fruits.clear()
80+
14 14 |
81+
15 15 | planets = {"mercury", "venus", "earth"}
82+
16 |-for planet in planets:
83+
16 |+for planet in planets.copy():
84+
17 17 | planets.discard("mercury")
85+
18 18 |
86+
19 19 | colors = {"red", "green", "blue"}
87+
88+
modified_iterating_set.py:20:1: PLE4703 [*] Iterated set `colors` is modified within the `for` loop
89+
|
90+
19 | colors = {"red", "green", "blue"}
91+
20 | / for color in colors:
92+
21 | | colors.remove("red")
93+
| |________________________^ PLE4703
94+
22 |
95+
23 | odds = {1, 3, 5}
96+
|
97+
= help: Iterate over a copy of `colors`
98+
99+
Unsafe fix
100+
17 17 | planets.discard("mercury")
101+
18 18 |
102+
19 19 | colors = {"red", "green", "blue"}
103+
20 |-for color in colors:
104+
20 |+for color in colors.copy():
105+
21 21 | colors.remove("red")
106+
22 22 |
107+
23 23 | odds = {1, 3, 5}
108+
109+
modified_iterating_set.py:24:1: PLE4703 [*] Iterated set `odds` is modified within the `for` loop
110+
|
111+
23 | odds = {1, 3, 5}
112+
24 | / for num in odds:
113+
25 | | if num > 1:
114+
26 | | odds.add(num + 1)
115+
| |_________________________^ PLE4703
116+
27 |
117+
28 | # OK
118+
|
119+
= help: Iterate over a copy of `odds`
120+
121+
Unsafe fix
122+
21 21 | colors.remove("red")
123+
22 22 |
124+
23 23 | odds = {1, 3, 5}
125+
24 |-for num in odds:
126+
24 |+for num in odds.copy():
127+
25 25 | if num > 1:
128+
26 26 | odds.add(num + 1)
129+
27 27 |

ruff.schema.json

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

0 commit comments

Comments
 (0)