Fix f-string formatting in assignment statement (#14454)

## Summary

fixes: #13813

This PR fixes a bug in the formatting assignment statement when the
value is an f-string.

This is resolved by using custom best fit layouts if the f-string is (a)
not already a flat f-string (thus, cannot be multiline) and (b) is not a
multiline string (thus, cannot be flattened). So, it is used in cases
like the following:
```py
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
    expression}moreeeeeeeeeeeeeeeee"
```
Which is (a) `FStringLayout::Multiline` and (b) not a multiline.

There are various other examples in the PR diff along with additional
explanation and context as code comments.

## Test Plan

Add multiple test cases for various scenarios.
This commit is contained in:
Dhruv Manilawala 2024-11-26 15:07:18 +05:30 committed by GitHub
parent e4cefd9bf9
commit f3dac27e9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2184 additions and 74 deletions

View file

@ -418,3 +418,9 @@ if True:
"permissions to manage this role, or else members of this role won't receive "
"a notification."
)
# This f-string should be flattened
xxxxxxxxxxxxxxxx = f"aaaaaaaaaaaaaaaaaaaaa {
expression } bbbbbbbbbbbbbbbbbbbbbbbb" + (
yyyyyyyyyyyyyy + zzzzzzzzzzz
)

View file

@ -313,6 +313,321 @@ f"{ # comment 26
# comment 28
} woah {x}"
# Assignment statement
# Even though this f-string has multiline expression, thus allowing us to break it at the
# curly braces, the f-string fits on a single line if it's moved inside the parentheses.
# We should prefer doing that instead.
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeee"
# Same as above
xxxxxxx = f"{
{'aaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'}
}"
# Similar to the previous example, but the f-string will exceed the line length limit,
# we shouldn't add any parentheses here.
xxxxxxx = f"{
{'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'}
}"
# Same as above but with an inline comment. The f-string should be formatted inside the
# parentheses and the comment should be part of the line inside the parentheses.
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeee" # comment
# Similar to the previous example but this time parenthesizing doesn't work because it
# exceeds the line length. So, avoid parenthesizing this f-string.
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeee" # comment loooooooong
# Similar to the previous example but we start with the parenthesized layout. This should
# remove the parentheses and format the f-string on a single line. This shows that the
# final layout for the formatter is same for this and the previous case. The only
# difference is that in the previous case the expression is already mulitline which means
# the formatter can break it further at the curly braces.
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee" # comment loooooooong
)
# The following f-strings are going to break because of the trailing comma so we should
# avoid using the best fit layout and instead use the default layout.
# left-to-right
aaaa = f"aaaa {[
1, 2,
]} bbbb"
# right-to-left
aaaa, bbbb = f"aaaa {[
1, 2,
]} bbbb"
# Using the right-to-left assignment statement variant.
aaaaaaaaaaaaaaaaaa, bbbbbbbbbbb = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeee" # comment
# Here, the f-string layout is flat but it exceeds the line length limit. This shouldn't
# try the custom best fit layout because the f-string doesn't have any split points.
aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = (
f"aaaaaaaaaaaaaaaaaaa {aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
)
# Same as above but without the parentheses to test that it gets formatted to the same
# layout as the previous example.
aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = f"aaaaaaaaaaaaaaaaaaa {aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
# But, the following f-string does have a split point because of the multiline expression.
aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = (
f"aaaaaaaaaaaaaaaaaaa {
aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
)
aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = (
f"aaaaaaaaaaaaaaaaaaa {
aaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbb + cccccccccccccccccccccc + dddddddddddddddddddddddddddd} ddddddddddddddddddd"
)
# This is an implicitly concatenated f-string but it cannot be joined because otherwise
# it'll exceed the line length limit. So, the two f-strings will be inside parentheses
# instead and the inline comment should be outside the parentheses.
a = f"test{
expression
}flat" f"can be {
joined
} togethereeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
# Similar to the above example but this fits within the line length limit.
a = f"test{
expression
}flat" f"can be {
joined
} togethereeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
# The following test cases are adopted from implicit string concatenation but for a
# single f-string instead.
# Don't inline f-strings that contain expressions that are guaranteed to split, e.g. because of a magic trailing comma
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}moreeeeeeeeeeeeeeeeeeee" # comment
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}moreeeeeeeeeeeeeeeeeeee" # comment
)
aaaaa[aaaaaaaaaaa] = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}moreeeeeeeeeeeeeeeeeeee" # comment
aaaaa[aaaaaaaaaaa] = (f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}moreeeeeeeeeeeeeeeeeeee" # comment
)
# Don't inline f-strings that contain commented expressions
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{[
a # comment
]}moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{[
a # comment
]}moreeeeeeeeeeeeeeeeeetest" # comment
)
# Don't inline f-strings with multiline debug expressions or format specifiers
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a +
b=}moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}moreeeeeeeeeeeeeeeeeetest" # comment
)
# This is not a multiline f-string even though it has a newline after the format specifier.
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a:.3f
}moreeeeeeeeeeeeeeeeeetest" # comment
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a:.3f
}moreeeeeeeeeeeeeeeeeetest" # comment
)
# The newline is only considered when it's a tripled-quoted f-string.
aaaaaaaaaaaaaaaaaa = f"""testeeeeeeeeeeeeeeeeeeeeeeeee{
a:.3f
}moreeeeeeeeeeeeeeeeeetest""" # comment
aaaaaaaaaaaaaaaaaa = (
f"""testeeeeeeeeeeeeeeeeeeeeeeeee{
a:.3f
}moreeeeeeeeeeeeeeeeeetest""" # comment
)
# Remove the parentheses here
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{[a, b,
# comment
]}moee" # comment
)
# ... but not here because of the ownline comment
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{[a, b,
]}moee"
# comment
)
# F-strings in other positions
if f"aaaaaaaaaaa {ttttteeeeeeeeest} more {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}": pass
if (
f"aaaaaaaaaaa {ttttteeeeeeeeest} more {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}"
): pass
if f"aaaaaaaaaaa {ttttteeeeeeeeest} more {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}": pass
if f"aaaaaaaaaaa {ttttteeeeeeeeest} more { # comment
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}": pass
if f"aaaaaaaaaaa {[ttttteeeeeeeeest,]} more {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}":
pass
if (
f"aaaaaaaaaaa {[ttttteeeeeeeeest,]} more {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}"
):
pass
if f"aaaaaaaaaaa {[ttttteeeeeeeeest,]} more {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
}":
pass
# For loops
for a in f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeeeeee":
pass
for a in f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeeeeeeeeeeeeeeee":
pass
for a in f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeeeeeeeeeeeeeeee":
pass
for a in (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeeeeeeeeeeeeeee"
):
pass
# With statements
with f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeeeeee":
pass
with f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeeeeeeeeeeeeeeee":
pass
with f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
expression}moreeeeeeeeeeeeeeeeeeeeeeeeeeeeee":
pass
with (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeeeeeeeeeeeeeee"
):
pass
# Assert statements
assert f"aaaaaaaaa{
expression}bbbbbbbbbbbb", f"cccccccccc{
expression}dddddddddd"
assert f"aaaaaaaaa{expression}bbbbbbbbbbbb", f"cccccccccccccccc{
expression}dddddddddddddddd"
assert f"aaaaaaaaa{expression}bbbbbbbbbbbb", f"cccccccccccccccc{expression}dddddddddddddddd"
assert f"aaaaaaaaaaaaaaaaaaaaaaaaaaa{
expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", f"ccccccc{expression}dddddddddd"
assert f"aaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", f"ccccccc{expression}dddddddddd"
assert f"aaaaaaaaaaaaaaaaaaaaaaaaaaa{
expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", f"ccccccccccccccccccccc {
expression} dddddddddddddddddddddddddd"
assert f"aaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", f"cccccccccccccccccccccccccccccccc {expression} ddddddddddddddddddddddddddddddddddddd"
# F-strings as a single argument to a call expression to test whether it's huggable or not.
call(f"{
testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
}")
call(f"{
testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
}")
call(f"{ # comment
testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
}")
call(f"""aaaaaaaaaaaaaaaa bbbbbbbbbb {testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee}""")
call(f"""aaaaaaaaaaaaaaaa bbbbbbbbbb {testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
}""")
call(f"""aaaaaaaaaaaaaaaa
bbbbbbbbbb {testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
}""")
call(f"""aaaaaaaaaaaaaaaa
bbbbbbbbbb {testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee # comment
}""")
call(
f"""aaaaaaaaaaaaaaaa
bbbbbbbbbb {testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee # comment
}"""
)
call(f"{
aaaaaa
+ '''test
more'''
}")
# Indentation
# What should be the indentation?

View file

@ -28,7 +28,7 @@ impl NeedsParentheses for ExprBinOp {
} else if let Ok(string) = StringLike::try_from(&*self.left) {
// Multiline strings are guaranteed to never fit, avoid adding unnecessary parentheses
if !string.is_implicit_concatenated()
&& string.is_multiline(context.source())
&& string.is_multiline(context)
&& has_parentheses(&self.right, context).is_some()
&& !context.comments().has_dangling(self)
&& !context.comments().has(string)

View file

@ -40,7 +40,7 @@ impl NeedsParentheses for ExprBytesLiteral {
) -> OptionalParentheses {
if self.value.is_implicit_concatenated() {
OptionalParentheses::Multiline
} else if StringLike::Bytes(self).is_multiline(context.source()) {
} else if StringLike::Bytes(self).is_multiline(context) {
OptionalParentheses::Never
} else {
OptionalParentheses::BestFit

View file

@ -29,7 +29,7 @@ impl NeedsParentheses for ExprCompare {
} else if let Ok(string) = StringLike::try_from(&*self.left) {
// Multiline strings are guaranteed to never fit, avoid adding unnecessary parentheses
if !string.is_implicit_concatenated()
&& string.is_multiline(context.source())
&& string.is_multiline(context)
&& !context.comments().has(string)
&& self.comparators.first().is_some_and(|right| {
has_parentheses(right, context).is_some() && !context.comments().has(right)

View file

@ -4,10 +4,12 @@ use ruff_text_size::TextSlice;
use crate::expression::parentheses::{
in_parentheses_only_group, NeedsParentheses, OptionalParentheses,
};
use crate::other::f_string::FormatFString;
use crate::other::f_string::{FStringLayout, FormatFString};
use crate::prelude::*;
use crate::string::implicit::FormatImplicitConcatenatedStringFlat;
use crate::string::{implicit::FormatImplicitConcatenatedString, Quoting, StringLikeExtensions};
use crate::string::implicit::{
FormatImplicitConcatenatedString, FormatImplicitConcatenatedStringFlat,
};
use crate::string::{Quoting, StringLikeExtensions};
#[derive(Default)]
pub struct FormatExprFString;
@ -45,26 +47,11 @@ impl NeedsParentheses for ExprFString {
) -> OptionalParentheses {
if self.value.is_implicit_concatenated() {
OptionalParentheses::Multiline
}
// TODO(dhruvmanila): Ideally what we want here is a new variant which
// is something like:
// - If the expression fits by just adding the parentheses, then add them and
// avoid breaking the f-string expression. So,
// ```
// xxxxxxxxx = (
// f"aaaaaaaaaaaa { xxxxxxx + yyyyyyyy } bbbbbbbbbbbbb"
// )
// ```
// - But, if the expression is too long to fit even with parentheses, then
// don't add the parentheses and instead break the expression at `soft_line_break`.
// ```
// xxxxxxxxx = f"aaaaaaaaaaaa {
// xxxxxxxxx + yyyyyyyyyy
// } bbbbbbbbbbbbb"
// ```
// This isn't decided yet, refer to the relevant discussion:
// https://github.com/astral-sh/ruff/discussions/9785
else if StringLike::FString(self).is_multiline(context.source()) {
} else if StringLike::FString(self).is_multiline(context)
|| self.value.as_single().is_some_and(|f_string| {
FStringLayout::from_f_string(f_string, context.source()).is_multiline()
})
{
OptionalParentheses::Never
} else {
OptionalParentheses::BestFit

View file

@ -53,7 +53,7 @@ impl NeedsParentheses for ExprStringLiteral {
) -> OptionalParentheses {
if self.value.is_implicit_concatenated() {
OptionalParentheses::Multiline
} else if StringLike::String(self).is_multiline(context.source()) {
} else if StringLike::String(self).is_multiline(context) {
OptionalParentheses::Never
} else {
OptionalParentheses::BestFit

View file

@ -1,5 +1,5 @@
use ruff_formatter::{write, FormatContext};
use ruff_python_ast::{ArgOrKeyword, Arguments, Expr, StringLike};
use ruff_python_ast::{ArgOrKeyword, Arguments, Expr, StringFlags, StringLike};
use ruff_python_trivia::{PythonWhitespace, SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
@ -137,6 +137,7 @@ fn is_single_argument_parenthesized(argument: &Expr, call_end: TextSize, source:
false
}
/// Returns `true` if the arguments can hug directly to the enclosing parentheses in the call, as
/// in Black's `hug_parens_with_braces_and_square_brackets` preview style behavior.
///
@ -223,7 +224,13 @@ fn is_huggable_string_argument(
arguments: &Arguments,
context: &PyFormatContext,
) -> bool {
if string.is_implicit_concatenated() || !string.is_multiline(context.source()) {
if string.is_implicit_concatenated()
|| !string.is_multiline(context)
|| !string
.parts()
.next()
.is_some_and(|part| part.flags().is_triple_quoted())
{
return false;
}

View file

@ -140,6 +140,10 @@ impl FStringLayout {
}
}
pub(crate) const fn is_flat(self) -> bool {
matches!(self, FStringLayout::Flat)
}
pub(crate) const fn is_multiline(self) -> bool {
matches!(self, FStringLayout::Multiline)
}

View file

@ -1,6 +1,7 @@
use ruff_formatter::{format_args, write, FormatError, RemoveSoftLinesBuffer};
use ruff_python_ast::{
AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, StringLike, TypeParams,
AnyNodeRef, Expr, ExprAttribute, ExprCall, FStringPart, Operator, StmtAssign, StringLike,
TypeParams,
};
use crate::builders::parenthesize_if_expands;
@ -8,6 +9,7 @@ use crate::comments::{
trailing_comments, Comments, LeadingDanglingTrailingComments, SourceComment,
};
use crate::context::{NodeLevel, WithNodeLevel};
use crate::expression::expr_f_string::f_string_quoting;
use crate::expression::parentheses::{
is_expression_parenthesized, optional_parentheses, NeedsParentheses, OptionalParentheses,
Parentheses, Parenthesize,
@ -16,12 +18,16 @@ use crate::expression::{
can_omit_optional_parentheses, has_own_parentheses, has_parentheses,
maybe_parenthesize_expression,
};
use crate::preview::is_join_implicit_concatenated_string_enabled;
use crate::other::f_string::{FStringLayout, FormatFString};
use crate::preview::{
is_f_string_formatting_enabled, is_join_implicit_concatenated_string_enabled,
};
use crate::statement::trailing_semicolon;
use crate::string::implicit::{
FormatImplicitConcatenatedStringExpanded, FormatImplicitConcatenatedStringFlat,
ImplicitConcatenatedLayout,
};
use crate::string::StringLikeExtensions;
use crate::{has_skip_comment, prelude::*};
#[derive(Default)]
@ -183,6 +189,7 @@ impl Format<PyFormatContext<'_>> for FormatTargetWithEqualOperator<'_> {
///
/// This logic isn't implemented in [`place_comment`] by associating trailing statement comments to the expression because
/// doing so breaks the suite empty lines formatting that relies on trailing comments to be stored on the statement.
#[derive(Debug)]
pub(super) enum FormatStatementsLastExpression<'a> {
/// Prefers to split what's left of `value` before splitting the value.
///
@ -286,11 +293,18 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
match self {
FormatStatementsLastExpression::LeftToRight { value, statement } => {
let can_inline_comment = should_inline_comments(value, *statement, f.context());
let format_implicit_flat = StringLike::try_from(*value).ok().and_then(|string| {
let string_like = StringLike::try_from(*value).ok();
let format_f_string =
string_like.and_then(|string| format_f_string_assignment(string, f.context()));
let format_implicit_flat = string_like.and_then(|string| {
FormatImplicitConcatenatedStringFlat::new(string, f.context())
});
if !can_inline_comment && format_implicit_flat.is_none() {
if !can_inline_comment
&& format_implicit_flat.is_none()
&& format_f_string.is_none()
{
return maybe_parenthesize_expression(
value,
*statement,
@ -436,6 +450,79 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
best_fitting![single_line, joined_parenthesized, implicit_expanded]
.with_mode(BestFittingMode::AllLines)
.fmt(f)?;
} else if let Some(format_f_string) = format_f_string {
inline_comments.mark_formatted();
let f_string_flat = format_with(|f| {
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [format_f_string])
})
.memoized();
// F-String containing an expression with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
// ```python
// aaaa = f"aaaa {[
// 1, 2,
// ]} bbbb"
// ```
if f_string_flat.inspect(f)?.will_break() {
inline_comments.mark_unformatted();
return write!(
f,
[maybe_parenthesize_expression(
value,
*statement,
Parenthesize::IfBreaks,
)]
);
}
// Considering the following example:
// ```python
// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
// expression}moreeeeeeeeeeeeeeeee"
// ```
// Flatten the f-string.
// ```python
// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee"
// ```
let single_line =
format_with(|f| write!(f, [f_string_flat, inline_comments]));
// Parenthesize the f-string and flatten the f-string.
// ```python
// aaaaaaaaaaaaaaaaaa = (
// f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee"
// )
// ```
let joined_parenthesized = format_with(|f| {
group(&format_args![
token("("),
soft_block_indent(&format_args![f_string_flat, inline_comments]),
token(")"),
])
.with_group_id(Some(group_id))
.should_expand(true)
.fmt(f)
});
// Avoid flattening or parenthesizing the f-string, keep the original
// f-string formatting.
// ```python
// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
// expression
// }moreeeeeeeeeeeeeeeee"
// ```
let format_f_string =
format_with(|f| write!(f, [format_f_string, inline_comments]));
best_fitting![single_line, joined_parenthesized, format_f_string]
.with_mode(BestFittingMode::AllLines)
.fmt(f)?;
} else {
best_fit_parenthesize(&format_once(|f| {
inline_comments.mark_formatted();
@ -474,7 +561,11 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
statement,
} => {
let should_inline_comments = should_inline_comments(value, *statement, f.context());
let format_implicit_flat = StringLike::try_from(*value).ok().and_then(|string| {
let string_like = StringLike::try_from(*value).ok();
let format_f_string =
string_like.and_then(|string| format_f_string_assignment(string, f.context()));
let format_implicit_flat = string_like.and_then(|string| {
FormatImplicitConcatenatedStringFlat::new(string, f.context())
});
@ -482,6 +573,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
if !should_inline_comments
&& !should_non_inlineable_use_best_fit(value, *statement, f.context())
&& format_implicit_flat.is_none()
&& format_f_string.is_none()
{
return write!(
f,
@ -503,7 +595,10 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
let expression_comments = comments.leading_dangling_trailing(*value);
// Don't inline comments for attribute and call expressions for black compatibility
let inline_comments = if should_inline_comments || format_implicit_flat.is_some() {
let inline_comments = if should_inline_comments
|| format_implicit_flat.is_some()
|| format_f_string.is_some()
{
OptionalParenthesesInlinedComments::new(
&expression_comments,
*statement,
@ -542,7 +637,8 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// This is mainly a performance optimisation that avoids unnecessary memoization
// and using the costly `BestFitting` layout if it is already known that only the last variant
// can ever fit because the left breaks.
if format_implicit_flat.is_none() && last_target_breaks {
if format_implicit_flat.is_none() && format_f_string.is_none() && last_target_breaks
{
return write!(
f,
[
@ -568,6 +664,11 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
} else {
format_implicit_flat.fmt(f)
}
} else if let Some(format_f_string) = format_f_string.as_ref() {
// Similar to above, remove any soft line breaks emitted by the f-string
// formatting.
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [format_f_string])
} else {
value.format().with_options(Parentheses::Never).fmt(f)
}
@ -805,6 +906,132 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
.with_mode(BestFittingMode::AllLines)
.fmt(f)
}
} else if let Some(format_f_string) = &format_f_string {
// F-String containing an expression with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
//
// ```python
// aaaa, bbbb = f"aaaa {[
// 1, 2,
// ]} bbbb"
// ```
if format_value.inspect(f)?.will_break() {
inline_comments.mark_unformatted();
return write!(
f,
[
before_operator,
space(),
operator,
space(),
maybe_parenthesize_expression(
value,
*statement,
Parenthesize::IfBreaks
)
]
);
}
let format_f_string =
format_with(|f| write!(f, [format_f_string, inline_comments])).memoized();
// Considering the following initial source:
//
// ```python
// aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = (
// f"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
// )
// ```
//
// Keep the target flat, and use the regular f-string formatting.
//
// ```python
// aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = f"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc
// } ddddddddddddddddddd"
// ```
let flat_target_regular_f_string = format_with(|f| {
write!(
f,
[last_target, space(), operator, space(), format_f_string]
)
});
// Expand the parent and parenthesize the flattened f-string.
//
// ```python
// aaaaaaaaaaaa[
// "bbbbbbbbbbbbbbbb"
// ] = (
// f"aaaaaaaaaaaaaaaaaaa {aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
// )
// ```
let split_target_value_parenthesized_flat = format_with(|f| {
write!(
f,
[
group(&last_target).should_expand(true),
space(),
operator,
space(),
token("("),
group(&soft_block_indent(&format_args![
format_value,
inline_comments
]))
.should_expand(true),
token(")")
]
)
});
// Expand the parent, and use the regular f-string formatting.
//
// ```python
// aaaaaaaaaaaa[
// "bbbbbbbbbbbbbbbb"
// ] = f"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc
// } ddddddddddddddddddd"
// ```
let split_target_regular_f_string = format_with(|f| {
write!(
f,
[
group(&last_target).should_expand(true),
space(),
operator,
space(),
format_f_string,
]
)
});
// This is only a perf optimisation. No point in trying all the "flat-target"
// variants if we know that the last target must break.
if last_target_breaks {
best_fitting![
split_target_flat_value,
split_target_value_parenthesized_flat,
split_target_regular_f_string,
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)
} else {
best_fitting![
single_line,
flat_target_parenthesize_value,
flat_target_regular_f_string,
split_target_flat_value,
split_target_value_parenthesized_flat,
split_target_regular_f_string,
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)
}
} else {
best_fitting![
single_line,
@ -818,6 +1045,95 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
}
}
/// Formats an f-string that is at the value position of an assignment statement.
///
/// This is just a wrapper around [`FormatFString`] while considering a special case when the
/// f-string is at an assignment statement's value position.
///
/// This is necessary to prevent an instability where an f-string contains a multiline expression
/// and the f-string fits on the line, but only when it's surrounded by parentheses.
///
/// ```python
/// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
/// expression}moreeeeeeeeeeeeeeeee"
/// ```
///
/// Without the special handling, this would get formatted to:
/// ```python
/// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
/// expression
/// }moreeeeeeeeeeeeeeeee"
/// ```
///
/// However, if the parentheses already existed in the source like:
/// ```python
/// aaaaaaaaaaaaaaaaaa = (
/// f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee"
/// )
/// ```
///
/// Then, it would remain unformatted because it fits on the line. This means that even in the
/// first example, the f-string should be formatted by surrounding it with parentheses.
///
/// One might ask why not just use the `BestFit` layout in this case. Consider the following
/// example in which the f-string doesn't fit on the line even when surrounded by parentheses:
/// ```python
/// xxxxxxx = f"{
/// {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'}
/// }"
/// ```
///
/// The `BestFit` layout will format this as:
/// ```python
/// xxxxxxx = (
/// f"{
/// {
/// 'aaaaaaaaaaaaaaaaaaaaaaaaa',
/// 'bbbbbbbbbbbbbbbbbbbbbbbbbbb',
/// 'cccccccccccccccccccccccccc',
/// }
/// }"
/// )
/// ```
///
/// The reason for this is because (a) f-string already has a multiline expression thus it tries to
/// break the expression and (b) the `BestFit` layout doesn't considers the layout where the
/// multiline f-string isn't surrounded by parentheses.
fn format_f_string_assignment<'a>(
string: StringLike<'a>,
context: &PyFormatContext,
) -> Option<FormatFString<'a>> {
if !is_f_string_formatting_enabled(context) {
return None;
}
let StringLike::FString(expr) = string else {
return None;
};
let [FStringPart::FString(f_string)] = expr.value.as_slice() else {
return None;
};
// If the f-string is flat, there are no breakpoints from which it can be made multiline.
// This is the case when the f-string has no expressions or if it does then the expressions
// are flat (no newlines).
if FStringLayout::from_f_string(f_string, context.source()).is_flat() {
return None;
}
// This checks whether the f-string is multi-line and it can *never* be flattened. Thus,
// it's useless to try the flattened layout.
if string.is_multiline(context) {
return None;
}
Some(FormatFString::new(
f_string,
f_string_quoting(expr, context.source()),
))
}
#[derive(Debug, Default)]
struct OptionalParenthesesInlinedComments<'a> {
expression: &'a [SourceComment],

View file

@ -154,7 +154,7 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
}
// Multiline strings can never fit on a single line.
if !string.is_fstring() && string.is_multiline(context.source()) {
if string.is_multiline(context) {
return None;
}
@ -187,25 +187,6 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
}
if let StringLikePart::FString(fstring) = part {
if fstring.elements.iter().any(|element| match element {
// Same as for other literals. Multiline literals can't fit on a single line.
FStringElement::Literal(literal) => {
context.source().contains_line_break(literal.range())
}
FStringElement::Expression(expression) => {
if is_f_string_formatting_enabled(context) {
// Expressions containing comments can't be joined.
context.comments().contains_comments(expression.into())
} else {
// Multiline f-string expressions can't be joined if the f-string formatting is disabled because
// the string gets inserted in verbatim preserving the newlines.
context.source().contains_line_break(expression.range())
}
}
}) {
return None;
}
if context.options().target_version().supports_pep_701() {
if is_fstring_with_quoted_format_spec_and_debug(fstring, context) {
if preserve_quotes_requirement

View file

@ -1,16 +1,18 @@
use memchr::memchr2;
pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer};
use ruff_python_ast::str::Quote;
use ruff_python_ast::StringLikePart;
use ruff_python_ast::{
self as ast,
str_prefix::{AnyStringPrefix, StringLiteralPrefix},
AnyStringFlags, StringFlags,
};
use ruff_source_file::LineRanges;
use ruff_text_size::Ranged;
use crate::expression::expr_f_string::f_string_quoting;
use crate::prelude::*;
use crate::preview::is_f_string_formatting_enabled;
use crate::QuoteStyle;
pub(crate) mod docstring;
@ -90,7 +92,7 @@ impl From<Quote> for QuoteStyle {
pub(crate) trait StringLikeExtensions {
fn quoting(&self, source: &str) -> Quoting;
fn is_multiline(&self, source: &str) -> bool;
fn is_multiline(&self, context: &PyFormatContext) -> bool;
}
impl StringLikeExtensions for ast::StringLike<'_> {
@ -101,15 +103,59 @@ impl StringLikeExtensions for ast::StringLike<'_> {
}
}
fn is_multiline(&self, source: &str) -> bool {
match self {
Self::String(_) | Self::Bytes(_) => self.parts().any(|part| {
fn is_multiline(&self, context: &PyFormatContext) -> bool {
self.parts().any(|part| match part {
StringLikePart::String(_) | StringLikePart::Bytes(_) => {
part.flags().is_triple_quoted()
&& memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some()
}),
Self::FString(fstring) => {
memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some()
&& context.source().contains_line_break(part.range())
}
}
StringLikePart::FString(f_string) => {
fn contains_line_break_or_comments(
elements: &ast::FStringElements,
context: &PyFormatContext,
) -> bool {
elements.iter().any(|element| match element {
ast::FStringElement::Literal(literal) => {
context.source().contains_line_break(literal.range())
}
ast::FStringElement::Expression(expression) => {
if is_f_string_formatting_enabled(context) {
// Expressions containing comments can't be joined.
//
// Format specifiers needs to be checked as well. For example, the
// following should be considered multiline because the literal
// part of the format specifier contains a newline at the end
// (`.3f\n`):
//
// ```py
// x = f"hello {a + b + c + d:.3f
// } world"
// ```
context.comments().contains_comments(expression.into())
|| expression.format_spec.as_deref().is_some_and(|spec| {
contains_line_break_or_comments(&spec.elements, context)
})
|| expression.debug_text.as_ref().is_some_and(|debug_text| {
memchr2(b'\n', b'\r', debug_text.leading.as_bytes())
.is_some()
|| memchr2(b'\n', b'\r', debug_text.trailing.as_bytes())
.is_some()
})
} else {
// Multiline f-string expressions can't be joined if the f-string
// formatting is disabled because the string gets inserted in
// verbatim preserving the newlines.
//
// We don't need to check format specifiers or debug text here
// because the expression range already includes them.
context.source().contains_line_break(expression.range())
}
}
})
}
contains_line_break_or_comments(&f_string.elements, context)
}
})
}
}

View file

@ -1,7 +1,6 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/binary.py
snapshot_kind: text
---
## Input
```python
@ -425,6 +424,12 @@ if True:
"permissions to manage this role, or else members of this role won't receive "
"a notification."
)
# This f-string should be flattened
xxxxxxxxxxxxxxxx = f"aaaaaaaaaaaaaaaaaaaaa {
expression } bbbbbbbbbbbbbbbbbbbbbbbb" + (
yyyyyyyyyyyyyy + zzzzzzzzzzz
)
```
## Output
@ -897,4 +902,24 @@ if True:
"permissions to manage this role, or else members of this role won't receive "
"a notification."
)
# This f-string should be flattened
xxxxxxxxxxxxxxxx = f"aaaaaaaaaaaaaaaaaaaaa {
expression } bbbbbbbbbbbbbbbbbbbbbbbb" + (yyyyyyyyyyyyyy + zzzzzzzzzzz)
```
## Preview changes
```diff
--- Stable
+++ Preview
@@ -468,5 +468,6 @@
)
# This f-string should be flattened
-xxxxxxxxxxxxxxxx = f"aaaaaaaaaaaaaaaaaaaaa {
- expression } bbbbbbbbbbbbbbbbbbbbbbbb" + (yyyyyyyyyyyyyy + zzzzzzzzzzz)
+xxxxxxxxxxxxxxxx = f"aaaaaaaaaaaaaaaaaaaaa {expression} bbbbbbbbbbbbbbbbbbbbbbbb" + (
+ yyyyyyyyyyyyyy + zzzzzzzzzzz
+)
```