Skip to content

Commit e826248

Browse files
akxclaude
andcommitted
Fix conversion of format strings with nested field references in format specs
'{0:>{1}}'.format(a, b) was incorrectly producing f'{a:>{{1}}}' (escaped braces) instead of f'{a:>{b}}'. The format spec was treated as a literal string, so nested field references like {1} were never resolved. Parse the format spec for nested fields and build a proper JoinedStr AST node with resolved references. This also enables converting the previously skipped "{:{}}".format(x, y) case, as well as named, mixed implicit/explicit, and multi-field nested specs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b66f970 commit e826248

3 files changed

Lines changed: 118 additions & 11 deletions

File tree

src/flynt/transform/format_call_transforms.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import ast
2-
import string
32
from collections import deque
43
from typing import Any, Dict, List, Union
54

65
from flynt.exceptions import ConversionRefused, FlyntException
76
from flynt.utils.utils import (
8-
ast_formatted_value,
7+
ast_formatted_value_with_nested,
98
ast_string_node,
109
get_str_value,
1110
is_str_constant,
11+
stdlib_parse,
1212
)
1313

14-
stdlib_parse = string.Formatter().parse
15-
1614

1715
def joined_string(
1816
fmt_call: ast.Call,
@@ -100,7 +98,16 @@ def joined_string(
10098

10199
if suffix:
102100
ast_name = ast.Attribute(value=ast_name, attr=suffix)
103-
new_segments.append(ast_formatted_value(ast_name, fmt_str, conversion))
101+
102+
node, consumed, used_keys = ast_formatted_value_with_nested(
103+
ast_name, fmt_str, conversion, var_map=var_map, seq_ctr=seq_ctr
104+
)
105+
seq_ctr += consumed
106+
if not aggressive:
107+
for key in used_keys:
108+
var_map.pop(key, None)
109+
110+
new_segments.append(node)
104111

105112
if var_map and not aggressive:
106113
raise FlyntException(

src/flynt/utils/utils.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
import codecs
33
import io
44
import re
5+
import string
56
import tokenize
67
from typing import Dict, List, Optional, Union
78

89
from flynt.exceptions import ConversionRefused
910
from flynt.linting.fstr_lint import FstrInliner
1011
from flynt.utils.format import QuoteTypes, get_quote_type, set_quote_type
1112

13+
stdlib_parse = string.Formatter().parse
14+
1215

1316
def ast_to_string(node: ast.AST) -> str:
1417
"""Convert ``node`` back into source code."""
@@ -80,8 +83,70 @@ def ast_formatted_value(
8083
fmt_str: Optional[str] = None,
8184
conversion: Optional[str] = None,
8285
) -> Union[ast.FormattedValue, ast.Constant]:
86+
return _ast_formatted_value_impl(val, fmt_str, conversion)[0]
87+
88+
89+
def ast_formatted_value_with_nested(
90+
val: ast.AST,
91+
fmt_str: Optional[str],
92+
conversion: Optional[str],
93+
var_map: Dict,
94+
seq_ctr: int = 0,
95+
) -> tuple[Union[ast.FormattedValue, ast.Constant], int, set]:
96+
"""Like :func:`ast_formatted_value` but resolves nested field references.
97+
98+
Returns ``(node, implicit_count, used_keys)`` where *implicit_count* is the
99+
number of implicit positional fields consumed from the format spec and
100+
*used_keys* is the set of var_map keys referenced by the format spec.
101+
"""
102+
return _ast_formatted_value_impl(val, fmt_str, conversion, var_map, seq_ctr)
103+
104+
105+
def _build_format_spec(
106+
fmt_str: str,
107+
var_map: Dict,
108+
seq_ctr: int = 0,
109+
) -> tuple[ast.JoinedStr, int, set]:
110+
"""Build a JoinedStr for a format spec, resolving nested field references.
111+
112+
Returns ``(node, implicit_count, used_keys)`` where *implicit_count* is the
113+
number of implicit positional fields consumed from *seq_ctr* onwards and
114+
*used_keys* is the set of var_map keys referenced.
115+
"""
116+
parts: List[Union[ast.Constant, ast.FormattedValue]] = []
117+
consumed = 0
118+
used_keys = set()
119+
for literal, field_name, nested_fmt, _conv in stdlib_parse(fmt_str):
120+
if literal:
121+
parts.append(ast_string_node(literal))
122+
if field_name is not None:
123+
if field_name.isdigit():
124+
key: Union[str, int] = int(field_name)
125+
elif field_name == "":
126+
key = seq_ctr + consumed
127+
consumed += 1
128+
else:
129+
key = field_name
130+
used_keys.add(key)
131+
parts.append(
132+
ast.FormattedValue(
133+
value=var_map[key],
134+
conversion=-1,
135+
format_spec=None,
136+
)
137+
)
138+
return ast.JoinedStr(parts), consumed, used_keys
139+
140+
141+
def _ast_formatted_value_impl(
142+
val: ast.AST,
143+
fmt_str: Optional[str] = None,
144+
conversion: Optional[str] = None,
145+
var_map: Optional[Dict] = None,
146+
seq_ctr: int = 0,
147+
) -> tuple[Union[ast.FormattedValue, ast.Constant], int, set]:
83148
if isinstance(val, ast.FormattedValue):
84-
return val
149+
return val, 0, set()
85150

86151
if ast_to_string(val).startswith("{"):
87152
raise ConversionRefused(
@@ -100,21 +165,29 @@ def ast_formatted_value(
100165
conversion = f"!{'s' if val.func.id == 'str' else 'r'}"
101166
val = val.args[0]
102167

168+
consumed = 0
169+
used_keys: set = set()
103170
if fmt_str:
104-
format_spec = ast.JoinedStr([ast_string_node(fmt_str)])
171+
if var_map is not None and "{" in fmt_str:
172+
format_spec, consumed, used_keys = _build_format_spec(
173+
fmt_str, var_map, seq_ctr=seq_ctr
174+
)
175+
else:
176+
format_spec = ast.JoinedStr([ast_string_node(fmt_str)])
105177
else:
106178
format_spec = None
107179

108180
conversion_val = -1 if conversion is None else ord(conversion.replace("!", ""))
109181

110182
if format_spec is None and is_str_constant(val):
111-
return val # type:ignore[return-value]
183+
return val, consumed, used_keys # type:ignore[return-value]
112184

113-
return ast.FormattedValue(
185+
node = ast.FormattedValue(
114186
value=val,
115187
conversion=conversion_val,
116188
format_spec=format_spec,
117189
)
190+
return (node, consumed, used_keys)
118191

119192

120193
def ast_string_node(string: str) -> ast.Constant:

test/test_transform.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,6 @@ def test_digit_grouping_2(state: State):
155155
# not enough placeholders / placeholders missing
156156
'"{}{}".format(a)',
157157
'"{a}{b}".format(a=a)',
158-
# too complex syntax
159-
'"{:{}}".format(x, y)',
160158
'"{}".format(b"\\n")',
161159
'"{}".format("\\n".join(items))',
162160
'msg = "{}\\nPossible solutions:\\n{}".format(msg, "\\n".join(solutions))',
@@ -191,6 +189,10 @@ def test_fix_fstrings_noop(s, state: State):
191189
('"{}" . format(x)', 'f"""{x}"""'),
192190
# spans multiple lines
193191
('"{}".format(\n a,\n)', 'f"""{a}"""'),
192+
# nested field references in format spec
193+
('"{:{}}".format(x, y)', 'f"""{x:{y}}"""'),
194+
('"{:{fill}}".format(x, fill=c)', 'f"""{x:{c}}"""'),
195+
('"{} {:>{}}".format(a, b, c)', 'f"""{a} {b:>{c}}"""'),
194196
),
195197
)
196198
def test_fix_fstrings(s, expected, state: State):
@@ -199,6 +201,31 @@ def test_fix_fstrings(s, expected, state: State):
199201
assert new == expected
200202

201203

204+
@pytest.mark.parametrize(
205+
("s", "expected"),
206+
(
207+
# nested field references in format spec (needs aggressive for numbered)
208+
(
209+
"""'{0:>{1}}'.format(bench[prop], widths[prop])""",
210+
'f"""{bench[prop]:>{widths[prop]}}"""',
211+
),
212+
(
213+
'"{0:{1}.{2}f}".format(x, w, p)',
214+
'f"""{x:{w}.{p}f}"""',
215+
),
216+
(
217+
'"{0:*>{1}}".format(x, n)',
218+
'f"""{x:*>{n}}"""',
219+
),
220+
),
221+
)
222+
def test_fix_fstrings_aggressive(s, expected, state: State):
223+
state.aggressive = 1
224+
new, changed = transform_chunk_from_str(s, state)
225+
assert changed
226+
assert new == expected
227+
228+
202229
def test_disabled_transforms():
203230
# Test that disabling transforms does disable them
204231
assert not transform_chunk_from_str(

0 commit comments

Comments
 (0)