Fix RUF027 handling for f-string backslashes in Python 3.11

Updates the MissingFStringSyntax rule to correctly skip diagnostics for f-string interpolations containing backslashes when targeting Python 3.11, matching Python's behavior. Adds a dedicated test and snapshot to verify the change.
This commit is contained in:
Dan 2025-10-27 18:15:24 -04:00
parent 4b4447664b
commit 95efd8c4dc
3 changed files with 323 additions and 4 deletions

View File

@ -126,6 +126,21 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn missing_fstring_syntax_backslash_py311() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF027_0.py"),
&LinterSettings {
unresolved_target_version: PythonVersion::PY311.into(),
..LinterSettings::for_rule(Rule::MissingFStringSyntax)
},
)?;
// With Python 3.11, backslashes in interpolations should NOT trigger RUF027
// (only the backslash_test function should be skipped)
assert_diagnostics!(diagnostics);
Ok(())
}
#[test] #[test]
fn prefer_parentheses_getitem_tuple() -> Result<()> { fn prefer_parentheses_getitem_tuple() -> Result<()> {
let diagnostics = test_path( let diagnostics = test_path(

View File

@ -116,7 +116,12 @@ pub(crate) fn missing_fstring_syntax(checker: &Checker, literal: &ast::StringLit
return; return;
} }
if should_be_fstring(literal, checker.locator(), semantic, checker) { if should_be_fstring(
literal,
checker.locator(),
semantic,
checker.target_version(),
) {
checker checker
.report_diagnostic(MissingFStringSyntax, literal.range()) .report_diagnostic(MissingFStringSyntax, literal.range())
.set_fix(fix_fstring_syntax(literal.range())); .set_fix(fix_fstring_syntax(literal.range()));
@ -180,7 +185,7 @@ fn should_be_fstring(
literal: &ast::StringLiteral, literal: &ast::StringLiteral,
locator: &Locator, locator: &Locator,
semantic: &SemanticModel, semantic: &SemanticModel,
checker: &Checker, target_version: PythonVersion,
) -> bool { ) -> bool {
if !has_brackets(&literal.value) { if !has_brackets(&literal.value) {
return false; return false;
@ -220,8 +225,7 @@ fn should_be_fstring(
// Check if the interpolation expression contains backslashes // Check if the interpolation expression contains backslashes
// F-strings with backslashes in interpolations are only valid in Python 3.12+ // F-strings with backslashes in interpolations are only valid in Python 3.12+
let interpolation_text = &fstring_expr[element.range()]; let interpolation_text = &fstring_expr[element.range()];
if interpolation_text.contains('\\') && checker.target_version() < PythonVersion::PY312 if interpolation_text.contains('\\') && target_version < PythonVersion::PY312 {
{
return false; return false;
} }

View File

@ -0,0 +1,300 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:5:7
|
3 | "always ignore this: {val}"
4 |
5 | print("but don't ignore this: {val}") # RUF027
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Add `f` prefix
2 |
3 | "always ignore this: {val}"
4 |
- print("but don't ignore this: {val}") # RUF027
5 + print(f"but don't ignore this: {val}") # RUF027
6 |
7 |
8 | def simple_cases():
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:10:9
|
8 | def simple_cases():
9 | a = 4
10 | b = "{a}" # RUF027
| ^^^^^
11 | c = "{a} {b} f'{val}' " # RUF027
|
help: Add `f` prefix
7 |
8 | def simple_cases():
9 | a = 4
- b = "{a}" # RUF027
10 + b = f"{a}" # RUF027
11 | c = "{a} {b} f'{val}' " # RUF027
12 |
13 |
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:11:9
|
9 | a = 4
10 | b = "{a}" # RUF027
11 | c = "{a} {b} f'{val}' " # RUF027
| ^^^^^^^^^^^^^^^^^^^
|
help: Add `f` prefix
8 | def simple_cases():
9 | a = 4
10 | b = "{a}" # RUF027
- c = "{a} {b} f'{val}' " # RUF027
11 + c = f"{a} {b} f'{val}' " # RUF027
12 |
13 |
14 | def escaped_string():
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:21:9
|
19 | def raw_string():
20 | a = 4
21 | b = r"raw string with formatting: {a}" # RUF027
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
22 | c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027
|
help: Add `f` prefix
18 |
19 | def raw_string():
20 | a = 4
- b = r"raw string with formatting: {a}" # RUF027
21 + b = fr"raw string with formatting: {a}" # RUF027
22 | c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027
23 |
24 |
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:22:9
|
20 | a = 4
21 | b = r"raw string with formatting: {a}" # RUF027
22 | c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Add `f` prefix
19 | def raw_string():
20 | a = 4
21 | b = r"raw string with formatting: {a}" # RUF027
- c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027
22 + c = fr"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027
23 |
24 |
25 | def print_name(name: str):
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:27:11
|
25 | def print_name(name: str):
26 | a = 4
27 | print("Hello, {name}!") # RUF027
| ^^^^^^^^^^^^^^^^
28 | print("The test value we're using today is {a}") # RUF027
|
help: Add `f` prefix
24 |
25 | def print_name(name: str):
26 | a = 4
- print("Hello, {name}!") # RUF027
27 + print(f"Hello, {name}!") # RUF027
28 | print("The test value we're using today is {a}") # RUF027
29 |
30 |
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:28:11
|
26 | a = 4
27 | print("Hello, {name}!") # RUF027
28 | print("The test value we're using today is {a}") # RUF027
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Add `f` prefix
25 | def print_name(name: str):
26 | a = 4
27 | print("Hello, {name}!") # RUF027
- print("The test value we're using today is {a}") # RUF027
28 + print(f"The test value we're using today is {a}") # RUF027
29 |
30 |
31 | def nested_funcs():
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:33:33
|
31 | def nested_funcs():
32 | a = 4
33 | print(do_nothing(do_nothing("{a}"))) # RUF027
| ^^^^^
|
help: Add `f` prefix
30 |
31 | def nested_funcs():
32 | a = 4
- print(do_nothing(do_nothing("{a}"))) # RUF027
33 + print(do_nothing(do_nothing(f"{a}"))) # RUF027
34 |
35 |
36 | def tripled_quoted():
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:39:19
|
37 | a = 4
38 | c = a
39 | single_line = """ {a} """ # RUF027
| ^^^^^^^^^^^
40 | # RUF027
41 | multi_line = a = """b { # comment
|
help: Add `f` prefix
36 | def tripled_quoted():
37 | a = 4
38 | c = a
- single_line = """ {a} """ # RUF027
39 + single_line = f""" {a} """ # RUF027
40 | # RUF027
41 | multi_line = a = """b { # comment
42 | c} d
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:41:22
|
39 | single_line = """ {a} """ # RUF027
40 | # RUF027
41 | multi_line = a = """b { # comment
| ______________________^
42 | | c} d
43 | | """
| |_______^
|
help: Add `f` prefix
38 | c = a
39 | single_line = """ {a} """ # RUF027
40 | # RUF027
- multi_line = a = """b { # comment
41 + multi_line = a = f"""b { # comment
42 | c} d
43 | """
44 |
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:56:9
|
54 | def implicit_concat():
55 | a = 4
56 | b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only
| ^^^^^
57 | print(f"{a}" "{a}" f"{b}") # RUF027
|
help: Add `f` prefix
53 |
54 | def implicit_concat():
55 | a = 4
- b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only
56 + b = f"{a}" "+" "{b}" r" \\ " # RUF027 for the first part only
57 | print(f"{a}" "{a}" f"{b}") # RUF027
58 |
59 |
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:57:18
|
55 | a = 4
56 | b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only
57 | print(f"{a}" "{a}" f"{b}") # RUF027
| ^^^^^
|
help: Add `f` prefix
54 | def implicit_concat():
55 | a = 4
56 | b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only
- print(f"{a}" "{a}" f"{b}") # RUF027
57 + print(f"{a}" f"{a}" f"{b}") # RUF027
58 |
59 |
60 | def escaped_chars():
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:62:9
|
60 | def escaped_chars():
61 | a = 4
62 | b = "\"not escaped:\" '{a}' \"escaped:\": '{{c}}'" # RUF027
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Add `f` prefix
59 |
60 | def escaped_chars():
61 | a = 4
- b = "\"not escaped:\" '{a}' \"escaped:\": '{{c}}'" # RUF027
62 + b = f"\"not escaped:\" '{a}' \"escaped:\": '{{c}}'" # RUF027
63 |
64 |
65 | def method_calls():
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:70:18
|
68 | first = "Wendy"
69 | last = "Appleseed"
70 | value.method("{first} {last}") # RUF027
| ^^^^^^^^^^^^^^^^
71 |
72 | def format_specifiers():
|
help: Add `f` prefix
67 | value.method = print_name
68 | first = "Wendy"
69 | last = "Appleseed"
- value.method("{first} {last}") # RUF027
70 + value.method(f"{first} {last}") # RUF027
71 |
72 | def format_specifiers():
73 | a = 4
note: This is an unsafe fix and may change runtime behavior
RUF027 [*] Possible f-string without an `f` prefix
--> RUF027_0.py:74:9
|
72 | def format_specifiers():
73 | a = 4
74 | b = "{a:b} {a:^5}"
| ^^^^^^^^^^^^^^
75 |
76 | # fstrings are never correct as type definitions
|
help: Add `f` prefix
71 |
72 | def format_specifiers():
73 | a = 4
- b = "{a:b} {a:^5}"
74 + b = f"{a:b} {a:^5}"
75 |
76 | # fstrings are never correct as type definitions
77 | # so we should always skip those
note: This is an unsafe fix and may change runtime behavior