Skip to content

Commit 1a703ab

Browse files
authored
gh-154719: Preserve trailing whitespace in t-string interpolation expressions (#154762)
1 parent 121e27c commit 1a703ab

6 files changed

Lines changed: 124 additions & 11 deletions

File tree

Lib/test/test_annotationlib.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1869,7 +1869,7 @@ def nested():
18691869
self.assertEqual(type_repr(t'''{ 0
18701870
& 1
18711871
| 2
1872-
}'''), 't"""{ 0\n & 1\n | 2}"""')
1872+
}'''), 't"""{ 0\n & 1\n | 2\n }"""')
18731873
self.assertEqual(
18741874
type_repr(Template("hi", Interpolation(42, "42"))), "t'hi{42}'"
18751875
)

Lib/test/test_fstring.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,6 +1695,14 @@ def __repr__(self):
16951695
self.assertEqual(f'''{f"{d["a#b"]}"=}''',
16961696
'f"{d["a#b"]}"=\'42\'')
16971697

1698+
result = f'''{(
1699+
1, # Force lexer metadata reconstruction.
1700+
"\"#")=}'''
1701+
self.assertEqual(
1702+
result,
1703+
'(\n 1, \n "\\"#")=(1, \'"#\')',
1704+
)
1705+
16981706
self.assertEqual(f'{ # some comment goes here
16991707
"""hello"""=}', ' \n """hello"""=\'hello\'')
17001708
self.assertEqual(f'{"""# this is not a comment

Lib/test/test_tstring.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,47 @@ def test_debug_specifier(self):
136136
# Test white space in debug specifier
137137
t = t"Value: {value = }"
138138
self.assertTStringEqual(
139-
t, ("Value: value = ", ""), [(value, "value", "r")]
139+
t, ("Value: value = ", ""), [(value, "value ", "r")]
140140
)
141141
self.assertEqual(fstring(t), "Value: value = 42")
142142

143+
# Explicit line continuations after the debug marker are part of
144+
# the debug text, not the interpolation expression.
145+
for template, strings, interpolation, rendered in (
146+
(
147+
t"""Value: {value =\
148+
}""",
149+
("Value: value =\\\n", ""),
150+
(value, "value ", "r"),
151+
"Value: value =\\\n42",
152+
),
153+
(
154+
t"""Value: {value =\
155+
!r}""",
156+
("Value: value =\\\n", ""),
157+
(value, "value ", "r"),
158+
"Value: value =\\\n42",
159+
),
160+
(
161+
t"""Value: {value =\
162+
:04}""",
163+
("Value: value =\\\n", ""),
164+
(value, "value ", None, "04"),
165+
"Value: value =\\\n0042",
166+
),
167+
(
168+
t"""Value: {value =\
169+
\
170+
}""",
171+
("Value: value =\\\n\\\n", ""),
172+
(value, "value ", "r"),
173+
"Value: value =\\\n\\\n42",
174+
),
175+
):
176+
with self.subTest(template=template):
177+
self.assertTStringEqual(template, strings, [interpolation])
178+
self.assertEqual(fstring(template), rendered)
179+
143180
class C:
144181
def __format__(self, spec):
145182
return f"FORMAT-{spec}"
@@ -149,6 +186,41 @@ def __format__(self, spec):
149186
self.assertEqual(t.interpolations[0].format_spec,
150187
"FORMAT-value=42")
151188

189+
def test_interpolation_expression_whitespace(self):
190+
x = 42
191+
for template, expected in (
192+
(t"{x}", "x"),
193+
(t"{x }", "x "),
194+
(t"{ x}", " x"),
195+
(t"{ x }", " x "),
196+
(t"{ x }", " x "),
197+
(t"""{
198+
x
199+
}""", "\n x\n"),
200+
(t"{ x !r}", " x "),
201+
(t"{ x :.2f}", " x "),
202+
(t"{ x = }", " x "),
203+
(t"{ x = !r}", " x "),
204+
(t"{ x = :.2f}", " x "),
205+
(t"{x == 42 = }", "x == 42 "),
206+
):
207+
with self.subTest(template=template):
208+
self.assertEqual(
209+
template.interpolations[0].expression,
210+
expected,
211+
)
212+
213+
def test_interpolation_expression_with_reconstructed_metadata(self):
214+
regular = t'''{(
215+
1, # Force lexer metadata reconstruction.
216+
"\"#")}'''
217+
debug = t'''{(
218+
1, # Force lexer metadata reconstruction.
219+
"\"#")=}'''
220+
expected = '(\n 1, \n "\\"#")'
221+
self.assertEqual(regular.interpolations[0].expression, expected)
222+
self.assertEqual(debug.interpolations[0].expression, expected)
223+
152224
def test_raw_tstrings(self):
153225
path = r"C:\Users"
154226
t = rt"{path}\Documents"
@@ -314,12 +386,12 @@ def test_triple_quoted(self):
314386

315387
t = t'{"""a""""#" # outside
316388
}'
317-
self.assertEqual(t.interpolations[0].expression, '"""a""""#"')
389+
self.assertEqual(t.interpolations[0].expression, '"""a""""#" \n')
318390

319391
x, y = 1, 2
320392
t = t'{x != y # outside
321393
}'
322-
self.assertEqual(t.interpolations[0].expression, 'x != y')
394+
self.assertEqual(t.interpolations[0].expression, 'x != y \n')
323395

324396
d = {'a#b': 42}
325397
t = t'''{f"{d["a#b"]}"}'''

Lib/test/test_unparse.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,15 @@ def test_tstrings(self):
216216
self.check_ast_roundtrip('t""')
217217
self.check_ast_roundtrip("t'{(lambda x: x)}'")
218218
self.check_ast_roundtrip("t'{t'{x}'}'")
219+
self.check_ast_roundtrip(
220+
r"""t'''{(
221+
1, # Force lexer metadata reconstruction.
222+
"\"#")}'''"""
223+
)
224+
self.check_ast_roundtrip(
225+
r'''t"""Value: {value =\
226+
}"""'''
227+
)
219228

220229
def test_tstring_with_nonsensical_str_field(self):
221230
# `value` suggests that the original code is `t'{test1}`, but `str` suggests otherwise
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Trailing whitespace in a t-string interpolation expression is now preserved
2+
in :attr:`string.templatelib.Interpolation.expression`, up to the closing ``}``
3+
or the conversion (``!``), format (``:``), or debug (``=``) delimiter.
4+
Explicit line continuations following a debug ``=`` remain part of the debug
5+
text and are excluded from :attr:`~string.templatelib.Interpolation.expression`.

Parser/action_helpers.c

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,21 +1555,38 @@ _get_interpolation_conversion(Parser *p, Token *debug, ResultTokenWithMetadata *
15551555
}
15561556

15571557
static PyObject *
1558-
_strip_interpolation_expr(PyObject *exprstr)
1558+
_strip_interpolation_debug_expr(PyObject *exprstr)
15591559
{
15601560
Py_ssize_t len = PyUnicode_GET_LENGTH(exprstr);
15611561

1562-
for (Py_ssize_t i = len - 1; i >= 0; i--) {
1563-
Py_UCS4 c = PyUnicode_READ_CHAR(exprstr, i);
1564-
if (_PyUnicode_IsWhitespace(c) || c == '=') {
1562+
/* Discard whitespace and explicit line continuations after the debug "="
1563+
but preserve whitespace before it. */
1564+
while (len > 0) {
1565+
int has_newline = 0;
1566+
while (len > 0) {
1567+
Py_UCS4 c = PyUnicode_READ_CHAR(exprstr, len - 1);
1568+
if (!_PyUnicode_IsWhitespace(c)) {
1569+
break;
1570+
}
1571+
if (c == '\r' || c == '\n') {
1572+
has_newline = 1;
1573+
}
15651574
len--;
15661575
}
1567-
else {
1576+
if (!has_newline || len == 0 ||
1577+
PyUnicode_READ_CHAR(exprstr, len - 1) != '\\')
1578+
{
15681579
break;
15691580
}
1581+
len--;
1582+
}
1583+
1584+
/* Preserve unexpected metadata instead of dropping source text. */
1585+
if (len == 0 || PyUnicode_READ_CHAR(exprstr, len - 1) != '=') {
1586+
return Py_NewRef(exprstr);
15701587
}
15711588

1572-
return PyUnicode_Substring(exprstr, 0, len);
1589+
return PyUnicode_Substring(exprstr, 0, len - 1);
15731590
}
15741591

15751592
expr_ty _PyPegen_interpolation(Parser *p, expr_ty expression, Token *debug, ResultTokenWithMetadata *conversion,
@@ -1600,7 +1617,9 @@ expr_ty _PyPegen_interpolation(Parser *p, expr_ty expression, Token *debug, Resu
16001617
}
16011618

16021619
assert(exprstr != NULL);
1603-
PyObject *final_exprstr = _strip_interpolation_expr(exprstr);
1620+
PyObject *final_exprstr = debug
1621+
? _strip_interpolation_debug_expr(exprstr)
1622+
: Py_NewRef(exprstr);
16041623
if (!final_exprstr || _PyArena_AddPyObject(arena, final_exprstr) < 0) {
16051624
Py_XDECREF(final_exprstr);
16061625
return NULL;

0 commit comments

Comments
 (0)