Skip to content

Commit 96171f4

Browse files
authored
[ruff] Handle extra arguments to deque (RUF037) (#18614)
## Summary Fixes #18612 by: - Bailing out without a fix in the case of `*args`, which I don't think we can fix reliably - Using an `Edit::deletion` from `remove_argument` instead of an `Edit::range_replacement` in the presence of unrecognized keyword arguments I thought we could always switch to the `Edit::deletion` approach initially, but it caused problems when `maxlen` was passed positionally, which we didn't have any existing tests for. The replacement fix can easily delete comments, so I also marked the fix unsafe in these cases and updated the docs accordingly. ## Test Plan New test cases derived from the issue. ## Stabilization These are pretty significant changes, much like those to PYI059 in #18611 (and based a bit on the implementation there!), so I think it probably makes sense to un-stabilize this for the 0.12 release, but I'm open to other thoughts there.
1 parent 8123dab commit 96171f4

File tree

3 files changed

+212
-21
lines changed

3 files changed

+212
-21
lines changed

crates/ruff_linter/resources/test/fixtures/ruff/RUF037.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,35 @@ def f():
5959

6060
def f():
6161
x = 0 or(deque)([])
62+
63+
64+
# regression tests for https://github.com/astral-sh/ruff/issues/18612
65+
def f():
66+
deque([], *[10]) # RUF037 but no fix
67+
deque([], **{"maxlen": 10}) # RUF037
68+
deque([], foo=1) # RUF037
69+
70+
71+
# Somewhat related to the issue, both okay because we can't generally look
72+
# inside *args or **kwargs
73+
def f():
74+
deque(*([], 10)) # Ok
75+
deque(**{"iterable": [], "maxlen": 10}) # Ok
76+
77+
# The fix was actually always unsafe in the presence of comments. all of these
78+
# are deleted
79+
def f():
80+
deque( # a comment in deque, deleted
81+
[ # a comment _in_ the list, deleted
82+
], # a comment after the list, deleted
83+
maxlen=10, # a comment on maxlen, deleted
84+
) # only this is preserved
85+
86+
87+
# `maxlen` can also be passed positionally
88+
def f():
89+
deque([], 10)
90+
91+
92+
def f():
93+
deque([], iterable=[])

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

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
use ruff_diagnostics::{Applicability, Edit};
12
use ruff_macros::{ViolationMetadata, derive_message_formats};
23
use ruff_python_ast::parenthesize::parenthesized_range;
34
use ruff_python_ast::{self as ast, Expr};
45
use ruff_text_size::Ranged;
56

67
use crate::checkers::ast::Checker;
7-
use crate::{AlwaysFixableViolation, Edit, Fix};
8+
use crate::fix::edits::{Parentheses, remove_argument};
9+
use crate::{Fix, FixAvailability, Violation};
810

911
/// ## What it does
1012
/// Checks for usages of `collections.deque` that have an empty iterable as the first argument.
@@ -30,26 +32,37 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
3032
/// queue = deque(maxlen=10)
3133
/// ```
3234
///
35+
/// ## Fix safety
36+
///
37+
/// The fix is marked as unsafe whenever it would delete comments present in the `deque` call or if
38+
/// there are unrecognized arguments other than `iterable` and `maxlen`.
39+
///
40+
/// ## Fix availability
41+
///
42+
/// This rule's fix is unavailable if any starred arguments are present after the initial iterable.
43+
///
3344
/// ## References
3445
/// - [Python documentation: `collections.deque`](https://docs.python.org/3/library/collections.html#collections.deque)
3546
#[derive(ViolationMetadata)]
3647
pub(crate) struct UnnecessaryEmptyIterableWithinDequeCall {
3748
has_maxlen: bool,
3849
}
3950

40-
impl AlwaysFixableViolation for UnnecessaryEmptyIterableWithinDequeCall {
51+
impl Violation for UnnecessaryEmptyIterableWithinDequeCall {
52+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
53+
4154
#[derive_message_formats]
4255
fn message(&self) -> String {
4356
"Unnecessary empty iterable within a deque call".to_string()
4457
}
4558

46-
fn fix_title(&self) -> String {
59+
fn fix_title(&self) -> Option<String> {
4760
let title = if self.has_maxlen {
4861
"Replace with `deque(maxlen=...)`"
4962
} else {
5063
"Replace with `deque()`"
5164
};
52-
title.to_string()
65+
Some(title.to_string())
5366
}
5467
}
5568

@@ -66,13 +79,13 @@ pub(crate) fn unnecessary_literal_within_deque_call(checker: &Checker, deque: &a
6679
return;
6780
}
6881

69-
let Some(iterable) = arguments.find_argument_value("iterable", 0) else {
82+
let Some(iterable) = arguments.find_argument("iterable", 0) else {
7083
return;
7184
};
7285

7386
let maxlen = arguments.find_argument_value("maxlen", 1);
7487

75-
let is_empty_literal = match iterable {
88+
let is_empty_literal = match iterable.value() {
7689
Expr::Dict(dict) => dict.is_empty(),
7790
Expr::List(list) => list.is_empty(),
7891
Expr::Tuple(tuple) => tuple.is_empty(),
@@ -100,29 +113,61 @@ pub(crate) fn unnecessary_literal_within_deque_call(checker: &Checker, deque: &a
100113
deque.range,
101114
);
102115

103-
diagnostic.set_fix(fix_unnecessary_literal_in_deque(checker, deque, maxlen));
116+
// Return without a fix in the presence of a starred argument because we can't accurately
117+
// generate the fix. If all of the arguments are unpacked (e.g. `deque(*([], 10))`), we will
118+
// have already returned after the first `find_argument_value` call.
119+
if deque.arguments.args.iter().any(Expr::is_starred_expr) {
120+
return;
121+
}
122+
123+
diagnostic.try_set_fix(|| fix_unnecessary_literal_in_deque(checker, iterable, deque, maxlen));
104124
}
105125

106126
fn fix_unnecessary_literal_in_deque(
107127
checker: &Checker,
128+
iterable: ast::ArgOrKeyword,
108129
deque: &ast::ExprCall,
109130
maxlen: Option<&Expr>,
110-
) -> Fix {
111-
let deque_name = checker.locator().slice(
112-
parenthesized_range(
113-
deque.func.as_ref().into(),
114-
deque.into(),
131+
) -> anyhow::Result<Fix> {
132+
// if `maxlen` is `Some`, we know there were exactly two arguments, and we can replace the whole
133+
// call. otherwise, we only delete the `iterable` argument and leave the others untouched.
134+
let edit = if let Some(maxlen) = maxlen {
135+
let deque_name = checker.locator().slice(
136+
parenthesized_range(
137+
deque.func.as_ref().into(),
138+
deque.into(),
139+
checker.comment_ranges(),
140+
checker.source(),
141+
)
142+
.unwrap_or(deque.func.range()),
143+
);
144+
let len_str = checker.locator().slice(maxlen);
145+
let deque_str = format!("{deque_name}(maxlen={len_str})");
146+
Edit::range_replacement(deque_str, deque.range)
147+
} else {
148+
let range = parenthesized_range(
149+
iterable.value().into(),
150+
(&deque.arguments).into(),
115151
checker.comment_ranges(),
116152
checker.source(),
117153
)
118-
.unwrap_or(deque.func.range()),
119-
);
120-
let deque_str = match maxlen {
121-
Some(maxlen) => {
122-
let len_str = checker.locator().slice(maxlen);
123-
format!("{deque_name}(maxlen={len_str})")
124-
}
125-
None => format!("{deque_name}()"),
154+
.unwrap_or(iterable.range());
155+
remove_argument(
156+
&range,
157+
&deque.arguments,
158+
Parentheses::Preserve,
159+
checker.source(),
160+
)?
161+
};
162+
let has_comments = checker.comment_ranges().intersects(edit.range());
163+
// we've already checked maxlen.is_some() && args != 2 above, so this is the only problematic
164+
// case left
165+
let unknown_arguments = maxlen.is_none() && deque.arguments.len() != 1;
166+
let applicability = if has_comments || unknown_arguments {
167+
Applicability::Unsafe
168+
} else {
169+
Applicability::Safe
126170
};
127-
Fix::safe_edit(Edit::range_replacement(deque_str, deque.range))
171+
172+
Ok(Fix::applicable_edit(edit, applicability))
128173
}

crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF037_RUF037.py.snap

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,117 @@ RUF037.py:61:13: RUF037 [*] Unnecessary empty iterable within a deque call
141141
60 60 | def f():
142142
61 |- x = 0 or(deque)([])
143143
61 |+ x = 0 or(deque)()
144+
62 62 |
145+
63 63 |
146+
64 64 | # regression tests for https://github.com/astral-sh/ruff/issues/18612
147+
148+
RUF037.py:66:5: RUF037 Unnecessary empty iterable within a deque call
149+
|
150+
64 | # regression tests for https://github.com/astral-sh/ruff/issues/18612
151+
65 | def f():
152+
66 | deque([], *[10]) # RUF037 but no fix
153+
| ^^^^^^^^^^^^^^^^ RUF037
154+
67 | deque([], **{"maxlen": 10}) # RUF037
155+
68 | deque([], foo=1) # RUF037
156+
|
157+
= help: Replace with `deque()`
158+
159+
RUF037.py:67:5: RUF037 [*] Unnecessary empty iterable within a deque call
160+
|
161+
65 | def f():
162+
66 | deque([], *[10]) # RUF037 but no fix
163+
67 | deque([], **{"maxlen": 10}) # RUF037
164+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF037
165+
68 | deque([], foo=1) # RUF037
166+
|
167+
= help: Replace with `deque()`
168+
169+
Unsafe fix
170+
64 64 | # regression tests for https://github.com/astral-sh/ruff/issues/18612
171+
65 65 | def f():
172+
66 66 | deque([], *[10]) # RUF037 but no fix
173+
67 |- deque([], **{"maxlen": 10}) # RUF037
174+
67 |+ deque(**{"maxlen": 10}) # RUF037
175+
68 68 | deque([], foo=1) # RUF037
176+
69 69 |
177+
70 70 |
178+
179+
RUF037.py:68:5: RUF037 [*] Unnecessary empty iterable within a deque call
180+
|
181+
66 | deque([], *[10]) # RUF037 but no fix
182+
67 | deque([], **{"maxlen": 10}) # RUF037
183+
68 | deque([], foo=1) # RUF037
184+
| ^^^^^^^^^^^^^^^^ RUF037
185+
|
186+
= help: Replace with `deque()`
187+
188+
Unsafe fix
189+
65 65 | def f():
190+
66 66 | deque([], *[10]) # RUF037 but no fix
191+
67 67 | deque([], **{"maxlen": 10}) # RUF037
192+
68 |- deque([], foo=1) # RUF037
193+
68 |+ deque(foo=1) # RUF037
194+
69 69 |
195+
70 70 |
196+
71 71 | # Somewhat related to the issue, both okay because we can't generally look
197+
198+
RUF037.py:80:5: RUF037 [*] Unnecessary empty iterable within a deque call
199+
|
200+
78 | # are deleted
201+
79 | def f():
202+
80 | / deque( # a comment in deque, deleted
203+
81 | | [ # a comment _in_ the list, deleted
204+
82 | | ], # a comment after the list, deleted
205+
83 | | maxlen=10, # a comment on maxlen, deleted
206+
84 | | ) # only this is preserved
207+
| |_________^ RUF037
208+
|
209+
= help: Replace with `deque(maxlen=...)`
210+
211+
Unsafe fix
212+
77 77 | # The fix was actually always unsafe in the presence of comments. all of these
213+
78 78 | # are deleted
214+
79 79 | def f():
215+
80 |- deque( # a comment in deque, deleted
216+
81 |- [ # a comment _in_ the list, deleted
217+
82 |- ], # a comment after the list, deleted
218+
83 |- maxlen=10, # a comment on maxlen, deleted
219+
84 |- ) # only this is preserved
220+
80 |+ deque(maxlen=10) # only this is preserved
221+
85 81 |
222+
86 82 |
223+
87 83 | # `maxlen` can also be passed positionally
224+
225+
RUF037.py:89:5: RUF037 [*] Unnecessary empty iterable within a deque call
226+
|
227+
87 | # `maxlen` can also be passed positionally
228+
88 | def f():
229+
89 | deque([], 10)
230+
| ^^^^^^^^^^^^^ RUF037
231+
|
232+
= help: Replace with `deque(maxlen=...)`
233+
234+
Safe fix
235+
86 86 |
236+
87 87 | # `maxlen` can also be passed positionally
237+
88 88 | def f():
238+
89 |- deque([], 10)
239+
89 |+ deque(maxlen=10)
240+
90 90 |
241+
91 91 |
242+
92 92 | def f():
243+
244+
RUF037.py:93:5: RUF037 [*] Unnecessary empty iterable within a deque call
245+
|
246+
92 | def f():
247+
93 | deque([], iterable=[])
248+
| ^^^^^^^^^^^^^^^^^^^^^^ RUF037
249+
|
250+
= help: Replace with `deque()`
251+
252+
Unsafe fix
253+
90 90 |
254+
91 91 |
255+
92 92 | def f():
256+
93 |- deque([], iterable=[])
257+
93 |+ deque([])

0 commit comments

Comments
 (0)