From 0115fd37572d728f32ca97c0ea190fd8fd185916 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Fri, 17 Oct 2025 08:49:16 -0400 Subject: [PATCH] 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 "", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' >>> f'{1: abcd "{"aa"}" }' # old output Traceback (most recent call last): File "", line 1, in ValueError: Invalid format specifier ' abcd "aa" ' for object of type 'int' >>> f'{1: abcd "{'aa'}" }' # new output File "", 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 https://github.com/astral-sh/ruff/pull/20867 because we don't flag this as a syntax error after 0.14.1:
Shell output

``` > uvx ruff@0.14.0 check --ignore ALL --target-version py311 - < -:1:14 | 1 | f'{1: abcd "{'aa'}" }' | ^ | Found 1 error. > uvx ruff@0.14.1 check --ignore ALL --target-version py311 - < uvx python@3.11 -m ast <", line 198, in _run_module_as_main File "", 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 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 "", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' ```

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. --- .../test/fixtures/ruff/expression/fstring.py | 4 ++++ crates/ruff_python_formatter/src/context.rs | 14 ++++++++++++- .../src/other/interpolated_string_element.rs | 14 +++++++++---- .../src/string/normalize.rs | 9 +++++++- ...black_compatibility@cases__fstring.py.snap | 21 +++---------------- .../format@expression__fstring.py.snap | 16 ++++++++++++-- 6 files changed, 52 insertions(+), 26 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index fd658cc5ce..46322e38f2 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -748,3 +748,7 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1, }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" diff --git a/crates/ruff_python_formatter/src/context.rs b/crates/ruff_python_formatter/src/context.rs index bc88b987ae..528afc6c71 100644 --- a/crates/ruff_python_formatter/src/context.rs +++ b/crates/ruff_python_formatter/src/context.rs @@ -144,6 +144,12 @@ pub(crate) enum InterpolatedStringState { /// /// The containing `FStringContext` is the surrounding f-string context. InsideInterpolatedElement(InterpolatedStringContext), + /// The formatter is inside more than one nested f-string, such as in `nested` in: + /// + /// ```py + /// f"{f'''{'nested'} inner'''} outer" + /// ``` + NestedInterpolatedElement(InterpolatedStringContext), /// The formatter is outside an f-string. #[default] Outside, @@ -152,12 +158,18 @@ pub(crate) enum InterpolatedStringState { impl InterpolatedStringState { pub(crate) fn can_contain_line_breaks(self) -> Option { match self { - InterpolatedStringState::InsideInterpolatedElement(context) => { + InterpolatedStringState::InsideInterpolatedElement(context) + | InterpolatedStringState::NestedInterpolatedElement(context) => { Some(context.is_multiline()) } InterpolatedStringState::Outside => None, } } + + /// Returns `true` if the interpolated string state is [`NestedInterpolatedElement`]. + pub(crate) fn is_nested(self) -> bool { + matches!(self, Self::NestedInterpolatedElement(..)) + } } /// The position of a top-level statement in the module. diff --git a/crates/ruff_python_formatter/src/other/interpolated_string_element.rs b/crates/ruff_python_formatter/src/other/interpolated_string_element.rs index 13526c218f..49495aa646 100644 --- a/crates/ruff_python_formatter/src/other/interpolated_string_element.rs +++ b/crates/ruff_python_formatter/src/other/interpolated_string_element.rs @@ -181,10 +181,16 @@ impl Format> for FormatInterpolatedElement<'_> { let item = format_with(|f: &mut PyFormatter| { // Update the context to be inside the f-string expression element. - let f = &mut WithInterpolatedStringState::new( - InterpolatedStringState::InsideInterpolatedElement(self.context), - f, - ); + let state = match f.context().interpolated_string_state() { + InterpolatedStringState::InsideInterpolatedElement(_) + | InterpolatedStringState::NestedInterpolatedElement(_) => { + InterpolatedStringState::NestedInterpolatedElement(self.context) + } + InterpolatedStringState::Outside => { + InterpolatedStringState::InsideInterpolatedElement(self.context) + } + }; + let f = &mut WithInterpolatedStringState::new(state, f); write!(f, [bracket_spacing, expression.format()])?; diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index fa60c4a13f..fabd10a029 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -46,8 +46,15 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { .unwrap_or(self.context.options().quote_style()); let supports_pep_701 = self.context.options().target_version().supports_pep_701(); + // Preserve the existing quote style for nested interpolations more than one layer deep, if + // PEP 701 isn't supported. + if !supports_pep_701 && self.context.interpolated_string_state().is_nested() { + return QuoteStyle::Preserve; + } + // For f-strings and t-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't. - if let InterpolatedStringState::InsideInterpolatedElement(parent_context) = + if let InterpolatedStringState::InsideInterpolatedElement(parent_context) + | InterpolatedStringState::NestedInterpolatedElement(parent_context) = self.context.interpolated_string_state() { let parent_flags = parent_context.flags(); diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap index 6ff1f73fd7..f4140291a5 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap @@ -28,12 +28,11 @@ but none started with prefix {parentdir_prefix}" f'{{NOT \'a\' "formatted" "value"}}' f"some f-string with {a} {few():.2f} {formatted.values!r}" -f'some f-string with {a} {few(""):.2f} {formatted.values!r}' --f"{f'''{'nested'} inner'''} outer" ++f"some f-string with {a} {few(''):.2f} {formatted.values!r}" + f"{f'''{'nested'} inner'''} outer" -f"\"{f'{nested} inner'}\" outer" -f"space between opening braces: { {a for a in (1, 2, 3)}}" -f'Hello \'{tricky + "example"}\'' -+f"some f-string with {a} {few(''):.2f} {formatted.values!r}" -+f"{f'''{"nested"} inner'''} outer" +f'"{f"{nested} inner"}" outer' +f"space between opening braces: { {a for a in (1, 2, 3)} }" +f"Hello '{tricky + 'example'}'" @@ -49,7 +48,7 @@ f"{{NOT a formatted value}}" f'{{NOT \'a\' "formatted" "value"}}' f"some f-string with {a} {few():.2f} {formatted.values!r}" f"some f-string with {a} {few(''):.2f} {formatted.values!r}" -f"{f'''{"nested"} inner'''} outer" +f"{f'''{'nested'} inner'''} outer" f'"{f"{nested} inner"}" outer' f"space between opening braces: { {a for a in (1, 2, 3)} }" f"Hello '{tricky + 'example'}'" @@ -72,17 +71,3 @@ f'Hello \'{tricky + "example"}\'' f"Tried directories {str(rootdirs)} \ but none started with prefix {parentdir_prefix}" ``` - -## New Unsupported Syntax Errors - -error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) - --> fstring.py:6:9 - | -4 | f"some f-string with {a} {few():.2f} {formatted.values!r}" -5 | f"some f-string with {a} {few(''):.2f} {formatted.values!r}" -6 | f"{f'''{"nested"} inner'''} outer" - | ^ -7 | f'"{f"{nested} inner"}" outer' -8 | f"space between opening braces: { {a for a in (1, 2, 3)} }" - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index c9d90b1764..4460667feb 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -754,6 +754,10 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1, }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" ``` ## Outputs @@ -1532,7 +1536,7 @@ f'{f"""other " """}' f'{1: hy "user"}' f'{1:hy "user"}' f'{1: abcd "{1}" }' -f'{1: abcd "{"aa"}" }' +f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' f"{x:a{z:hy \"user\"}} '''" @@ -1581,6 +1585,10 @@ print( # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1 }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" ``` @@ -2359,7 +2367,7 @@ f'{f"""other " """}' f'{1: hy "user"}' f'{1:hy "user"}' f'{1: abcd "{1}" }' -f'{1: abcd "{"aa"}" }' +f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' f"{x:a{z:hy \"user\"}} '''" @@ -2408,6 +2416,10 @@ print( # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1 }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" ```