Skip to content

Commit 8c219bb

Browse files
committed
Python: Workaround to support f-strings on Python 3.11 and older
1 parent d06843a commit 8c219bb

6 files changed

Lines changed: 97 additions & 29 deletions

File tree

rewrite-python/rewrite/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
.idea/
1+
.idea/
2+
.tox/

rewrite-python/rewrite/src/rewrite/python/_parser_visitor.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2285,6 +2285,10 @@ def visit_JoinedStr(self, node):
22852285
while tok.type not in (FSTRING_START, token.STRING):
22862286
tok = self._advance_token()
22872287

2288+
# Python < 3.12: f-strings are a single STRING token; store as opaque J.Literal
2289+
if FSTRING_START == -2 and tok.type == token.STRING:
2290+
return self.__map_fstring_as_literal(node, leading_prefix, tok)
2291+
22882292
value_idx = 0
22892293
res = None
22902294
is_first = True
@@ -3175,6 +3179,65 @@ def _map_assignment_operator(self, op):
31753179
raise ValueError(f"Unsupported operator: {op}")
31763180
return self.__pad_left(self.__source_before(op_str), op)
31773181

3182+
def __map_fstring_as_literal(self, node: ast.JoinedStr, leading_prefix: Space, tok: TokenInfo) -> J:
3183+
"""Fallback for Python < 3.12: treat f-string as an opaque J.Literal.
3184+
3185+
On Python < 3.12, the tokenizer produces a single STRING token for the
3186+
entire f-string. We store it as a J.Literal, preserving round-trip
3187+
fidelity but without structural access to the f-string internals.
3188+
Handles implicit string concatenation (e.g. f"a" "b").
3189+
"""
3190+
res = None
3191+
prefix = leading_prefix
3192+
is_first = True
3193+
while True:
3194+
if not is_first:
3195+
save_idx = self._token_idx
3196+
saw_statement_end = False
3197+
while self._token_idx < len(self._tokens):
3198+
peek_tok = self._tokens[self._token_idx]
3199+
if peek_tok.type == token.NEWLINE:
3200+
saw_statement_end = True
3201+
self._token_idx += 1
3202+
elif peek_tok.type in (token.NL, token.INDENT, token.DEDENT, token.COMMENT,
3203+
token.ENCODING, token.ENDMARKER, WHITESPACE_TOKEN):
3204+
self._token_idx += 1
3205+
else:
3206+
break
3207+
if saw_statement_end or peek_tok.type != token.STRING:
3208+
self._token_idx = save_idx
3209+
break
3210+
self._token_idx = save_idx
3211+
prefix = self.__whitespace()
3212+
tok = self._skip_whitespace_tokens()
3213+
3214+
value_source = tok.string
3215+
self._advance_token()
3216+
current = j.Literal(
3217+
random_id(),
3218+
prefix,
3219+
Markers.EMPTY,
3220+
None,
3221+
value_source,
3222+
None,
3223+
JavaType.Primitive.String,
3224+
)
3225+
if res is None:
3226+
res = current
3227+
else:
3228+
res = py.Binary(
3229+
random_id(),
3230+
Space.EMPTY,
3231+
Markers.EMPTY,
3232+
res,
3233+
self.__pad_left(Space.EMPTY, py.Binary.Type.StringConcatenation),
3234+
None,
3235+
current,
3236+
self._type_mapping.type(node)
3237+
)
3238+
is_first = False
3239+
return res
3240+
31783241
def __map_fstring(self, node: ast.JoinedStr, prefix: Space, tok: TokenInfo, value_idx: int = 0) -> \
31793242
Tuple[J, TokenInfo, int]:
31803243
"""Map an f-string to a FormattedString AST node.

rewrite-python/rewrite/tests/python/all/tree/fstring_test.py

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,6 @@ def test_concat_fstring_6():
8585
))
8686

8787

88-
def test_concat_fstring_7():
89-
# language=python
90-
RecipeSpec().rewrite_run(python("""
91-
_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
92-
"""
93-
))
94-
95-
96-
def test_concat_fstring_8():
97-
# language=python
98-
RecipeSpec().rewrite_run(python('_ = f"n{\' \':{1}}Groups"'))
99-
100-
10188
def test_concat_fstring_9():
10289
# language=python
10390
RecipeSpec().rewrite_run(python('print(f"Progress: {output.escaped_title[:30]:<30} {default_output:>161}")'))
@@ -201,16 +188,6 @@ def test_nested_fstring_conversion_and_format_expr():
201188
RecipeSpec().rewrite_run(python("""a = f'{f"foo"!s:<{5*2}}'"""))
202189

203190

204-
def test_comment_in_expr():
205-
# language=python
206-
RecipeSpec().rewrite_run(python(
207-
"""
208-
f"abc{a # This is a comment }
209-
+ 3}"
210-
"""
211-
))
212-
213-
214191
def test_simple_format_spec():
215192
# language=python
216193
RecipeSpec().rewrite_run(python("a = f'{1:n}'"))
@@ -238,11 +215,6 @@ def test_format_value():
238215
RecipeSpec().rewrite_run(python('''a = f"{'abc':>{2*3}}"'''))
239216

240217

241-
def test_nested_fstring_with_format_value():
242-
# language=python
243-
RecipeSpec().rewrite_run(python("""a = f'{f"{'foo'}":>{2*3}}'"""))
244-
245-
246218
def test_adjoining_expressions():
247219
# language=python
248220
RecipeSpec().rewrite_run(python("""a = f'{1}{0}'"""))
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from rewrite.test import RecipeSpec, python
2+
3+
4+
# PEP 701 — f-string syntax features requiring Python 3.12+
5+
# (nested quote reuse, backslashes in expressions, comments in expressions)
6+
7+
def test_concat_fstring_nested_quotes():
8+
# language=python
9+
RecipeSpec().rewrite_run(python("""
10+
_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
11+
"""
12+
))
13+
14+
15+
def test_fstring_backslash_in_expr():
16+
# language=python
17+
RecipeSpec().rewrite_run(python('_ = f"n{\' \':{1}}Groups"'))
18+
19+
20+
def test_comment_in_expr():
21+
# language=python
22+
RecipeSpec().rewrite_run(python(
23+
"""
24+
f"abc{a # This is a comment }
25+
+ 3}"
26+
"""
27+
))
28+
29+
30+
def test_nested_fstring_with_format_value():
31+
# language=python
32+
RecipeSpec().rewrite_run(python("""a = f'{f"{'foo'}":>{2*3}}'"""))

rewrite-python/rewrite/tests/python/all/tree/type_alias_with_params_test.py renamed to rewrite-python/rewrite/tests/python/py312/type_alias_with_params_test.py

File renamed without changes.

rewrite-python/rewrite/tests/python/all/tree/type_params_test.py renamed to rewrite-python/rewrite/tests/python/py312/type_params_test.py

File renamed without changes.

0 commit comments

Comments
 (0)