Skip to content

Commit 0115fd3

Browse files
authored
Avoid reusing nested, interpolated quotes before Python 3.12 (#20930)
## Summary Fixes #20774 by tracking whether an `InterpolatedStringState` element is nested inside of another interpolated element. This feels like kind of a naive fix, so I'm welcome to other ideas. But it resolves the problem in the issue and clears up the syntax error in the black compatibility test, without affecting many other cases. The other affected case is actually interesting too because the [input](https://github.com/astral-sh/ruff/blob/96b156303b81c5114e8375a6ffd467fb638c3963/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py#L707) is invalid, but the previous quote selection fixed the invalid syntax: ```pycon Python 3.11.13 (main, Sep 2 2025, 14:20:25) [Clang 20.1.4 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> f'{1: abcd "{'aa'}" }' # input File "<stdin>", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' >>> f'{1: abcd "{"aa"}" }' # old output Traceback (most recent call last): File "<stdin>", line 1, in <module> ValueError: Invalid format specifier ' abcd "aa" ' for object of type 'int' >>> f'{1: abcd "{'aa'}" }' # new output File "<stdin>", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' ``` We now preserve the invalid syntax in the input. Unfortunately, this also seems to be another edge case I didn't consider in #20867 because we don't flag this as a syntax error after 0.14.1: <details><summary>Shell output</summary> <p> ``` > uvx [email protected] check --ignore ALL --target-version py311 - <<EOF f'{1: abcd "{'aa'}" }' EOF invalid-syntax: Cannot reuse outer quote character in f-strings on Python 3.11 (syntax was added in Python 3.12) --> -:1:14 | 1 | f'{1: abcd "{'aa'}" }' | ^ | Found 1 error. > uvx [email protected] check --ignore ALL --target-version py311 - <<EOF f'{1: abcd "{'aa'}" }' EOF All checks passed! > uvx [email protected] -m ast <<EOF f'{1: abcd "{'aa'}" }' EOF Traceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 1752, in <module> main() File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 1748, in main tree = parse(source, args.infile.name, args.mode, type_comments=args.no_type_comments) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 50, in parse return compile(source, filename, mode, flags, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<stdin>", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' ``` </p> </details> I assumed that was the same `ParseError` as the one caused by `f"{1:""}"`, but this is a nested interpolation inside of the format spec. ## Test Plan New test copied from the black compatibility test. I guess this is a duplicate now, I started working on this branch before the new black tests were imported, so I could delete the separate test in our fixtures if that's preferable.
1 parent cfbd42c commit 0115fd3

File tree

6 files changed

+52
-26
lines changed

6 files changed

+52
-26
lines changed

crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,3 +748,7 @@
748748

749749
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
750750
print(f"{ {}, 1, }")
751+
752+
753+
# The inner quotes should not be changed to double quotes before Python 3.12
754+
f"{f'''{'nested'} inner'''} outer"

crates/ruff_python_formatter/src/context.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ pub(crate) enum InterpolatedStringState {
144144
///
145145
/// The containing `FStringContext` is the surrounding f-string context.
146146
InsideInterpolatedElement(InterpolatedStringContext),
147+
/// The formatter is inside more than one nested f-string, such as in `nested` in:
148+
///
149+
/// ```py
150+
/// f"{f'''{'nested'} inner'''} outer"
151+
/// ```
152+
NestedInterpolatedElement(InterpolatedStringContext),
147153
/// The formatter is outside an f-string.
148154
#[default]
149155
Outside,
@@ -152,12 +158,18 @@ pub(crate) enum InterpolatedStringState {
152158
impl InterpolatedStringState {
153159
pub(crate) fn can_contain_line_breaks(self) -> Option<bool> {
154160
match self {
155-
InterpolatedStringState::InsideInterpolatedElement(context) => {
161+
InterpolatedStringState::InsideInterpolatedElement(context)
162+
| InterpolatedStringState::NestedInterpolatedElement(context) => {
156163
Some(context.is_multiline())
157164
}
158165
InterpolatedStringState::Outside => None,
159166
}
160167
}
168+
169+
/// Returns `true` if the interpolated string state is [`NestedInterpolatedElement`].
170+
pub(crate) fn is_nested(self) -> bool {
171+
matches!(self, Self::NestedInterpolatedElement(..))
172+
}
161173
}
162174

163175
/// The position of a top-level statement in the module.

crates/ruff_python_formatter/src/other/interpolated_string_element.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,16 @@ impl Format<PyFormatContext<'_>> for FormatInterpolatedElement<'_> {
181181

182182
let item = format_with(|f: &mut PyFormatter| {
183183
// Update the context to be inside the f-string expression element.
184-
let f = &mut WithInterpolatedStringState::new(
185-
InterpolatedStringState::InsideInterpolatedElement(self.context),
186-
f,
187-
);
184+
let state = match f.context().interpolated_string_state() {
185+
InterpolatedStringState::InsideInterpolatedElement(_)
186+
| InterpolatedStringState::NestedInterpolatedElement(_) => {
187+
InterpolatedStringState::NestedInterpolatedElement(self.context)
188+
}
189+
InterpolatedStringState::Outside => {
190+
InterpolatedStringState::InsideInterpolatedElement(self.context)
191+
}
192+
};
193+
let f = &mut WithInterpolatedStringState::new(state, f);
188194

189195
write!(f, [bracket_spacing, expression.format()])?;
190196

crates/ruff_python_formatter/src/string/normalize.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,15 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
4646
.unwrap_or(self.context.options().quote_style());
4747
let supports_pep_701 = self.context.options().target_version().supports_pep_701();
4848

49+
// Preserve the existing quote style for nested interpolations more than one layer deep, if
50+
// PEP 701 isn't supported.
51+
if !supports_pep_701 && self.context.interpolated_string_state().is_nested() {
52+
return QuoteStyle::Preserve;
53+
}
54+
4955
// For f-strings and t-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't.
50-
if let InterpolatedStringState::InsideInterpolatedElement(parent_context) =
56+
if let InterpolatedStringState::InsideInterpolatedElement(parent_context)
57+
| InterpolatedStringState::NestedInterpolatedElement(parent_context) =
5158
self.context.interpolated_string_state()
5259
{
5360
let parent_flags = parent_context.flags();

crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ but none started with prefix {parentdir_prefix}"
2828
f'{{NOT \'a\' "formatted" "value"}}'
2929
f"some f-string with {a} {few():.2f} {formatted.values!r}"
3030
-f'some f-string with {a} {few(""):.2f} {formatted.values!r}'
31-
-f"{f'''{'nested'} inner'''} outer"
31+
+f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
32+
f"{f'''{'nested'} inner'''} outer"
3233
-f"\"{f'{nested} inner'}\" outer"
3334
-f"space between opening braces: { {a for a in (1, 2, 3)}}"
3435
-f'Hello \'{tricky + "example"}\''
35-
+f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
36-
+f"{f'''{"nested"} inner'''} outer"
3736
+f'"{f"{nested} inner"}" outer'
3837
+f"space between opening braces: { {a for a in (1, 2, 3)} }"
3938
+f"Hello '{tricky + 'example'}'"
@@ -49,7 +48,7 @@ f"{{NOT a formatted value}}"
4948
f'{{NOT \'a\' "formatted" "value"}}'
5049
f"some f-string with {a} {few():.2f} {formatted.values!r}"
5150
f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
52-
f"{f'''{"nested"} inner'''} outer"
51+
f"{f'''{'nested'} inner'''} outer"
5352
f'"{f"{nested} inner"}" outer'
5453
f"space between opening braces: { {a for a in (1, 2, 3)} }"
5554
f"Hello '{tricky + 'example'}'"
@@ -72,17 +71,3 @@ f'Hello \'{tricky + "example"}\''
7271
f"Tried directories {str(rootdirs)} \
7372
but none started with prefix {parentdir_prefix}"
7473
```
75-
76-
## New Unsupported Syntax Errors
77-
78-
error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12)
79-
--> fstring.py:6:9
80-
|
81-
4 | f"some f-string with {a} {few():.2f} {formatted.values!r}"
82-
5 | f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
83-
6 | f"{f'''{"nested"} inner'''} outer"
84-
| ^
85-
7 | f'"{f"{nested} inner"}" outer'
86-
8 | f"space between opening braces: { {a for a in (1, 2, 3)} }"
87-
|
88-
warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors.

crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,10 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets
754754

755755
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
756756
print(f"{ {}, 1, }")
757+
758+
759+
# The inner quotes should not be changed to double quotes before Python 3.12
760+
f"{f'''{'nested'} inner'''} outer"
757761
```
758762
759763
## Outputs
@@ -1532,7 +1536,7 @@ f'{f"""other " """}'
15321536
f'{1: hy "user"}'
15331537
f'{1:hy "user"}'
15341538
f'{1: abcd "{1}" }'
1535-
f'{1: abcd "{"aa"}" }'
1539+
f'{1: abcd "{'aa'}" }'
15361540
f'{1=: "abcd {'aa'}}'
15371541
f"{x:a{z:hy \"user\"}} '''"
15381542

@@ -1581,6 +1585,10 @@ print(
15811585

15821586
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
15831587
print(f"{ {}, 1 }")
1588+
1589+
1590+
# The inner quotes should not be changed to double quotes before Python 3.12
1591+
f"{f'''{'nested'} inner'''} outer"
15841592
```
15851593
15861594
@@ -2359,7 +2367,7 @@ f'{f"""other " """}'
23592367
f'{1: hy "user"}'
23602368
f'{1:hy "user"}'
23612369
f'{1: abcd "{1}" }'
2362-
f'{1: abcd "{"aa"}" }'
2370+
f'{1: abcd "{'aa'}" }'
23632371
f'{1=: "abcd {'aa'}}'
23642372
f"{x:a{z:hy \"user\"}} '''"
23652373

@@ -2408,6 +2416,10 @@ print(
24082416

24092417
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
24102418
print(f"{ {}, 1 }")
2419+
2420+
2421+
# The inner quotes should not be changed to double quotes before Python 3.12
2422+
f"{f'''{'nested'} inner'''} outer"
24112423
```
24122424
24132425

0 commit comments

Comments
 (0)