Skip to content

Commit 72bf1c2

Browse files
authored
Preview minimal f-string formatting (#9642)
## Summary _This is preview only feature and is available using the `--preview` command-line flag._ With the implementation of [PEP 701] in Python 3.12, f-strings can now be broken into multiple lines, can contain comments, and can re-use the same quote character. Currently, no other Python formatter formats the f-strings so there's some discussion which needs to happen in defining the style used for f-string formatting. Relevant discussion: #9785 The goal for this PR is to add minimal support for f-string formatting. This would be to format expression within the replacement field without introducing any major style changes. ### Newlines The heuristics for adding newline is similar to that of [Prettier](https://prettier.io/docs/en/next/rationale.html#template-literals) where the formatter would only split an expression in the replacement field across multiple lines if there was already a line break within the replacement field. In other words, the formatter would not add any newlines unless they were already present i.e., they were added by the user. This makes breaking any expression inside an f-string optional and in control of the user. For example, ```python # We wouldn't break this aaaaaaaaaaa = f"asaaaaaaaaaaaaaaaa { aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc } cccccccccc" # But, we would break the following as there's already a newline aaaaaaaaaaa = f"asaaaaaaaaaaaaaaaa { aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc } cccccccccc" ``` If there are comments in any of the replacement field of the f-string, then it will always be a multi-line f-string in which case the formatter would prefer to break expressions i.e., introduce newlines. For example, ```python x = f"{ # comment a }" ``` ### Quotes The logic for formatting quotes remains unchanged. The existing logic is used to determine the necessary quote char and is used accordingly. Now, if the expression inside an f-string is itself a string like, then we need to make sure to preserve the existing quote and not change it to the preferred quote unless it's 3.12. For example, ```python f"outer {'inner'} outer" # For pre 3.12, preserve the single quote f"outer {'inner'} outer" # While for 3.12 and later, the quotes can be changed f"outer {"inner"} outer" ``` But, for triple-quoted strings, we can re-use the same quote char unless the inner string is itself a triple-quoted string. ```python f"""outer {"inner"} outer""" # valid f"""outer {'''inner'''} outer""" # preserve the single quote char for the inner string ``` ### Debug expressions If debug expressions are present in the replacement field of a f-string, then the whitespace needs to be preserved as they will be rendered as it is (for example, `f"{ x = }"`. If there are any nested f-strings, then the whitespace in them needs to be preserved as well which means that we'll stop formatting the f-string as soon as we encounter a debug expression. ```python f"outer { x = !s :.3f}" # ^^ # We can remove these whitespaces ``` Now, the whitespace doesn't need to be preserved around conversion spec and format specifiers, so we'll format them as usual but we won't be formatting any nested f-string within the format specifier. ### Miscellaneous - The [`hug_parens_with_braces_and_square_brackets`](#8279) preview style isn't implemented w.r.t. the f-string curly braces. - The [indentation](#9785 (comment)) is always relative to the f-string containing statement ## Test Plan * Add new test cases * Review existing snapshot changes * Review the ecosystem changes [PEP 701]: https://peps.python.org/pep-0701/
1 parent c47ff65 commit 72bf1c2

20 files changed

+1973
-56
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[
2+
{
3+
"preview": "enabled"
4+
},
5+
{
6+
"preview": "disabled"
7+
}
8+
]

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

+223-5
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,30 @@
3030
# an expression inside a formatted value
3131
(
3232
f'{1}'
33-
# comment
33+
# comment 1
3434
''
3535
)
3636

3737
(
38-
f'{1}' # comment
38+
f'{1}' # comment 2
3939
f'{2}'
4040
)
4141

4242
(
4343
f'{1}'
44-
f'{2}' # comment
44+
f'{2}' # comment 3
4545
)
4646

4747
(
48-
1, ( # comment
48+
1, ( # comment 4
4949
f'{2}'
5050
)
5151
)
5252

5353
(
5454
(
5555
f'{1}'
56-
# comment
56+
# comment 5
5757
),
5858
2
5959
)
@@ -62,3 +62,221 @@
6262
x = f'''a{""}b'''
6363
y = f'''c{1}d"""e'''
6464
z = f'''a{""}b''' f'''c{1}d"""e'''
65+
66+
# F-String formatting test cases (Preview)
67+
68+
# Simple expression with a mix of debug expression and comments.
69+
x = f"{a}"
70+
x = f"{
71+
a = }"
72+
x = f"{ # comment 6
73+
a }"
74+
x = f"{ # comment 7
75+
a = }"
76+
77+
# Remove the parentheses as adding them doesn't make then fit within the line length limit.
78+
# This is similar to how we format it before f-string formatting.
79+
aaaaaaaaaaa = (
80+
f"asaaaaaaaaaaaaaaaa { aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc + dddddddd } cccccccccc"
81+
)
82+
# Here, we would use the best fit layout to put the f-string indented on the next line
83+
# similar to the next example.
84+
aaaaaaaaaaa = f"asaaaaaaaaaaaaaaaa { aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc } cccccccccc"
85+
aaaaaaaaaaa = (
86+
f"asaaaaaaaaaaaaaaaa { aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc } cccccccccc"
87+
)
88+
89+
# This should never add the optional parentheses because even after adding them, the
90+
# f-string exceeds the line length limit.
91+
x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc"
92+
x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc"
93+
x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8
94+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc"
95+
x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9
96+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc"
97+
98+
# Multiple larger expressions which exceeds the line length limit. Here, we need to decide
99+
# whether to split at the first or second expression. This should work similarly to the
100+
# assignment statement formatting where we split from right to left in preview mode.
101+
x = f"aaaaaaaaaaaa { bbbbbbbbbbbbbb } cccccccccccccccccccc { ddddddddddddddd } eeeeeeeeeeeeee"
102+
103+
# The above example won't split but when we start introducing line breaks:
104+
x = f"aaaaaaaaaaaa {
105+
bbbbbbbbbbbbbb } cccccccccccccccccccc { ddddddddddddddd } eeeeeeeeeeeeee"
106+
x = f"aaaaaaaaaaaa { bbbbbbbbbbbbbb
107+
} cccccccccccccccccccc { ddddddddddddddd } eeeeeeeeeeeeee"
108+
x = f"aaaaaaaaaaaa { bbbbbbbbbbbbbb } cccccccccccccccccccc {
109+
ddddddddddddddd } eeeeeeeeeeeeee"
110+
x = f"aaaaaaaaaaaa { bbbbbbbbbbbbbb } cccccccccccccccccccc { ddddddddddddddd
111+
} eeeeeeeeeeeeee"
112+
113+
# But, in case comments are present, we would split at the expression containing the
114+
# comments:
115+
x = f"aaaaaaaaaaaa { bbbbbbbbbbbbbb # comment 10
116+
} cccccccccccccccccccc { ddddddddddddddd } eeeeeeeeeeeeee"
117+
x = f"aaaaaaaaaaaa { bbbbbbbbbbbbbb
118+
} cccccccccccccccccccc { # comment 11
119+
ddddddddddddddd } eeeeeeeeeeeeee"
120+
121+
# Here, the expression part itself starts with a curly brace so we need to add an extra
122+
# space between the opening curly brace and the expression.
123+
x = f"{ {'x': 1, 'y': 2} }"
124+
# Although the extra space isn't required before the ending curly brace, we add it for
125+
# consistency.
126+
x = f"{ {'x': 1, 'y': 2}}"
127+
x = f"{ {'x': 1, 'y': 2} = }"
128+
x = f"{ # comment 12
129+
{'x': 1, 'y': 2} }"
130+
x = f"{ # comment 13
131+
{'x': 1, 'y': 2} = }"
132+
133+
# But, in this case, we would split the expression itself because it exceeds the line
134+
# length limit so we need not add the extra space.
135+
xxxxxxx = f"{
136+
{'aaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbb', 'ccccccccccccccccccccc'}
137+
}"
138+
# And, split the expression itself because it exceeds the line length.
139+
xxxxxxx = f"{
140+
{'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'}
141+
}"
142+
143+
# Quotes
144+
f"foo 'bar' {x}"
145+
f"foo \"bar\" {x}"
146+
f'foo "bar" {x}'
147+
f'foo \'bar\' {x}'
148+
f"foo {"bar"}"
149+
f"foo {'\'bar\''}"
150+
151+
# Here, the formatter will remove the escapes which is correct because they aren't allowed
152+
# pre 3.12. This means we can assume that the f-string is used in the context of 3.12.
153+
f"foo {'\"bar\"'}"
154+
155+
156+
# Triple-quoted strings
157+
# It's ok to use the same quote char for the inner string if it's single-quoted.
158+
f"""test {'inner'}"""
159+
f"""test {"inner"}"""
160+
# But if the inner string is also triple-quoted then we should preserve the existing quotes.
161+
f"""test {'''inner'''}"""
162+
163+
# Magic trailing comma
164+
#
165+
# The expression formatting will result in breaking it across multiple lines with a
166+
# trailing comma but as the expression isn't already broken, we will remove all the line
167+
# breaks which results in the trailing comma being present. This test case makes sure
168+
# that the trailing comma is removed as well.
169+
f"aaaaaaa {['aaaaaaaaaaaaaaa', 'bbbbbbbbbbbbb', 'ccccccccccccccccc', 'ddddddddddddddd', 'eeeeeeeeeeeeee']} aaaaaaa"
170+
171+
# And, if the trailing comma is already present, we still need to remove it.
172+
f"aaaaaaa {['aaaaaaaaaaaaaaa', 'bbbbbbbbbbbbb', 'ccccccccccccccccc', 'ddddddddddddddd', 'eeeeeeeeeeeeee',]} aaaaaaa"
173+
174+
# Keep this Multiline by breaking it at the square brackets.
175+
f"""aaaaaa {[
176+
xxxxxxxx,
177+
yyyyyyyy,
178+
]} ccc"""
179+
180+
# Add the magic trailing comma because the elements don't fit within the line length limit
181+
# when collapsed.
182+
f"aaaaaa {[
183+
xxxxxxxxxxxx,
184+
xxxxxxxxxxxx,
185+
xxxxxxxxxxxx,
186+
xxxxxxxxxxxx,
187+
xxxxxxxxxxxx,
188+
xxxxxxxxxxxx,
189+
yyyyyyyyyyyy
190+
]} ccccccc"
191+
192+
# Remove the parenthese because they aren't required
193+
xxxxxxxxxxxxxxx = (
194+
f"aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbb {
195+
xxxxxxxxxxx # comment 14
196+
+ yyyyyyyyyy
197+
} dddddddddd"
198+
)
199+
200+
# Comments
201+
202+
# No comments should be dropped!
203+
f"{ # comment 15
204+
# comment 16
205+
foo # comment 17
206+
# comment 18
207+
}" # comment 19
208+
# comment 20
209+
210+
# Conversion flags
211+
#
212+
# This is not a valid Python code because of the additional whitespace between the `!`
213+
# and conversion type. But, our parser isn't strict about this. This should probably be
214+
# removed once we have a strict parser.
215+
x = f"aaaaaaaaa { x ! r }"
216+
217+
# Even in the case of debug expresions, we only need to preserve the whitespace within
218+
# the expression part of the replacement field.
219+
x = f"aaaaaaaaa { x = ! r }"
220+
221+
# Combine conversion flags with format specifiers
222+
x = f"{x = ! s
223+
:>0
224+
225+
}"
226+
# This is interesting. There can be a comment after the format specifier but only if it's
227+
# on it's own line. Refer to https://github.com/astral-sh/ruff/pull/7787 for more details.
228+
# We'll format is as trailing comments.
229+
x = f"{x !s
230+
:>0
231+
# comment 21
232+
}"
233+
234+
x = f"""
235+
{ # comment 22
236+
x = :.0{y # comment 23
237+
}f}"""
238+
239+
# Here, the debug expression is in a nested f-string so we should start preserving
240+
# whitespaces from that point onwards. This means we should format the outer f-string.
241+
x = f"""{"foo " + # comment 24
242+
f"{ x =
243+
244+
}" # comment 25
245+
}
246+
"""
247+
248+
# Mix of various features.
249+
f"{ # comment 26
250+
foo # after foo
251+
:>{
252+
x # after x
253+
}
254+
# comment 27
255+
# comment 28
256+
} woah {x}"
257+
258+
# Indentation
259+
260+
# What should be the indentation?
261+
# https://github.com/astral-sh/ruff/discussions/9785#discussioncomment-8470590
262+
if indent0:
263+
if indent1:
264+
if indent2:
265+
foo = f"""hello world
266+
hello {
267+
f"aaaaaaa {
268+
[
269+
'aaaaaaaaaaaaaaaaaaaaa',
270+
'bbbbbbbbbbbbbbbbbbbbb',
271+
'ccccccccccccccccccccc',
272+
'ddddddddddddddddddddd'
273+
]
274+
} bbbbbbbb" +
275+
[
276+
'aaaaaaaaaaaaaaaaaaaaa',
277+
'bbbbbbbbbbbbbbbbbbbbb',
278+
'ccccccccccccccccccccc',
279+
'ddddddddddddddddddddd'
280+
]
281+
} --------
282+
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[
2+
{
3+
"target_version": "py312"
4+
}
5+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# This file contains test cases only for cases where the logic tests for whether
2+
# the target version is 3.12 or later. A user can have 3.12 syntax even if the target
3+
# version isn't set.
4+
5+
# Quotes re-use
6+
f"{'a'}"

crates/ruff_python_formatter/src/builders.rs

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use ruff_formatter::{write, Argument, Arguments};
22
use ruff_text_size::{Ranged, TextRange, TextSize};
33

4-
use crate::context::{NodeLevel, WithNodeLevel};
4+
use crate::context::{FStringState, NodeLevel, WithNodeLevel};
55
use crate::other::commas::has_magic_trailing_comma;
66
use crate::prelude::*;
77

@@ -206,6 +206,16 @@ impl<'fmt, 'ast, 'buf> JoinCommaSeparatedBuilder<'fmt, 'ast, 'buf> {
206206

207207
pub(crate) fn finish(&mut self) -> FormatResult<()> {
208208
self.result.and_then(|()| {
209+
// If the formatter is inside an f-string expression element, and the layout
210+
// is flat, then we don't need to add a trailing comma.
211+
if let FStringState::InsideExpressionElement(context) =
212+
self.fmt.context().f_string_state()
213+
{
214+
if context.layout().is_flat() {
215+
return Ok(());
216+
}
217+
}
218+
209219
if let Some(last_end) = self.entries.position() {
210220
let magic_trailing_comma = has_magic_trailing_comma(
211221
TextRange::new(last_end, self.sequence_end),

crates/ruff_python_formatter/src/comments/placement.rs

+22
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,28 @@ fn handle_enclosed_comment<'a>(
289289
}
290290
}
291291
AnyNodeRef::FString(fstring) => CommentPlacement::dangling(fstring, comment),
292+
AnyNodeRef::FStringExpressionElement(_) => {
293+
// Handle comments after the format specifier (should be rare):
294+
//
295+
// ```python
296+
// f"literal {
297+
// expr:.3f
298+
// # comment
299+
// }"
300+
// ```
301+
//
302+
// This is a valid comment placement.
303+
if matches!(
304+
comment.preceding_node(),
305+
Some(
306+
AnyNodeRef::FStringExpressionElement(_) | AnyNodeRef::FStringLiteralElement(_)
307+
)
308+
) {
309+
CommentPlacement::trailing(comment.enclosing_node(), comment)
310+
} else {
311+
handle_bracketed_end_of_line_comment(comment, locator)
312+
}
313+
}
292314
AnyNodeRef::ExprList(_)
293315
| AnyNodeRef::ExprSet(_)
294316
| AnyNodeRef::ExprListComp(_)

0 commit comments

Comments
 (0)