Skip to content

Commit 710f128

Browse files
committed
Relax restrictions about backslashes in pep701 f-strings
1 parent 0a7cd60 commit 710f128

5 files changed

Lines changed: 138 additions & 45 deletions

File tree

corpus_test/generate_report.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,9 @@ def format_size_change_detail() -> str:
338338
got_smaller_count = len(list(summary.compare_size_decrease(base_summary)))
339339

340340
if got_bigger_count > 0:
341-
s += f', {got_bigger_count}:chart_with_upwards_trend:'
341+
s += f', {got_bigger_count} :chart_with_upwards_trend:'
342342
if got_smaller_count > 0:
343-
s += f', {got_smaller_count}:chart_with_downwards_trend:'
343+
s += f', {got_smaller_count} :chart_with_downwards_trend:'
344344

345345
s += ')'
346346

src/python_minifier/expression_printer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -736,11 +736,11 @@ def visit_JoinedStr(self, node):
736736
import python_minifier.f_string
737737

738738
if sys.version_info < (3, 12):
739-
quote_reuse = False
739+
pep701 = False
740740
else:
741-
quote_reuse = True
741+
pep701 = True
742742

743-
self.printer.fstring(str(python_minifier.f_string.OuterFString(node, quote_reuse=quote_reuse)))
743+
self.printer.fstring(str(python_minifier.f_string.OuterFString(node, pep701=pep701)))
744744

745745
def visit_NamedExpr(self, node):
746746
self._expression(node.target)

src/python_minifier/f_string.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ class FString(object):
2424
An F-string in the expression part of another f-string
2525
"""
2626

27-
def __init__(self, node, allowed_quotes, quote_reuse):
27+
def __init__(self, node, allowed_quotes, pep701):
2828
assert isinstance(node, ast.JoinedStr)
2929

3030
self.node = node
3131
self.allowed_quotes = allowed_quotes
32-
self.quote_reuse = quote_reuse
32+
self.pep701 = pep701
3333

3434
def is_correct_ast(self, code):
3535
try:
@@ -54,7 +54,7 @@ def complete_debug_specifier(self, partial_specifier_candidates, value_node):
5454
conversion_candidates = [x + conversion for x in partial_specifier_candidates]
5555

5656
if value_node.format_spec is not None:
57-
conversion_candidates = [c + ':' + fs for c in conversion_candidates for fs in FormatSpec(value_node.format_spec, self.allowed_quotes, self.quote_reuse).candidates()]
57+
conversion_candidates = [c + ':' + fs for c in conversion_candidates for fs in FormatSpec(value_node.format_spec, self.allowed_quotes, self.pep701).candidates()]
5858

5959
return [x + '}' for x in conversion_candidates]
6060

@@ -66,7 +66,7 @@ def candidates(self):
6666
debug_specifier_candidates = []
6767
nested_allowed = copy.copy(self.allowed_quotes)
6868

69-
if not self.quote_reuse:
69+
if not self.pep701:
7070
nested_allowed.remove(quote)
7171

7272
for v in self.node.values:
@@ -90,7 +90,7 @@ def candidates(self):
9090
try:
9191
completed = self.complete_debug_specifier(debug_specifier_candidates, v)
9292
candidates = [
93-
x + y for x in candidates for y in FormattedValue(v, nested_allowed, self.quote_reuse).get_candidates()
93+
x + y for x in candidates for y in FormattedValue(v, nested_allowed, self.pep701).get_candidates()
9494
] + completed
9595
debug_specifier_candidates = []
9696
except Exception as e:
@@ -115,9 +115,9 @@ class OuterFString(FString):
115115
OuterFString is free to use backslashes in the Str parts
116116
"""
117117

118-
def __init__(self, node, quote_reuse=False):
118+
def __init__(self, node, pep701=False):
119119
assert isinstance(node, ast.JoinedStr)
120-
super(OuterFString, self).__init__(node, ['"', "'", '"""', "'''"], quote_reuse=quote_reuse)
120+
super(OuterFString, self).__init__(node, ['"', "'", '"""', "'''"], pep701=pep701)
121121

122122
def __str__(self):
123123
if len(self.node.values) == 0:
@@ -155,13 +155,13 @@ class FormattedValue(ExpressionPrinter):
155155
An F-String Expression Part
156156
"""
157157

158-
def __init__(self, node, allowed_quotes, quote_reuse):
158+
def __init__(self, node, allowed_quotes, pep701):
159159
super(FormattedValue, self).__init__()
160160

161161
assert isinstance(node, ast.FormattedValue)
162162
self.node = node
163163
self.allowed_quotes = allowed_quotes
164-
self.quote_reuse = quote_reuse
164+
self.pep701 = pep701
165165
self.candidates = ['']
166166

167167
def get_candidates(self):
@@ -182,7 +182,7 @@ def get_candidates(self):
182182

183183
if self.node.format_spec is not None:
184184
self.printer.delimiter(':')
185-
self._append(FormatSpec(self.node.format_spec, self.allowed_quotes, quote_reuse=self.quote_reuse).candidates())
185+
self._append(FormatSpec(self.node.format_spec, self.allowed_quotes, pep701=self.pep701).candidates())
186186

187187
self.printer.delimiter('}')
188188

@@ -211,7 +211,7 @@ def is_curly(self, node):
211211
return False
212212

213213
def visit_Str(self, node):
214-
self.printer.append(str(Str(node.s, self.allowed_quotes)), TokenTypes.NonNumberLiteral)
214+
self.printer.append(str(Str(node.s, self.allowed_quotes, self.pep701)), TokenTypes.NonNumberLiteral)
215215

216216
def visit_Bytes(self, node):
217217
self.printer.append(str(Bytes(node.s, self.allowed_quotes)), TokenTypes.NonNumberLiteral)
@@ -220,7 +220,7 @@ def visit_JoinedStr(self, node):
220220
assert isinstance(node, ast.JoinedStr)
221221
if self.printer.previous_token in [TokenTypes.Identifier, TokenTypes.Keyword, TokenTypes.SoftKeyword]:
222222
self.printer.delimiter(' ')
223-
self._append(FString(node, allowed_quotes=self.allowed_quotes, quote_reuse=self.quote_reuse).candidates())
223+
self._append(FString(node, allowed_quotes=self.allowed_quotes, pep701=self.pep701).candidates())
224224

225225
def _finalize(self):
226226
self.candidates = [x + str(self.printer) for x in self.candidates]
@@ -235,20 +235,21 @@ class Str(object):
235235
"""
236236
A Str node inside an f-string expression
237237
238-
May use any of the allowed quotes, no backslashes!
238+
May use any of the allowed quotes. In Python <3.12, backslashes are not allowed.
239239
240240
"""
241241

242-
def __init__(self, s, allowed_quotes):
242+
def __init__(self, s, allowed_quotes, pep701=False):
243243
self._s = s
244244
self.allowed_quotes = allowed_quotes
245245
self.current_quote = None
246+
self.pep701 = pep701
246247

247248
def _can_quote(self, c):
248249
if self.current_quote is None:
249250
return False
250251

251-
if (c == '\n' or c == '\r') and len(self.current_quote) == 1:
252+
if (c == '\n' or c == '\r') and len(self.current_quote) == 1 and not self.pep701:
252253
return False
253254

254255
if c == self.current_quote[0]:
@@ -258,7 +259,7 @@ def _can_quote(self, c):
258259

259260
def _get_quote(self, c):
260261
for quote in self.allowed_quotes:
261-
if c == '\n' or c == '\r':
262+
if not self.pep701 and (c == '\n' or c == '\r'):
262263
if len(quote) == 3:
263264
return quote
264265
elif c != quote:
@@ -279,7 +280,13 @@ def _literals(self):
279280

280281
if l == '':
281282
l += self.current_quote
282-
l += c
283+
284+
if c == '\n':
285+
l += '\\n'
286+
elif c == '\r':
287+
l += '\\r'
288+
else:
289+
l += c
283290

284291
if l:
285292
l += self.current_quote
@@ -292,7 +299,7 @@ def __str__(self):
292299
if '\0' in self._s or '\\' in self._s:
293300
raise ValueError('Impossible to represent a %r character in f-string expression part')
294301

295-
if '\n' in self._s or '\r' in self._s:
302+
if not self.pep701 and ('\n' in self._s or '\r' in self._s):
296303
if '"""' not in self.allowed_quotes and "'''" not in self.allowed_quotes:
297304
raise ValueError(
298305
'Impossible to represent newline character in f-string expression part without a long quote'
@@ -324,12 +331,12 @@ class FormatSpec(object):
324331
325332
"""
326333

327-
def __init__(self, node, allowed_quotes, quote_reuse):
334+
def __init__(self, node, allowed_quotes, pep701):
328335
assert isinstance(node, ast.JoinedStr)
329336

330337
self.node = node
331338
self.allowed_quotes = allowed_quotes
332-
self.quote_reuse = quote_reuse
339+
self.pep701 = pep701
333340

334341
def candidates(self):
335342

@@ -339,7 +346,7 @@ def candidates(self):
339346
candidates = [x + self.str_for(v.s) for x in candidates]
340347
elif isinstance(v, ast.FormattedValue):
341348
candidates = [
342-
x + y for x in candidates for y in FormattedValue(v, self.allowed_quotes, self.quote_reuse).get_candidates()
349+
x + y for x in candidates for y in FormattedValue(v, self.allowed_quotes, self.pep701).get_candidates()
343350
]
344351
else:
345352
raise RuntimeError('Unexpected JoinedStr value')

test/test_empty_fstring.py

Lines changed: 0 additions & 19 deletions
This file was deleted.

test/test_fstring.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import ast
2+
import sys
3+
4+
import pytest
5+
6+
from python_minifier import unparse
7+
from python_minifier.ast_compare import compare_ast
8+
9+
10+
@pytest.mark.parametrize('statement', [
11+
'f"{1=!r:.4}"',
12+
'f"{1=:.4}"',
13+
'f"{1=!s:.4}"',
14+
'f"{1=:.4}"',
15+
'f"{1}"',
16+
'f"{1=}"',
17+
'f"{1=!s}"',
18+
'f"{1=!a}"'
19+
])
20+
def test_fstring_statement(statement):
21+
if sys.version_info < (3, 8):
22+
pytest.skip('f-string debug specifier added in python 3.8')
23+
24+
assert unparse(ast.parse(statement)) == statement
25+
26+
def test_pep0701():
27+
if sys.version_info < (3, 12):
28+
pytest.skip('f-string syntax is bonkers before python 3.12')
29+
30+
statement = 'f"{f"{f"{f"{"hello"}"}"}"}"'
31+
assert unparse(ast.parse(statement)) == statement
32+
33+
statement = 'f"This is the playlist: {", ".join([])}"'
34+
assert unparse(ast.parse(statement)) == statement
35+
36+
statement = 'f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}"'
37+
assert unparse(ast.parse(statement)) == statement
38+
39+
statement = """
40+
f"This is the playlist: {", ".join([
41+
'Take me back to Eden', # My, my, those eyes like fire
42+
'Alkaline', # Not acid nor alkaline
43+
'Ascensionism' # Take to the broken skies at last
44+
])}"
45+
"""
46+
assert unparse(ast.parse(statement)) == 'f"This is the playlist: {", ".join(["Take me back to Eden","Alkaline","Ascensionism"])}"'
47+
48+
statement = '''print(f"This is the playlist: {"\N{BLACK HEART SUIT}".join(songs)}")'''
49+
assert unparse(ast.parse(statement)) == statement
50+
51+
statement = '''f"Magic wand: {bag["wand"]}"'''
52+
assert unparse(ast.parse(statement)) == statement
53+
54+
statement = """
55+
f'''A complex trick: {
56+
bag['bag'] # recursive bags!
57+
}'''
58+
"""
59+
assert unparse(ast.parse(statement)) == 'f"A complex trick: {bag["bag"]}"'
60+
61+
statement = '''f"These are the things: {", ".join(things)}"'''
62+
assert unparse(ast.parse(statement)) == statement
63+
64+
statement = '''f"{source.removesuffix(".py")}.c: $(srcdir)/{source}"'''
65+
assert unparse(ast.parse(statement)) == statement
66+
67+
statement = '''f"{f"{f"infinite"}"}"+' '+f"{f"nesting!!!"}"'''
68+
assert unparse(ast.parse(statement)) == statement
69+
70+
statement = '''f"{"\\n".join(a)}"'''
71+
assert unparse(ast.parse(statement)) == statement
72+
73+
statement = '''f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}"'''
74+
assert unparse(ast.parse(statement)) == statement
75+
76+
statement = '''f"{"":*^{1:{1}}}"'''
77+
assert unparse(ast.parse(statement)) == statement
78+
79+
#statement = '''f"{"":*^{1:{1:{1}}}}"'''
80+
#assert unparse(ast.parse(statement)) == statement
81+
# SyntaxError: f-string: expressions nested too deeply
82+
83+
statement = '''f"___{
84+
x
85+
}___"'''
86+
assert unparse(ast.parse(statement)) == '''f"___{x}___"'''
87+
88+
statement = '''f"___{(
89+
x
90+
)}___"'''
91+
assert unparse(ast.parse(statement)) == '''f"___{x}___"'''
92+
93+
def test_fstring_empty_str():
94+
if sys.version_info < (3, 6):
95+
pytest.skip('f-string expressions not allowed in python < 3.6')
96+
97+
source = r'''
98+
f"""\
99+
{fg_br}"""
100+
'''
101+
102+
print(source)
103+
expected_ast = ast.parse(source)
104+
actual_ast = unparse(expected_ast)
105+
compare_ast(expected_ast, ast.parse(actual_ast))

0 commit comments

Comments
 (0)