Skip to content

Commit 50e22cb

Browse files
committed
Use starred unpacking for RUF017 in Python 3.15+
1 parent 8bc437e commit 50e22cb

4 files changed

Lines changed: 232 additions & 17 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,19 @@ mod tests {
245245
assert_diagnostics!("PY313_RUF036_runtime_evaluated", diagnostics);
246246
}
247247

248+
#[test]
249+
fn quadratic_list_summation_py315() -> Result<()> {
250+
let diagnostics = test_path(
251+
Path::new("ruff/RUF017_0.py"),
252+
&settings::LinterSettings {
253+
unresolved_target_version: PythonVersion::PY315.into(),
254+
..settings::LinterSettings::for_rule(Rule::QuadraticListSummation)
255+
},
256+
)?;
257+
assert_diagnostics!("PY315_RUF017_RUF017_0.py", diagnostics);
258+
Ok(())
259+
}
260+
248261
#[test]
249262
fn access_annotations_from_class_dict_py310() -> Result<()> {
250263
let diagnostics = test_path(

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

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use itertools::Itertools;
33

44
use ruff_macros::{ViolationMetadata, derive_message_formats};
55
use ruff_python_ast::token::parenthesized_range;
6-
use ruff_python_ast::{self as ast, Arguments, Expr};
6+
use ruff_python_ast::{self as ast, Arguments, Expr, PythonVersion};
77
use ruff_python_semantic::SemanticModel;
88
use ruff_text_size::Ranged;
99

@@ -23,12 +23,15 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
2323
/// quadratic complexity. The following methods are all linear in the number of
2424
/// lists:
2525
///
26+
/// - `[*sublist for sublist in lists]` (Python 3.15+)
2627
/// - `functools.reduce(operator.iadd, lists, [])`
2728
/// - `list(itertools.chain.from_iterable(lists))`
2829
/// - `[item for sublist in lists for item in sublist]`
2930
///
30-
/// When fixing relevant violations, Ruff defaults to the `functools.reduce`
31-
/// form, which outperforms the other methods in [microbenchmarks].
31+
/// When fixing relevant violations, Ruff uses the starred-list-comprehension
32+
/// form on Python 3.15 and later. On older Python versions, Ruff falls back to
33+
/// the `functools.reduce` form, which outperforms the other pre-3.15
34+
/// alternatives in [microbenchmarks].
3235
///
3336
/// ## Example
3437
/// ```python
@@ -38,21 +41,18 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
3841
///
3942
/// Use instead:
4043
/// ```python
41-
/// import functools
42-
/// import operator
43-
///
44-
///
4544
/// lists = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
46-
/// functools.reduce(operator.iadd, lists, [])
45+
/// joined = [*sublist for sublist in lists]
4746
/// ```
4847
///
4948
/// ## Fix safety
5049
///
51-
/// This fix is always marked as unsafe because `sum` uses the `__add__` magic method while
52-
/// `operator.iadd` uses the `__iadd__` magic method, and these behave differently on lists.
53-
/// The former requires the right summand to be a list, whereas the latter allows for any iterable.
54-
/// Therefore, the fix could inadvertently cause code that previously raised an error to silently
55-
/// succeed. Moreover, the fix could remove comments from the original code.
50+
/// This fix is always marked as unsafe because the replacement may accept any
51+
/// iterable where `sum` previously required lists. On Python 3.15 and later,
52+
/// Ruff uses iterable unpacking within a list comprehension; on older Python
53+
/// versions, Ruff uses `operator.iadd`. In both cases, code that previously
54+
/// raised an error may silently succeed. Moreover, the fix could remove
55+
/// comments from the original code.
5656
///
5757
/// ## References
5858
/// - [_How Not to Flatten a List of Lists in Python_](https://mathieularose.com/how-not-to-flatten-a-list-of-lists-in-python)
@@ -61,7 +61,32 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
6161
/// [microbenchmarks]: https://github.com/astral-sh/ruff/issues/5073#issuecomment-1591836349
6262
#[derive(ViolationMetadata)]
6363
#[violation_metadata(stable_since = "v0.0.285")]
64-
pub(crate) struct QuadraticListSummation;
64+
pub(crate) struct QuadraticListSummation {
65+
fix_style: QuadraticListSummationFixStyle,
66+
}
67+
68+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69+
enum QuadraticListSummationFixStyle {
70+
FunctoolsReduce,
71+
StarredListComprehension,
72+
}
73+
74+
impl QuadraticListSummationFixStyle {
75+
fn from_target_version(target_version: PythonVersion) -> Self {
76+
if target_version >= PythonVersion::PY315 {
77+
Self::StarredListComprehension
78+
} else {
79+
Self::FunctoolsReduce
80+
}
81+
}
82+
83+
const fn title(self) -> &'static str {
84+
match self {
85+
Self::FunctoolsReduce => "Replace with `functools.reduce`",
86+
Self::StarredListComprehension => "Replace with a starred list comprehension",
87+
}
88+
}
89+
}
6590

6691
impl AlwaysFixableViolation for QuadraticListSummation {
6792
#[derive_message_formats]
@@ -70,7 +95,7 @@ impl AlwaysFixableViolation for QuadraticListSummation {
7095
}
7196

7297
fn fix_title(&self) -> String {
73-
"Replace with `functools.reduce`".to_string()
98+
self.fix_style.title().to_string()
7499
}
75100
}
76101

@@ -97,8 +122,41 @@ pub(crate) fn quadratic_list_summation(checker: &Checker, call: &ast::ExprCall)
97122
return;
98123
}
99124

100-
let mut diagnostic = checker.report_diagnostic(QuadraticListSummation, *range);
101-
diagnostic.try_set_fix(|| convert_to_reduce(iterable, call, checker));
125+
let fix_style = QuadraticListSummationFixStyle::from_target_version(checker.target_version());
126+
let mut diagnostic = checker.report_diagnostic(QuadraticListSummation { fix_style }, *range);
127+
diagnostic.try_set_fix(|| convert_to_fix(iterable, call, checker, fix_style));
128+
}
129+
130+
fn convert_to_fix(
131+
iterable: &Expr,
132+
call: &ast::ExprCall,
133+
checker: &Checker,
134+
fix_style: QuadraticListSummationFixStyle,
135+
) -> Result<Fix> {
136+
match fix_style {
137+
QuadraticListSummationFixStyle::FunctoolsReduce => {
138+
convert_to_reduce(iterable, call, checker)
139+
}
140+
QuadraticListSummationFixStyle::StarredListComprehension => Ok(
141+
convert_to_starred_list_comprehension(iterable, call, checker),
142+
),
143+
}
144+
}
145+
146+
fn convert_to_starred_list_comprehension(
147+
iterable: &Expr,
148+
call: &ast::ExprCall,
149+
checker: &Checker,
150+
) -> Fix {
151+
let iterable = checker.locator().slice(
152+
parenthesized_range(iterable.into(), (&call.arguments).into(), checker.tokens())
153+
.unwrap_or(iterable.range()),
154+
);
155+
156+
Fix::unsafe_edit(Edit::range_replacement(
157+
format!("[*sublist for sublist in {iterable}]"),
158+
call.range(),
159+
))
102160
}
103161

104162
/// Generate a [`Fix`] to convert a `sum()` call to a `functools.reduce()` call.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF017 [*] Avoid quadratic list summation
5+
--> RUF017_0.py:5:1
6+
|
7+
4 | # RUF017
8+
5 | sum([x, y], start=[])
9+
| ^^^^^^^^^^^^^^^^^^^^^
10+
6 | sum([x, y], [])
11+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
12+
|
13+
help: Replace with a starred list comprehension
14+
2 | y = [4, 5, 6]
15+
3 |
16+
4 | # RUF017
17+
- sum([x, y], start=[])
18+
5 + [*sublist for sublist in [x, y]]
19+
6 | sum([x, y], [])
20+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
21+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
22+
note: This is an unsafe fix and may change runtime behavior
23+
24+
RUF017 [*] Avoid quadratic list summation
25+
--> RUF017_0.py:6:1
26+
|
27+
4 | # RUF017
28+
5 | sum([x, y], start=[])
29+
6 | sum([x, y], [])
30+
| ^^^^^^^^^^^^^^^
31+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
32+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
33+
|
34+
help: Replace with a starred list comprehension
35+
3 |
36+
4 | # RUF017
37+
5 | sum([x, y], start=[])
38+
- sum([x, y], [])
39+
6 + [*sublist for sublist in [x, y]]
40+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
41+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
42+
9 | sum([[1, 2, 3], [4, 5, 6]],
43+
note: This is an unsafe fix and may change runtime behavior
44+
45+
RUF017 [*] Avoid quadratic list summation
46+
--> RUF017_0.py:7:1
47+
|
48+
5 | sum([x, y], start=[])
49+
6 | sum([x, y], [])
50+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
51+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
52+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
53+
9 | sum([[1, 2, 3], [4, 5, 6]],
54+
|
55+
help: Replace with a starred list comprehension
56+
4 | # RUF017
57+
5 | sum([x, y], start=[])
58+
6 | sum([x, y], [])
59+
- sum([[1, 2, 3], [4, 5, 6]], start=[])
60+
7 + [*sublist for sublist in [[1, 2, 3], [4, 5, 6]]]
61+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
62+
9 | sum([[1, 2, 3], [4, 5, 6]],
63+
10 | [])
64+
note: This is an unsafe fix and may change runtime behavior
65+
66+
RUF017 [*] Avoid quadratic list summation
67+
--> RUF017_0.py:8:1
68+
|
69+
6 | sum([x, y], [])
70+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
71+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
72+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
73+
9 | sum([[1, 2, 3], [4, 5, 6]],
74+
10 | [])
75+
|
76+
help: Replace with a starred list comprehension
77+
5 | sum([x, y], start=[])
78+
6 | sum([x, y], [])
79+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
80+
- sum([[1, 2, 3], [4, 5, 6]], [])
81+
8 + [*sublist for sublist in [[1, 2, 3], [4, 5, 6]]]
82+
9 | sum([[1, 2, 3], [4, 5, 6]],
83+
10 | [])
84+
11 |
85+
note: This is an unsafe fix and may change runtime behavior
86+
87+
RUF017 [*] Avoid quadratic list summation
88+
--> RUF017_0.py:9:1
89+
|
90+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
91+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
92+
9 | / sum([[1, 2, 3], [4, 5, 6]],
93+
10 | | [])
94+
| |_______^
95+
11 |
96+
12 | # OK
97+
|
98+
help: Replace with a starred list comprehension
99+
6 | sum([x, y], [])
100+
7 | sum([[1, 2, 3], [4, 5, 6]], start=[])
101+
8 | sum([[1, 2, 3], [4, 5, 6]], [])
102+
- sum([[1, 2, 3], [4, 5, 6]],
103+
- [])
104+
9 + [*sublist for sublist in [[1, 2, 3], [4, 5, 6]]]
105+
10 |
106+
11 | # OK
107+
12 | sum([x, y])
108+
note: This is an unsafe fix and may change runtime behavior
109+
110+
RUF017 [*] Avoid quadratic list summation
111+
--> RUF017_0.py:21:5
112+
|
113+
19 | import functools, operator
114+
20 |
115+
21 | sum([x, y], [])
116+
| ^^^^^^^^^^^^^^^
117+
|
118+
help: Replace with a starred list comprehension
119+
18 | def func():
120+
19 | import functools, operator
121+
20 |
122+
- sum([x, y], [])
123+
21 + [*sublist for sublist in [x, y]]
124+
22 |
125+
23 |
126+
24 | # Regression test for: https://github.com/astral-sh/ruff/issues/7718
127+
note: This is an unsafe fix and may change runtime behavior
128+
129+
RUF017 [*] Avoid quadratic list summation
130+
--> RUF017_0.py:26:5
131+
|
132+
24 | # Regression test for: https://github.com/astral-sh/ruff/issues/7718
133+
25 | def func():
134+
26 | sum((factor.dims for factor in bases), [])
135+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
136+
|
137+
help: Replace with a starred list comprehension
138+
23 |
139+
24 | # Regression test for: https://github.com/astral-sh/ruff/issues/7718
140+
25 | def func():
141+
- sum((factor.dims for factor in bases), [])
142+
26 + [*sublist for sublist in (factor.dims for factor in bases)]
143+
note: This is an unsafe fix and may change runtime behavior

scripts/check_docs_formatted.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
"mixed-spaces-and-tabs",
118118
"no-indented-block",
119119
"non-pep695-type-alias", # requires Python 3.12
120+
"quadratic-list-summation", # requires Python 3.15
120121
"syntax-error",
121122
"tab-after-comma",
122123
"tab-after-keyword",

0 commit comments

Comments
 (0)