Skip to content

Commit f42a233

Browse files
adityasingh2400MichaReiser
authored andcommitted
[flake8-simplify] Preserve f-string source verbatim in SIM101 fix (astral-sh#25061)
Co-authored-by: Micha Reiser <micha@reiser.io>
1 parent abfdde3 commit f42a233

3 files changed

Lines changed: 262 additions & 66 deletions

File tree

crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM101.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,21 @@ def isinstance(a, b):
5252
# Regression test for: https://github.com/astral-sh/ruff/issues/7455#issuecomment-1722460483
5353
if(isinstance(a, int)) or (isinstance(a, float)):
5454
pass
55+
56+
# Regression test for: https://github.com/astral-sh/ruff/issues/19601
57+
# The fix must preserve the target's source verbatim — re-rendering through
58+
# the AST mangles f-strings whose format spec contains escape sequences or
59+
# whose interpolations include lambdas.
60+
isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
61+
isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
62+
isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
63+
isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
64+
65+
# Regression test for: https://github.com/astral-sh/ruff/pull/25061
66+
types = (int,)
67+
isinstance(x, (*types,)) or isinstance(x, ())
68+
isinstance(x, (*types,)) or isinstance(x, (*types,))
69+
isinstance(x, ()) or isinstance(x, int)
70+
isinstance(x, ()) or isinstance(x, ())
71+
((isinstance(x, int)) or isinstance(x, str))
72+
isinstance(x, int) or (isinstance(x, str))

crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs

Lines changed: 42 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::collections::BTreeMap;
22
use std::iter;
33

4-
use itertools::Either::{Left, Right};
54
use itertools::Itertools;
65
use ruff_python_ast::{self as ast, Arguments, BoolOp, CmpOp, Expr, ExprContext, UnaryOp};
76
use ruff_text_size::{Ranged, TextRange};
@@ -394,86 +393,63 @@ pub(crate) fn duplicate_isinstance_call(checker: &Checker, expr: &Expr) {
394393
expr.range(),
395394
);
396395
if !contains_effect(target, |id| checker.semantic().has_builtin_binding(id)) {
397-
// Grab the types used in each duplicate `isinstance` call (e.g., `int` and `str`
398-
// in `isinstance(obj, int) or isinstance(obj, str)`).
399-
let types: Vec<&Expr> = indices
396+
// Flatten the type expressions from each duplicate `isinstance` call into the
397+
// elements they would contribute to the merged tuple. Tuple operands splice
398+
// their elements; everything else contributes itself.
399+
let flattened: Vec<&Expr> = indices
400400
.iter()
401-
.map(|index| &values[*index])
402-
.map(|expr| {
401+
.flat_map(|index| {
403402
let Expr::Call(ast::ExprCall {
404403
arguments: Arguments { args, .. },
405404
..
406-
}) = expr
405+
}) = &values[*index]
407406
else {
408407
unreachable!("Indices should only contain `isinstance` calls")
409408
};
410-
args.get(1).expect("`isinstance` should have two arguments")
409+
let value = args.get(1).expect("`isinstance` should have two arguments");
410+
if let Expr::Tuple(tuple) = value {
411+
&tuple.elts
412+
} else {
413+
std::slice::from_ref(value)
414+
}
411415
})
412416
.collect();
413417

414-
// Generate a single `isinstance` call.
415-
let tuple = ast::ExprTuple {
416-
// Flatten all the types used across the `isinstance` calls.
417-
elts: types
418-
.iter()
419-
.flat_map(|value| {
420-
if let Expr::Tuple(tuple) = value {
421-
Left(tuple.iter())
422-
} else {
423-
Right(iter::once(*value))
424-
}
425-
})
426-
.map(Clone::clone)
427-
.collect(),
428-
ctx: ExprContext::Load,
429-
range: TextRange::default(),
430-
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
431-
parenthesized: true,
432-
};
433-
let isinstance_call = ast::ExprCall {
434-
func: Box::new(
435-
ast::ExprName {
436-
id: Name::new_static("isinstance"),
437-
ctx: ExprContext::Load,
438-
range: TextRange::default(),
439-
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
440-
}
441-
.into(),
442-
),
443-
arguments: Arguments {
444-
args: Box::from([target.clone(), tuple.into()]),
445-
keywords: Box::from([]),
446-
range: TextRange::default(),
447-
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
448-
},
449-
range: TextRange::default(),
450-
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
418+
// Build the replacement by splicing source slices for the target and the
419+
// type expressions, rather than letting the generator re-render them.
420+
// The generator normalizes escape sequences and otherwise reformats
421+
// expressions, which can break f-strings that rely on specific spelling
422+
// (e.g. `\x7d` in a format spec) or change observable behavior.
423+
let locator = checker.locator();
424+
let target_src = locator.slice(target);
425+
let mut type_srcs = flattened.iter().map(|e| locator.slice(*e)).join(", ");
426+
if matches!(flattened.as_slice(), [single] if single.is_starred_expr()) {
427+
type_srcs.push(',');
451428
}
452-
.into();
453-
454-
// Generate the combined `BoolOp`.
429+
let combined = format!("isinstance({target_src}, ({type_srcs}))");
430+
431+
// Replace the consecutive duplicate calls (and the `or`s between them) with
432+
// the combined call. Extend the replacement to absorb any parentheses that
433+
// wrap the first or last operand within the `BoolOp`; otherwise those
434+
// parentheses would be left dangling against a merged operand that has its
435+
// own balance, producing invalid syntax (e.g. `((isinstance(x, int)) or
436+
// isinstance(x, str))`).
455437
let [first, .., last] = indices.as_slice() else {
456438
unreachable!("Indices should have at least two elements")
457439
};
458-
let before = values.iter().take(*first).cloned();
459-
let after = values.iter().skip(last + 1).cloned();
460-
let bool_op = ast::ExprBoolOp {
461-
op: BoolOp::Or,
462-
values: before
463-
.chain(iter::once(isinstance_call))
464-
.chain(after)
465-
.collect(),
466-
range: TextRange::default(),
467-
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
468-
}
469-
.into();
470-
let fixed_source = checker.generator().expr(&bool_op);
471-
472-
// Populate the `Fix`. Replace the _entire_ `BoolOp`. Note that if we have
473-
// multiple duplicates, the fixes will conflict.
440+
let tokens = checker.tokens();
441+
let first = &values[*first];
442+
let last = &values[*last];
443+
let start = parenthesized_range(first.into(), expr.into(), tokens)
444+
.unwrap_or(first.range())
445+
.start();
446+
let end = parenthesized_range(last.into(), expr.into(), tokens)
447+
.unwrap_or(last.range())
448+
.end();
449+
let replace_range = TextRange::new(start, end);
474450
diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(
475-
pad(fixed_source, expr.range(), checker.locator()),
476-
expr.range(),
451+
pad(combined, replace_range, locator),
452+
replace_range,
477453
)));
478454
}
479455
}

crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM101_SIM101.py.snap

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,206 @@ help: Merge `isinstance` calls for `a`
182182
- if(isinstance(a, int)) or (isinstance(a, float)):
183183
53 + if isinstance(a, (int, float)):
184184
54 | pass
185+
55 |
186+
56 | # Regression test for: https://github.com/astral-sh/ruff/issues/19601
187+
note: This is an unsafe fix and may change runtime behavior
188+
189+
SIM101 [*] Multiple `isinstance` calls for expression, merge into a single call
190+
--> SIM101.py:60:1
191+
|
192+
58 | # the AST mangles f-strings whose format spec contains escape sequences or
193+
59 | # whose interpolations include lambdas.
194+
60 | isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
195+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
196+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
197+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
198+
|
199+
help: Merge `isinstance` calls
200+
57 | # The fix must preserve the target's source verbatim — re-rendering through
201+
58 | # the AST mangles f-strings whose format spec contains escape sequences or
202+
59 | # whose interpolations include lambdas.
203+
- isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
204+
60 + isinstance(f"{(lambda: 0)}", (int, str))
205+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
206+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
207+
63 | isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
208+
note: This is an unsafe fix and may change runtime behavior
209+
210+
SIM101 [*] Multiple `isinstance` calls for expression, merge into a single call
211+
--> SIM101.py:61:1
212+
|
213+
59 | # whose interpolations include lambdas.
214+
60 | isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
215+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
216+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
217+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
218+
63 | isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
219+
|
220+
help: Merge `isinstance` calls
221+
58 | # the AST mangles f-strings whose format spec contains escape sequences or
222+
59 | # whose interpolations include lambdas.
223+
60 | isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
224+
- isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
225+
61 + isinstance(f"{0:{(lambda: 0)}}", (int, str))
226+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
227+
63 | isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
228+
64 |
229+
note: This is an unsafe fix and may change runtime behavior
230+
231+
SIM101 [*] Multiple `isinstance` calls for expression, merge into a single call
232+
--> SIM101.py:62:1
233+
|
234+
60 | isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
235+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
236+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
237+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
238+
63 | isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
239+
|
240+
help: Merge `isinstance` calls
241+
59 | # whose interpolations include lambdas.
242+
60 | isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
243+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
244+
- isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
245+
62 + isinstance(f"{0:\x22}", (int, str))
246+
63 | isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
247+
64 |
248+
65 | # Regression test for: https://github.com/astral-sh/ruff/pull/25061
249+
note: This is an unsafe fix and may change runtime behavior
250+
251+
SIM101 [*] Multiple `isinstance` calls for expression, merge into a single call
252+
--> SIM101.py:63:1
253+
|
254+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
255+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
256+
63 | isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
257+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
258+
64 |
259+
65 | # Regression test for: https://github.com/astral-sh/ruff/pull/25061
260+
|
261+
help: Merge `isinstance` calls
262+
60 | isinstance(f"{(lambda: 0)}", int) or isinstance(f"{(lambda: 0)}", str)
263+
61 | isinstance(f"{0:{(lambda: 0)}}", int) or isinstance(f"{0:{(lambda: 0)}}", str)
264+
62 | isinstance(f"{0:\x22}", int) or isinstance(f"{0:\x22}", str)
265+
- isinstance(f"{0:\x7b}", int) or isinstance(f"{0:\x7b}", str)
266+
63 + isinstance(f"{0:\x7b}", (int, str))
267+
64 |
268+
65 | # Regression test for: https://github.com/astral-sh/ruff/pull/25061
269+
66 | types = (int,)
270+
note: This is an unsafe fix and may change runtime behavior
271+
272+
SIM101 [*] Multiple `isinstance` calls for `x`, merge into a single call
273+
--> SIM101.py:67:1
274+
|
275+
65 | # Regression test for: https://github.com/astral-sh/ruff/pull/25061
276+
66 | types = (int,)
277+
67 | isinstance(x, (*types,)) or isinstance(x, ())
278+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
279+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
280+
69 | isinstance(x, ()) or isinstance(x, int)
281+
|
282+
help: Merge `isinstance` calls for `x`
283+
64 |
284+
65 | # Regression test for: https://github.com/astral-sh/ruff/pull/25061
285+
66 | types = (int,)
286+
- isinstance(x, (*types,)) or isinstance(x, ())
287+
67 + isinstance(x, (*types,))
288+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
289+
69 | isinstance(x, ()) or isinstance(x, int)
290+
70 | isinstance(x, ()) or isinstance(x, ())
291+
note: This is an unsafe fix and may change runtime behavior
292+
293+
SIM101 [*] Multiple `isinstance` calls for `x`, merge into a single call
294+
--> SIM101.py:68:1
295+
|
296+
66 | types = (int,)
297+
67 | isinstance(x, (*types,)) or isinstance(x, ())
298+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
299+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
300+
69 | isinstance(x, ()) or isinstance(x, int)
301+
70 | isinstance(x, ()) or isinstance(x, ())
302+
|
303+
help: Merge `isinstance` calls for `x`
304+
65 | # Regression test for: https://github.com/astral-sh/ruff/pull/25061
305+
66 | types = (int,)
306+
67 | isinstance(x, (*types,)) or isinstance(x, ())
307+
- isinstance(x, (*types,)) or isinstance(x, (*types,))
308+
68 + isinstance(x, (*types, *types))
309+
69 | isinstance(x, ()) or isinstance(x, int)
310+
70 | isinstance(x, ()) or isinstance(x, ())
311+
71 | ((isinstance(x, int)) or isinstance(x, str))
312+
note: This is an unsafe fix and may change runtime behavior
313+
314+
SIM101 [*] Multiple `isinstance` calls for `x`, merge into a single call
315+
--> SIM101.py:69:1
316+
|
317+
67 | isinstance(x, (*types,)) or isinstance(x, ())
318+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
319+
69 | isinstance(x, ()) or isinstance(x, int)
320+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
321+
70 | isinstance(x, ()) or isinstance(x, ())
322+
71 | ((isinstance(x, int)) or isinstance(x, str))
323+
|
324+
help: Merge `isinstance` calls for `x`
325+
66 | types = (int,)
326+
67 | isinstance(x, (*types,)) or isinstance(x, ())
327+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
328+
- isinstance(x, ()) or isinstance(x, int)
329+
69 + isinstance(x, (int))
330+
70 | isinstance(x, ()) or isinstance(x, ())
331+
71 | ((isinstance(x, int)) or isinstance(x, str))
332+
72 | isinstance(x, int) or (isinstance(x, str))
333+
note: This is an unsafe fix and may change runtime behavior
334+
335+
SIM101 [*] Multiple `isinstance` calls for `x`, merge into a single call
336+
--> SIM101.py:70:1
337+
|
338+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
339+
69 | isinstance(x, ()) or isinstance(x, int)
340+
70 | isinstance(x, ()) or isinstance(x, ())
341+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
342+
71 | ((isinstance(x, int)) or isinstance(x, str))
343+
72 | isinstance(x, int) or (isinstance(x, str))
344+
|
345+
help: Merge `isinstance` calls for `x`
346+
67 | isinstance(x, (*types,)) or isinstance(x, ())
347+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
348+
69 | isinstance(x, ()) or isinstance(x, int)
349+
- isinstance(x, ()) or isinstance(x, ())
350+
70 + isinstance(x, ())
351+
71 | ((isinstance(x, int)) or isinstance(x, str))
352+
72 | isinstance(x, int) or (isinstance(x, str))
353+
note: This is an unsafe fix and may change runtime behavior
354+
355+
SIM101 [*] Multiple `isinstance` calls for `x`, merge into a single call
356+
--> SIM101.py:71:2
357+
|
358+
69 | isinstance(x, ()) or isinstance(x, int)
359+
70 | isinstance(x, ()) or isinstance(x, ())
360+
71 | ((isinstance(x, int)) or isinstance(x, str))
361+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
362+
72 | isinstance(x, int) or (isinstance(x, str))
363+
|
364+
help: Merge `isinstance` calls for `x`
365+
68 | isinstance(x, (*types,)) or isinstance(x, (*types,))
366+
69 | isinstance(x, ()) or isinstance(x, int)
367+
70 | isinstance(x, ()) or isinstance(x, ())
368+
- ((isinstance(x, int)) or isinstance(x, str))
369+
71 + (isinstance(x, (int, str)))
370+
72 | isinstance(x, int) or (isinstance(x, str))
371+
note: This is an unsafe fix and may change runtime behavior
372+
373+
SIM101 [*] Multiple `isinstance` calls for `x`, merge into a single call
374+
--> SIM101.py:72:1
375+
|
376+
70 | isinstance(x, ()) or isinstance(x, ())
377+
71 | ((isinstance(x, int)) or isinstance(x, str))
378+
72 | isinstance(x, int) or (isinstance(x, str))
379+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
380+
|
381+
help: Merge `isinstance` calls for `x`
382+
69 | isinstance(x, ()) or isinstance(x, int)
383+
70 | isinstance(x, ()) or isinstance(x, ())
384+
71 | ((isinstance(x, int)) or isinstance(x, str))
385+
- isinstance(x, int) or (isinstance(x, str))
386+
72 + isinstance(x, (int, str))
185387
note: This is an unsafe fix and may change runtime behavior

0 commit comments

Comments
 (0)