Skip to content

Commit 87a0f01

Browse files
[ruff] Treat f-string interpolation as potential side effect in RUF019 (#24426)
<!-- Thank you for contributing to Ruff/ty! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? (Please prefix with `[ty]` for ty pull requests.) - Does this pull request include references to any relevant issues? - Does this PR follow our AI policy (https://github.com/astral-sh/.github/blob/main/AI_POLICY.md)? --> ## Summary Fixes #12953 F-string interpolation can call `__format__`/`__str__`/`__repr__`, which may have side effects. RUF019 was applying a safe auto-fix that collapsed two `__str__` calls into one, changing behavior. Added a tri-state `SideEffect` enum (`No`/`Maybe`/`Yes`) and a `side_effect()` function that reuses the existing `any_over_expr` traversal via a new `FnMut` variant (`any_over_expr_mut`), following the approach suggested by @ntBre in the other closed pr tagged in the issue In RUF019, `SideEffect::Maybe` (non-literal f-string interpolation) now produces an unsafe fix instead of a safe one. Literal interpolations like `f"{1}"` remain safe. ## Test Plan - Added f-string fixture cases to `RUF019.py` (non-literal → unsafe, literal → safe, no interpolation → safe). - `cargo nextest run -p ruff_linter` - Ecosystem check (stable + preview)
1 parent e9ba848 commit 87a0f01

4 files changed

Lines changed: 291 additions & 84 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,26 @@
3030
d ["key"]
3131
):
3232
...
33+
34+
# https://github.com/astral-sh/ruff/issues/12953
35+
# F-string with non-literal interpolation — unsafe fix (may invoke __str__)
36+
class Formatter:
37+
def __str__(self):
38+
print("side effect!")
39+
return "key"
40+
41+
c = Formatter()
42+
if f"{c}" in d and d[f"{c}"]:
43+
pass
44+
45+
# F-string with only literal interpolation — safe fix
46+
if f"{1}" in d and d[f"{1}"]:
47+
pass
48+
49+
# Plain f-string without interpolation — safe fix
50+
if f"key" in d and d[f"key"]:
51+
pass
52+
53+
# Walrus operator is a side effect — should not emit
54+
if (k := "key") in d and d[(k := "key")]:
55+
pass

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use ruff_python_ast::comparable::ComparableExpr;
33
use ruff_python_ast::{self as ast, BoolOp, CmpOp, Expr};
44

55
use ruff_macros::{ViolationMetadata, derive_message_formats};
6-
use ruff_python_ast::helpers::contains_effect;
6+
use ruff_python_ast::helpers::side_effect;
77
use ruff_python_ast::token::parenthesized_range;
88
use ruff_text_size::Ranged;
99

@@ -31,7 +31,8 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
3131
/// ```
3232
///
3333
/// ## Fix safety
34-
/// This rule's fix is marked as safe, unless the expression contains comments.
34+
/// This rule's fix is marked as safe, unless the expression contains comments
35+
/// or may have side effects.
3536
#[derive(ViolationMetadata)]
3637
#[violation_metadata(stable_since = "v0.2.0")]
3738
pub(crate) struct UnnecessaryKeyCheck;
@@ -101,19 +102,21 @@ pub(crate) fn unnecessary_key_check(checker: &Checker, expr: &Expr) {
101102
return;
102103
}
103104

104-
if contains_effect(obj_left, |id| checker.semantic().has_builtin_binding(id))
105-
|| contains_effect(key_left, |id| checker.semantic().has_builtin_binding(id))
106-
{
105+
let obj_effect = side_effect(obj_left, |id| checker.semantic().has_builtin_binding(id));
106+
let key_effect = side_effect(key_left, |id| checker.semantic().has_builtin_binding(id));
107+
let combined = obj_effect.merge(key_effect);
108+
if combined.is_present() {
107109
return;
108110
}
109111

110112
let mut diagnostic = checker.report_diagnostic(UnnecessaryKeyCheck, expr.range());
111113

112-
let applicability = if checker.comment_ranges().intersects(expr.range()) {
113-
Applicability::Unsafe
114-
} else {
115-
Applicability::Safe
116-
};
114+
let applicability =
115+
if !combined.is_absent() || checker.comment_ranges().intersects(expr.range()) {
116+
Applicability::Unsafe
117+
} else {
118+
Applicability::Safe
119+
};
117120

118121
diagnostic.set_fix(Fix::applicable_edit(
119122
Edit::range_replacement(

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,60 @@ help: Replace with `dict.get`
136136
28 + d.get("key")
137137
29 | ):
138138
30 | ...
139+
31 |
139140
note: This is an unsafe fix and may change runtime behavior
141+
142+
RUF019 [*] Unnecessary key check before dictionary access
143+
--> RUF019.py:42:4
144+
|
145+
41 | c = Formatter()
146+
42 | if f"{c}" in d and d[f"{c}"]:
147+
| ^^^^^^^^^^^^^^^^^^^^^^^^^
148+
43 | pass
149+
|
150+
help: Replace with `dict.get`
151+
39 | return "key"
152+
40 |
153+
41 | c = Formatter()
154+
- if f"{c}" in d and d[f"{c}"]:
155+
42 + if d.get(f"{c}"):
156+
43 | pass
157+
44 |
158+
45 | # F-string with only literal interpolationsafe fix
159+
note: This is an unsafe fix and may change runtime behavior
160+
161+
RUF019 [*] Unnecessary key check before dictionary access
162+
--> RUF019.py:46:4
163+
|
164+
45 | # F-string with only literal interpolationsafe fix
165+
46 | if f"{1}" in d and d[f"{1}"]:
166+
| ^^^^^^^^^^^^^^^^^^^^^^^^^
167+
47 | pass
168+
|
169+
help: Replace with `dict.get`
170+
43 | pass
171+
44 |
172+
45 | # F-string with only literal interpolationsafe fix
173+
- if f"{1}" in d and d[f"{1}"]:
174+
46 + if d.get(f"{1}"):
175+
47 | pass
176+
48 |
177+
49 | # Plain f-string without interpolationsafe fix
178+
179+
RUF019 [*] Unnecessary key check before dictionary access
180+
--> RUF019.py:50:4
181+
|
182+
49 | # Plain f-string without interpolationsafe fix
183+
50 | if f"key" in d and d[f"key"]:
184+
| ^^^^^^^^^^^^^^^^^^^^^^^^^
185+
51 | pass
186+
|
187+
help: Replace with `dict.get`
188+
47 | pass
189+
48 |
190+
49 | # Plain f-string without interpolationsafe fix
191+
- if f"key" in d and d[f"key"]:
192+
50 + if d.get(f"key"):
193+
51 | pass
194+
52 |
195+
53 | # Walrus operator is a side effectshould not emit

0 commit comments

Comments
 (0)