Skip to content

Commit a36878e

Browse files
authored
Fix an invalid quote escaping bug in f-string expressions (#3509)
Fixes #3506 We can't simply escape the quotes in a naked f-string when merging string groups, because backslashes are invalid. The quotes in f-string expressions should be toggled (this is safe since quotes can't be reused). This fix also means implicitly concatenated f-strings with different quotes can now be merged or quote-normalized by changing the quotes used in expressions. e.g.: ```diff raise sa_exc.UnboundExecutionError( "Could not locate a bind configured on " - f'{", ".join(context)} or this Session.' + f"{', '.join(context)} or this Session." ) ```
1 parent eabff67 commit a36878e

File tree

3 files changed

+51
-0
lines changed

3 files changed

+51
-0
lines changed

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
- Wrap multiple context managers in parentheses when targeting Python 3.9+ (#3489)
4040
- Fix several crashes in preview style with walrus operators used in `with` statements
4141
or tuples (#3473)
42+
- Fix an invalid quote escaping bug in f-string expressions where it produced invalid
43+
code. Implicitly concatenated f-strings with different quotes can now be merged or
44+
quote-normalized by changing the quotes used in expressions. (#3509)
4245

4346
### Configuration
4447

src/black/trans.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,12 @@ def make_naked(string: str, string_prefix: str) -> str:
572572
characters have been escaped.
573573
"""
574574
assert_is_leaf_string(string)
575+
if "f" in string_prefix:
576+
string = _toggle_fexpr_quotes(string, QUOTE)
577+
# After quotes toggling, quotes in expressions won't be escaped
578+
# because quotes can't be reused in f-strings. So we can simply
579+
# let the escaping logic below run without knowing f-string
580+
# expressions.
575581

576582
RE_EVEN_BACKSLASHES = r"(?:(?<!\\)(?:\\\\)*)"
577583
naked_string = string[len(string_prefix) + 1 : -1]
@@ -1240,6 +1246,30 @@ def fstring_contains_expr(s: str) -> bool:
12401246
return any(iter_fexpr_spans(s))
12411247

12421248

1249+
def _toggle_fexpr_quotes(fstring: str, old_quote: str) -> str:
1250+
"""
1251+
Toggles quotes used in f-string expressions that are `old_quote`.
1252+
1253+
f-string expressions can't contain backslashes, so we need to toggle the
1254+
quotes if the f-string itself will end up using the same quote. We can
1255+
simply toggle without escaping because, quotes can't be reused in f-string
1256+
expressions. They will fail to parse.
1257+
1258+
NOTE: If PEP 701 is accepted, above statement will no longer be true.
1259+
Though if quotes can be reused, we can simply reuse them without updates or
1260+
escaping, once Black figures out how to parse the new grammar.
1261+
"""
1262+
new_quote = "'" if old_quote == '"' else '"'
1263+
parts = []
1264+
previous_index = 0
1265+
for start, end in iter_fexpr_spans(fstring):
1266+
parts.append(fstring[previous_index:start])
1267+
parts.append(fstring[start:end].replace(old_quote, new_quote))
1268+
previous_index = end
1269+
parts.append(fstring[previous_index:])
1270+
return "".join(parts)
1271+
1272+
12431273
class StringSplitter(BaseStringSplitter, CustomSplitMapMixin):
12441274
"""
12451275
StringTransformer that splits "atom" strings (i.e. strings which exist on

tests/data/preview/long_strings__regression.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,16 @@ async def foo(self):
550550
("item1", "item2", "item3"),
551551
}
552552

553+
# Regression test for https://github.com/psf/black/issues/3506.
554+
s = (
555+
"With single quote: ' "
556+
f" {my_dict['foo']}"
557+
' With double quote: " '
558+
f' {my_dict["bar"]}'
559+
)
560+
561+
s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\''
562+
553563

554564
# output
555565

@@ -1235,3 +1245,11 @@ async def foo(self):
12351245
# And there is a comment before the value
12361246
("item1", "item2", "item3"),
12371247
}
1248+
1249+
# Regression test for https://github.com/psf/black/issues/3506.
1250+
s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}"
1251+
1252+
s = (
1253+
"Lorem Ipsum is simply dummy text of the printing and typesetting"
1254+
f" industry:'{my_dict['foo']}'"
1255+
)

0 commit comments

Comments
 (0)