Skip to content

Commit 4a7a297

Browse files
committed
Fix raw f-string FormatSpecs
1 parent d8d3ec1 commit 4a7a297

File tree

2 files changed

+216
-13
lines changed

2 files changed

+216
-13
lines changed

src/python_minifier/f_string.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import copy
1010
import re
11+
import sys
1112

1213
import python_minifier.ast_compat as ast
1314

@@ -58,11 +59,12 @@ def complete_debug_specifier(self, partial_specifier_candidates, value_node):
5859

5960
return [x + '}' for x in conversion_candidates]
6061

61-
def candidates(self):
62-
actual_candidates = []
63-
62+
def _generate_candidates_with_processor(self, prefix, str_processor):
63+
"""Generate f-string candidates using the given prefix and string processor function."""
64+
candidates = []
65+
6466
for quote in self.allowed_quotes:
65-
candidates = ['']
67+
quote_candidates = ['']
6668
debug_specifier_candidates = []
6769
nested_allowed = copy.copy(self.allowed_quotes)
6870

@@ -71,37 +73,66 @@ def candidates(self):
7173

7274
for v in self.node.values:
7375
if is_constant_node(v, ast.Str):
74-
7576
# Could this be used as a debug specifier?
76-
if len(candidates) < 10:
77+
if len(quote_candidates) < 10:
7778
debug_specifier = re.match(r'.*=\s*$', v.s)
7879
if debug_specifier:
79-
# Maybe!
8080
try:
81-
debug_specifier_candidates = [x + '{' + v.s for x in candidates]
81+
debug_specifier_candidates = [x + '{' + v.s for x in quote_candidates]
8282
except Exception:
8383
continue
8484

8585
try:
86-
candidates = [x + self.str_for(v.s, quote) for x in candidates]
86+
quote_candidates = [x + str_processor(v.s, quote) for x in quote_candidates]
8787
except Exception:
8888
continue
8989
elif isinstance(v, ast.FormattedValue):
9090
try:
9191
completed = self.complete_debug_specifier(debug_specifier_candidates, v)
92-
candidates = [
93-
x + y for x in candidates for y in FormattedValue(v, nested_allowed, self.pep701).get_candidates()
92+
quote_candidates = [
93+
x + y for x in quote_candidates for y in FormattedValue(v, nested_allowed, self.pep701).get_candidates()
9494
] + completed
9595
debug_specifier_candidates = []
9696
except Exception:
9797
continue
9898
else:
9999
raise RuntimeError('Unexpected JoinedStr value')
100100

101-
actual_candidates += ['f' + quote + x + quote for x in candidates]
101+
candidates += [prefix + quote + x + quote for x in quote_candidates]
102+
103+
return candidates
104+
105+
def candidates(self):
106+
actual_candidates = []
107+
108+
# Normal f-string candidates
109+
actual_candidates += self._generate_candidates_with_processor('f', self.str_for)
110+
111+
# Raw f-string candidates (if we detect backslashes)
112+
if self._contains_literal_backslashes():
113+
actual_candidates += self._generate_candidates_with_processor('rf', lambda s, quote: self.raw_str_for(s))
102114

103115
return filter(self.is_correct_ast, actual_candidates)
104116

117+
def raw_str_for(self, s):
118+
"""
119+
Generate string representation for raw f-strings.
120+
Don't escape backslashes like MiniString does.
121+
"""
122+
return s.replace('{', '{{').replace('}', '}}')
123+
124+
def _contains_literal_backslashes(self):
125+
"""
126+
Check if this f-string contains literal backslashes in constant values.
127+
This indicates it may need to be a raw f-string.
128+
"""
129+
for node in ast.walk(self.node):
130+
if is_constant_node(node, ast.Str):
131+
if '\\' in node.s:
132+
return True
133+
return False
134+
135+
105136
def str_for(self, s, quote):
106137
return s.replace('{', '{{').replace('}', '}}')
107138

@@ -360,7 +391,14 @@ def candidates(self):
360391
return candidates
361392

362393
def str_for(self, s):
363-
return s.replace('{', '{{').replace('}', '}}')
394+
# For Python 3.12+ raw f-string regression (fixed in 3.14rc2), we need to escape backslashes
395+
# in format specs so they round-trip correctly
396+
if (3, 12) <= sys.version_info < (3, 14) and '\\' in s:
397+
# In Python 3.12-3.13, format specs need backslashes escaped
398+
escaped = s.replace('\\', '\\\\')
399+
else:
400+
escaped = s
401+
return escaped.replace('{', '{{').replace('}', '}}')
364402

365403

366404
class Bytes(object):

test/test_raw_fstring_backslash.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""
2+
Test for raw f-string with backslash escape sequences.
3+
4+
This test covers the fix for issue where raw f-strings containing backslash
5+
escape sequences in format specs would fail with "Unable to create representation
6+
for f-string" error.
7+
"""
8+
9+
import ast
10+
import sys
11+
12+
import pytest
13+
14+
from python_minifier import unparse
15+
from python_minifier.ast_compare import compare_ast
16+
17+
18+
def test_raw_fstring_backslash_format_spec():
19+
"""Test that raw f-strings with backslash escapes in format specs can be unparsed correctly."""
20+
21+
if sys.version_info < (3, 6):
22+
pytest.skip('F-strings not supported in Python < 3.6')
23+
24+
# This is the minimal case that was failing before the fix
25+
source = 'rf"{x:\\xFF}"'
26+
27+
# This should round-trip correctly without "Unable to create representation for f-string"
28+
expected_ast = ast.parse(source)
29+
actual_code = unparse(expected_ast)
30+
compare_ast(expected_ast, ast.parse(actual_code))
31+
32+
33+
def test_raw_fstring_backslash_outer_str():
34+
"""Test that raw f-strings with backslashes in the outer string parts can be unparsed correctly."""
35+
36+
if sys.version_info < (3, 6):
37+
pytest.skip('F-strings not supported in Python < 3.6')
38+
39+
# Test backslashes in the literal parts of raw f-strings
40+
source = r'rf"\\n{x}\\t"'
41+
42+
expected_ast = ast.parse(source)
43+
actual_code = unparse(expected_ast)
44+
compare_ast(expected_ast, ast.parse(actual_code))
45+
46+
47+
def test_raw_fstring_mixed_backslashes():
48+
"""Test raw f-strings with backslashes in both literal parts and format specs."""
49+
50+
if sys.version_info < (3, 6):
51+
pytest.skip('F-strings not supported in Python < 3.6')
52+
53+
# Test combination of backslashes in literal parts and format specs
54+
source = r'rf"\\n{x:\\xFF}\\t"'
55+
56+
expected_ast = ast.parse(source)
57+
actual_code = unparse(expected_ast)
58+
compare_ast(expected_ast, ast.parse(actual_code))
59+
60+
61+
def test_nested_fstring_backslashes():
62+
"""Test nested f-strings with backslashes (Python 3.12+ only)."""
63+
64+
if sys.version_info < (3, 12):
65+
pytest.skip('Nested f-strings not supported in Python < 3.12')
66+
67+
# Test nested f-strings with backslashes in inner string parts
68+
source = r'f"{f"\\n{x}\\t"}"'
69+
70+
expected_ast = ast.parse(source)
71+
actual_code = unparse(expected_ast)
72+
compare_ast(expected_ast, ast.parse(actual_code))
73+
74+
75+
def test_nested_raw_fstring_backslashes():
76+
"""Test nested raw f-strings with backslashes (Python 3.12+ only)."""
77+
78+
if sys.version_info < (3, 12):
79+
pytest.skip('Nested f-strings not supported in Python < 3.12')
80+
81+
# Test nested raw f-strings with backslashes
82+
source = r'f"{rf"\\xFF{y}\\n"}"'
83+
84+
expected_ast = ast.parse(source)
85+
actual_code = unparse(expected_ast)
86+
compare_ast(expected_ast, ast.parse(actual_code))
87+
88+
89+
def test_nested_fstring_format_spec_backslashes():
90+
"""Test nested f-strings with backslashes in format specs (Python 3.12+ only)."""
91+
92+
if sys.version_info < (3, 12):
93+
pytest.skip('Nested f-strings not supported in Python < 3.12')
94+
95+
# Test nested f-strings with backslashes in format specifications
96+
source = r'f"{f"{x:\\xFF}"}"'
97+
98+
expected_ast = ast.parse(source)
99+
actual_code = unparse(expected_ast)
100+
compare_ast(expected_ast, ast.parse(actual_code))
101+
102+
103+
def test_raw_fstring_literal_single_backslash():
104+
"""Test raw f-string with single backslash in literal part only."""
105+
106+
if sys.version_info < (3, 6):
107+
pytest.skip('F-strings not supported in Python < 3.6')
108+
109+
source = r'rf"\n"'
110+
111+
expected_ast = ast.parse(source)
112+
actual_code = unparse(expected_ast)
113+
compare_ast(expected_ast, ast.parse(actual_code))
114+
115+
116+
def test_raw_fstring_literal_double_backslash():
117+
"""Test raw f-string with double backslash in literal part only."""
118+
119+
if sys.version_info < (3, 6):
120+
pytest.skip('F-strings not supported in Python < 3.6')
121+
122+
source = r'rf"\\n"'
123+
124+
expected_ast = ast.parse(source)
125+
actual_code = unparse(expected_ast)
126+
compare_ast(expected_ast, ast.parse(actual_code))
127+
128+
129+
def test_raw_fstring_formatspec_single_backslash():
130+
"""Test raw f-string with single backslash in format spec only."""
131+
132+
if sys.version_info < (3, 6):
133+
pytest.skip('F-strings not supported in Python < 3.6')
134+
135+
source = r'rf"{x:\xFF}"'
136+
137+
expected_ast = ast.parse(source)
138+
actual_code = unparse(expected_ast)
139+
compare_ast(expected_ast, ast.parse(actual_code))
140+
141+
142+
def test_raw_fstring_formatspec_double_backslash():
143+
"""Test raw f-string with double backslash in format spec only."""
144+
145+
if sys.version_info < (3, 6):
146+
pytest.skip('F-strings not supported in Python < 3.6')
147+
148+
source = r'rf"{x:\\xFF}"'
149+
150+
expected_ast = ast.parse(source)
151+
actual_code = unparse(expected_ast)
152+
compare_ast(expected_ast, ast.parse(actual_code))
153+
154+
155+
def test_raw_fstring_mixed_single_backslashes():
156+
"""Test raw f-string with single backslashes in both literal and format spec parts."""
157+
158+
if sys.version_info < (3, 6):
159+
pytest.skip('F-strings not supported in Python < 3.6')
160+
161+
source = r'rf"\n{x:\xFF}\t"'
162+
163+
expected_ast = ast.parse(source)
164+
actual_code = unparse(expected_ast)
165+
compare_ast(expected_ast, ast.parse(actual_code))

0 commit comments

Comments
 (0)