From 4c283f1e7dc53ab30aa63f3e6898dffd6c0b81f2 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 21 Oct 2024 16:24:07 +0200 Subject: [PATCH] Alternate quotes in nested f-strings when preview mode is enabled and targeting Py312+ --- crates/ruff_python_ast/src/expression.rs | 4 + .../src/other/f_string.rs | 37 +-- .../src/string/normalize.rs | 213 ++++++++++++++---- ...bility@cases__preview_long_strings.py.snap | 24 +- ...__preview_long_strings__regression.py.snap | 8 +- .../format@expression__fstring.py.snap | 106 +++++---- .../format@expression__fstring_py312.py.snap | 13 -- 7 files changed, 264 insertions(+), 141 deletions(-) diff --git a/crates/ruff_python_ast/src/expression.rs b/crates/ruff_python_ast/src/expression.rs index a4a9c044ba08c9..6fb0ef9d293069 100644 --- a/crates/ruff_python_ast/src/expression.rs +++ b/crates/ruff_python_ast/src/expression.rs @@ -524,6 +524,10 @@ impl StringLikePart<'_> { self.end() - kind.closer_len(), ) } + + pub const fn is_fstring(self) -> bool { + matches!(self, Self::FString(_)) + } } impl<'a> From<&'a ast::StringLiteral> for StringLikePart<'a> { diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 9202ea94aab206..826d5dbf67caf7 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -1,12 +1,12 @@ +use ruff_formatter::write; +use ruff_python_ast::{AnyStringFlags, FString, StringFlags}; +use ruff_source_file::Locator; + use crate::prelude::*; use crate::preview::{ is_f_string_formatting_enabled, is_f_string_implicit_concatenated_string_literal_quotes_enabled, }; use crate::string::{Quoting, StringNormalizer, StringQuotes}; -use ruff_formatter::write; -use ruff_python_ast::{AnyStringFlags, FString, StringFlags}; -use ruff_source_file::Locator; -use ruff_text_size::Ranged; use super::f_string_element::FormatFStringElement; @@ -35,7 +35,7 @@ impl Format> for FormatFString<'_> { // f-string instead of globally for the entire f-string expression. let quoting = if is_f_string_implicit_concatenated_string_literal_quotes_enabled(f.context()) { - f_string_quoting(self.value, &locator) + Quoting::CanChange } else { self.quoting }; @@ -92,17 +92,21 @@ impl Format> for FormatFString<'_> { #[derive(Clone, Copy, Debug)] pub(crate) struct FStringContext { - flags: AnyStringFlags, + /// The string flags of the enclosing f-string part. + enclosing_flags: AnyStringFlags, layout: FStringLayout, } impl FStringContext { const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { - Self { flags, layout } + Self { + enclosing_flags: flags, + layout, + } } pub(crate) fn flags(self) -> AnyStringFlags { - self.flags + self.enclosing_flags } pub(crate) const fn layout(self) -> FStringLayout { @@ -149,20 +153,3 @@ impl FStringLayout { matches!(self, FStringLayout::Multiline) } } - -fn f_string_quoting(f_string: &FString, locator: &Locator) -> Quoting { - let triple_quoted = f_string.flags.is_triple_quoted(); - - if f_string.elements.expressions().any(|expression| { - let string_content = locator.slice(expression.range()); - if triple_quoted { - string_content.contains(r#"""""#) || string_content.contains("'''") - } else { - string_content.contains(['"', '\'']) - } - }) { - Quoting::Preserve - } else { - Quoting::CanChange - } -} diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index 5e5706a38f769f..568229e75e9f49 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -3,7 +3,7 @@ use std::cmp::Ordering; use std::iter::FusedIterator; use ruff_formatter::FormatContext; -use ruff_python_ast::{str::Quote, AnyStringFlags, StringFlags, StringLikePart}; +use ruff_python_ast::{str::Quote, AnyStringFlags, FStringElement, StringFlags, StringLikePart}; use ruff_text_size::{Ranged, TextRange}; use crate::context::FStringState; @@ -37,51 +37,30 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { self } - fn quoting(&self, string: StringLikePart) -> Quoting { - match (self.quoting, self.context.f_string_state()) { - (Quoting::Preserve, _) => Quoting::Preserve, - - // If we're inside an f-string, we need to make sure to preserve the - // existing quotes unless we're inside a triple-quoted f-string and - // the inner string itself isn't triple-quoted. For example: - // - // ```python - // f"""outer {"inner"}""" # Valid - // f"""outer {"""inner"""}""" # Invalid - // ``` - // - // Or, if the target version supports PEP 701. - // - // The reason to preserve the quotes is based on the assumption that - // the original f-string is valid in terms of quoting, and we don't - // want to change that to make it invalid. - (Quoting::CanChange, FStringState::InsideExpressionElement(context)) => { - if (context.f_string().flags().is_triple_quoted() - && !string.flags().is_triple_quoted()) - || self.context.options().target_version().supports_pep_701() - { - Quoting::CanChange - } else { - Quoting::Preserve - } - } - - (Quoting::CanChange, _) => Quoting::CanChange, - } - } - /// Determines the preferred quote style for `string`. /// The formatter should use the preferred quote style unless /// it can't because the string contains the preferred quotes OR /// it leads to more escaping. pub(super) fn preferred_quote_style(&self, string: StringLikePart) -> QuoteStyle { - match self.quoting(string) { + match self.quoting { Quoting::Preserve => QuoteStyle::Preserve, Quoting::CanChange => { let preferred_quote_style = self .preferred_quote_style .unwrap_or(self.context.options().quote_style()); + if preferred_quote_style.is_preserve() { + return QuoteStyle::Preserve; + } + + if let FStringState::InsideExpressionElement(parent_context) = + self.context.f_string_state() + { + return QuoteStyle::from( + parent_context.f_string().flags().quote_style().opposite(), + ); + } + // Per PEP 8, always prefer double quotes for triple-quoted strings. // Except when using quote-style-preserve. if string.flags().is_triple_quoted() { @@ -132,8 +111,6 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { // if it doesn't have perfect alignment with PEP8. if let Some(quote) = self.context.docstring() { QuoteStyle::from(quote.opposite()) - } else if preferred_quote_style.is_preserve() { - QuoteStyle::Preserve } else { QuoteStyle::Double } @@ -146,6 +123,49 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { /// Computes the strings preferred quotes. pub(crate) fn choose_quotes(&self, string: StringLikePart) -> QuoteSelection { + // Preserve the f-string quotes if the target version isn't newer or equal than Python 3.12 + // and an f-string expression contains a debug text with a quote character + // because the formatter will emit the debug expression **exctly** the same as in the source text. + if is_f_string_formatting_enabled(self.context) + && !self.context.options().target_version().supports_pep_701() + { + if let StringLikePart::FString(fstring) = string { + if fstring + .elements + .iter() + .flat_map(FStringElement::as_expression) + .any(|expression| { + if expression.debug_text.is_some() { + let content = self.context.locator().slice(expression.range()); + match string.flags().quote_style() { + Quote::Single => { + if string.flags().is_triple_quoted() { + content.contains(r#"""""#) + } else { + content.contains('"') + } + } + Quote::Double => { + if string.flags().is_triple_quoted() { + content.contains("'''") + } else { + content.contains('\'') + } + } + } + } else { + false + } + }) + { + return QuoteSelection { + flags: string.flags(), + first_quote_or_normalized_char_offset: None, + }; + } + } + } + let raw_content = self.context.locator().slice(string.content_range()); let first_quote_or_normalized_char_offset = raw_content .bytes() @@ -163,12 +183,18 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { // The preferred quote style is single or double quotes, and the string contains a quote or // another character that may require escaping (Ok(preferred_quote), Some(first_quote_or_normalized_char_offset)) => { - let quote = QuoteMetadata::from_str( - &raw_content[first_quote_or_normalized_char_offset..], - string.flags(), - preferred_quote, - ) - .choose(preferred_quote); + let metadata = if string.is_fstring() { + QuoteMetadata::from_part(string, self.context, preferred_quote) + } else { + QuoteMetadata::from_str( + &raw_content[first_quote_or_normalized_char_offset..], + string.flags(), + preferred_quote, + ) + }; + + let quote = metadata.choose(preferred_quote); + string_flags.with_quote_style(quote) } @@ -235,6 +261,52 @@ pub(crate) struct QuoteMetadata { /// Tracks information about the used quotes in a string which is used /// to choose the quotes for a part. impl QuoteMetadata { + pub(crate) fn from_part( + part: StringLikePart, + context: &PyFormatContext, + preferred_quote: Quote, + ) -> Self { + match part { + StringLikePart::String(_) | StringLikePart::Bytes(_) => { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) + } + StringLikePart::FString(fstring) => { + // TODO: Should we limit this behavior to Post 312? + if is_f_string_formatting_enabled(context) { + let mut literals = fstring.elements.iter().flat_map(FStringElement::as_literal); + + let Some(first) = literals.next() else { + return QuoteMetadata::from_str("", part.flags(), preferred_quote); + }; + + let mut metadata = QuoteMetadata::from_str( + context.locator().slice(first.range()), + fstring.flags.into(), + preferred_quote, + ); + + for literal in literals { + metadata = metadata + .merge(&QuoteMetadata::from_str( + context.locator().slice(literal.range()), + fstring.flags.into(), + preferred_quote, + )) + .expect("Merge to succeed because all parts have the same flags"); + } + + metadata + } else { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) + } + } + } + } + pub(crate) fn from_str(text: &str, flags: AnyStringFlags, preferred_quote: Quote) -> Self { let kind = if flags.is_raw_string() { QuoteMetadataKind::raw(text, preferred_quote, flags.is_triple_quoted()) @@ -276,6 +348,61 @@ impl QuoteMetadata { }, } } + + /// Merges the quotes metadata of different literals. + /// + /// ## Raw and triple quoted strings + /// Merging raw and triple quoted strings is only correct if all literals are from the same part. + /// E.g. it's okay to merge triple and raw strings from a single FString part's literals + /// but it isn't safe to merge raw and triple quoted strings from different parts of an implicit + /// concatenated string. Where safe means, it may lead to incorrect results. + pub(super) fn merge(self, other: &QuoteMetadata) -> Option { + let kind = match (self.kind, other.kind) { + ( + QuoteMetadataKind::Regular { + single_quotes: self_single, + double_quotes: self_double, + }, + QuoteMetadataKind::Regular { + single_quotes: other_single, + double_quotes: other_double, + }, + ) => QuoteMetadataKind::Regular { + single_quotes: self_single + other_single, + double_quotes: self_double + other_double, + }, + + // Can't merge quotes from raw strings (even when both strings are raw) + ( + QuoteMetadataKind::Raw { + contains_preferred: self_contains_preferred, + }, + QuoteMetadataKind::Raw { + contains_preferred: other_contains_preferred, + }, + ) => QuoteMetadataKind::Raw { + contains_preferred: self_contains_preferred || other_contains_preferred, + }, + + ( + QuoteMetadataKind::Triple { + contains_preferred: self_contains_preferred, + }, + QuoteMetadataKind::Triple { + contains_preferred: other_contains_preferred, + }, + ) => QuoteMetadataKind::Triple { + contains_preferred: self_contains_preferred || other_contains_preferred, + }, + + (_, _) => return None, + }; + + Some(Self { + kind, + source_style: self.source_style, + }) + } } #[derive(Copy, Clone, Debug)] diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap index eeda12f088cf7f..90bca7e82b232b 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap @@ -902,7 +902,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share ) dict_with_lambda_values = { -@@ -524,65 +383,58 @@ +@@ -524,69 +383,62 @@ # Complex string concatenations with a method call in the middle. code = ( @@ -941,7 +941,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - "Skipping:" - f" {desc['db_id']} {foo('bar',x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" -+ f'Skipping: {desc["db_id"]} {foo("bar", x=123)} {"foo" != "bar"} {(x := "abc=")} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ++ f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -965,7 +965,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - "Skipping:" - f" {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" -+ f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ++ f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -982,17 +982,15 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - f"""Skipping: {"a" == 'b'} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" -+ f"""Skipping: {"a" == "b"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" ++ f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) log.info( -@@ -590,5 +442,5 @@ +- f"""Skipping: {'a' == "b"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" ++ f"""Skipping: {'a' == "b"=} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) log.info( -- f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" -+ f"""Skipping: {"a" == "b"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" - ) ``` ## Ruff Output @@ -1406,7 +1404,7 @@ log.info( ) log.info( - f'Skipping: {desc["db_id"]} {foo("bar", x=123)} {"foo" != "bar"} {(x := "abc=")} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -1422,7 +1420,7 @@ log.info( ) log.info( - f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -1434,15 +1432,15 @@ log.info( ) log.info( - f"""Skipping: {"a" == "b"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" + f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) log.info( - f"""Skipping: {'a' == "b"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" + f"""Skipping: {'a' == "b"=} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) log.info( - f"""Skipping: {"a" == "b"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" + f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap index 54b2c0f438b710..e017e31abfe265 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap @@ -1201,10 +1201,10 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + "With single quote: ' " + f" {my_dict['foo']}" + ' With double quote: " ' -+ f' {my_dict["bar"]}' ++ f" {my_dict['bar']}" ) + -+s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\'' ++s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` ## Ruff Output @@ -1839,10 +1839,10 @@ s = ( "With single quote: ' " f" {my_dict['foo']}" ' With double quote: " ' - f' {my_dict["bar"]}' + f" {my_dict['bar']}" ) -s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\'' +s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` ## Black Output 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 5faebb836e37df..39cd8acedce827 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 @@ -399,9 +399,9 @@ result_f = ( ) # https://github.com/astral-sh/ruff/issues/6841 -x = f"""a{""}b""" +x = f"""a{''}b""" y = f'''c{1}d"""e''' -z = f"""a{""}b""" f'''c{1}d"""e''' +z = f"""a{''}b""" f'''c{1}d"""e''' # F-String formatting test cases (Preview) @@ -429,10 +429,10 @@ aaaaaaaaaaa = ( # This should never add the optional parentheses because even after adding them, the # f-string exceeds the line length limit. -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" @@ -501,8 +501,8 @@ f"foo 'bar' {x}" f'foo "bar" {x}' f'foo "bar" {x}' f"foo 'bar' {x}" -f"foo {"bar"}" -f"foo {'\'bar\''}" +f"foo {'bar'}" +f"foo {"'bar'"}" # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. @@ -511,8 +511,8 @@ f"foo {'"bar"'}" # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. -f"""test {"inner"}""" -f"""test {"inner"}""" +f"""test {'inner'}""" +f"""test {'inner'}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" @@ -616,10 +616,10 @@ x = f""" # Here, the debug expression is in a nested f-string so we should start preserving # whitespaces from that point onwards. This means we should format the outer f-string. x = f"""{ - "foo " # comment 24 - + f"{ x = + 'foo ' # comment 24 + + f'{ x = - }" # comment 25 + }' # comment 25 } """ @@ -641,19 +641,19 @@ if indent0: if indent2: foo = f"""hello world hello { - f"aaaaaaa { + f'aaaaaaa { [ - 'aaaaaaaaaaaaaaaaaaaaa', - 'bbbbbbbbbbbbbbbbbbbbb', - 'ccccccccccccccccccccc', - 'ddddddddddddddddddddd', + "aaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbb", + "ccccccccccccccccccccc", + "ddddddddddddddddddddd", ] - } bbbbbbbb" + } bbbbbbbb' + [ - "aaaaaaaaaaaaaaaaaaaaa", - "bbbbbbbbbbbbbbbbbbbbb", - "ccccccccccccccccccccc", - "ddddddddddddddddddddd", + 'aaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbb', + 'ccccccccccccccccccccc', + 'ddddddddddddddddddddd', ] } -------- """ @@ -662,7 +662,7 @@ hello { # Implicit concatenated f-string containing quotes _ = ( "This string should change its quotes to double quotes" - f'This string uses double quotes in an expression {"woah"}' + f"This string uses double quotes in an expression {'woah'}" f"This f-string does not use any quotes." ) ``` @@ -1022,6 +1022,18 @@ _ = ( " f()\n" # XXX: The following line changes depending on whether the tests # are run through the interactive interpreter or with -m +@@ -57,9 +57,9 @@ + ) + + # https://github.com/astral-sh/ruff/issues/6841 +-x = f"""a{""}b""" ++x = f"""a{''}b""" + y = f'''c{1}d"""e''' +-z = f"""a{""}b""" f'''c{1}d"""e''' ++z = f"""a{''}b""" f'''c{1}d"""e''' + + # F-String formatting test cases (Preview) + @@ -67,64 +67,72 @@ x = f"{a}" x = f"{ @@ -1052,12 +1064,12 @@ _ = ( # This should never add the optional parentheses because even after adding them, the # f-string exceeds the line length limit. -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" -+x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} ccccccccccccccc" ++x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 -+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ++ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" @@ -1132,7 +1144,14 @@ _ = ( }" # Quotes -@@ -152,13 +164,13 @@ +@@ -147,18 +159,18 @@ + f'foo "bar" {x}' + f'foo "bar" {x}' + f"foo 'bar' {x}" +-f"foo {"bar"}" +-f"foo {'\'bar\''}" ++f"foo {'bar'}" ++f"foo {"'bar'"}" # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. @@ -1142,9 +1161,9 @@ _ = ( # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. --f"""test {'inner'}""" - f"""test {"inner"}""" -+f"""test {"inner"}""" + f"""test {'inner'}""" +-f"""test {"inner"}""" ++f"""test {'inner'}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" @@ -1282,12 +1301,12 @@ _ = ( -x = f"""{"foo " + # comment 24 - f"{ x = +x = f"""{ -+ "foo " # comment 24 -+ + f"{ x = ++ 'foo ' # comment 24 ++ + f'{ x = - }" # comment 25 - } -+ }" # comment 25 ++ }' # comment 25 +} """ @@ -1322,19 +1341,19 @@ _ = ( - 'ddddddddddddddddddddd' - ] - } -------- -+ f"aaaaaaa { ++ f'aaaaaaa { + [ -+ 'aaaaaaaaaaaaaaaaaaaaa', -+ 'bbbbbbbbbbbbbbbbbbbbb', -+ 'ccccccccccccccccccccc', -+ 'ddddddddddddddddddddd', ++ "aaaaaaaaaaaaaaaaaaaaa", ++ "bbbbbbbbbbbbbbbbbbbbb", ++ "ccccccccccccccccccccc", ++ "ddddddddddddddddddddd", + ] -+ } bbbbbbbb" ++ } bbbbbbbb' + + [ -+ "aaaaaaaaaaaaaaaaaaaaa", -+ "bbbbbbbbbbbbbbbbbbbbb", -+ "ccccccccccccccccccccc", -+ "ddddddddddddddddddddd", ++ 'aaaaaaaaaaaaaaaaaaaaa', ++ 'bbbbbbbbbbbbbbbbbbbbb', ++ 'ccccccccccccccccccccc', ++ 'ddddddddddddddddddddd', + ] + } -------- """ @@ -1343,9 +1362,10 @@ _ = ( # Implicit concatenated f-string containing quotes _ = ( - 'This string should change its quotes to double quotes' -+ "This string should change its quotes to double quotes" - f'This string uses double quotes in an expression {"woah"}' +- f'This string uses double quotes in an expression {"woah"}' - f'This f-string does not use any quotes.' ++ "This string should change its quotes to double quotes" ++ f"This string uses double quotes in an expression {'woah'}" + f"This f-string does not use any quotes." ) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap index fd22501c96a68d..d96e29e6a5471e 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap @@ -36,16 +36,3 @@ source_type = Python # Quotes reuse f"{'a'}" ``` - - -#### Preview changes -```diff ---- Stable -+++ Preview -@@ -3,4 +3,4 @@ - # version isn't set. - - # Quotes reuse --f"{'a'}" -+f"{"a"}" -```