Skip to content

Commit f54fc47

Browse files
committed
Preserve trailing whitespace in t-string interpolation expressions
1 parent 993a0c6 commit f54fc47

6 files changed

Lines changed: 70 additions & 12 deletions

File tree

Lib/test/test_annotationlib.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1862,7 +1862,7 @@ def nested():
18621862
self.assertEqual(type_repr(t'''{ 0
18631863
& 1
18641864
| 2
1865-
}'''), 't"""{ 0\n & 1\n | 2}"""')
1865+
}'''), 't"""{ 0\n & 1\n | 2\n }"""')
18661866
self.assertEqual(
18671867
type_repr(Template("hi", Interpolation(42, "42"))), "t'hi{42}'"
18681868
)

Lib/test/test_tstring.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,45 @@ 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+
def test_interpolation_expression_whitespace(self):
144+
x = 42
145+
for template, expected in (
146+
(t"{x}", "x"),
147+
(t"{x }", "x "),
148+
(t"{ x}", " x"),
149+
(t"{ x }", " x "),
150+
(t"{ x }", " x "),
151+
(t"""{
152+
x
153+
}""", "\n x\n"),
154+
(t"{ x !r}", " x "),
155+
(t"{ x :.2f}", " x "),
156+
(t"{ x = }", " x "),
157+
(t"{ x = !r}", " x "),
158+
(t"{ x = :.2f}", " x "),
159+
(t"{x == 42 = }", "x == 42 "),
160+
):
161+
with self.subTest(template=template):
162+
self.assertEqual(
163+
template.interpolations[0].expression,
164+
expected,
165+
)
166+
167+
def test_interpolation_expression_with_reconstructed_metadata(self):
168+
regular = t'''{(
169+
1, # Force lexer metadata reconstruction.
170+
"\"#")}'''
171+
debug = t'''{(
172+
1, # Force lexer metadata reconstruction.
173+
"\"#")=}'''
174+
expected = '(\n 1, \n "\\"#")'
175+
self.assertEqual(regular.interpolations[0].expression, expected)
176+
self.assertEqual(debug.interpolations[0].expression, expected)
177+
143178
def test_raw_tstrings(self):
144179
path = r"C:\Users"
145180
t = rt"{path}\Documents"

Lib/test/test_unparse.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ 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+
)
219224

220225
def test_tstring_with_nonsensical_str_field(self):
221226
# `value` suggests that the original code is `t'{test1}`, but `str` suggests otherwise
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Whitespace immediately before ``}``, ``!``, ``:``, or ``=`` is now included
2+
in :attr:`string.templatelib.Interpolation.expression` for interpolations
3+
created from t-string literals. Preserve escaped quotes while reconstructing
4+
interpolation expression metadata containing comments.

Parser/action_helpers.c

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,21 +1540,24 @@ _get_interpolation_conversion(Parser *p, Token *debug, ResultTokenWithMetadata *
15401540
}
15411541

15421542
static PyObject *
1543-
_strip_interpolation_expr(PyObject *exprstr)
1543+
_strip_interpolation_debug_expr(PyObject *exprstr)
15441544
{
15451545
Py_ssize_t len = PyUnicode_GET_LENGTH(exprstr);
15461546

1547-
for (Py_ssize_t i = len - 1; i >= 0; i--) {
1548-
Py_UCS4 c = PyUnicode_READ_CHAR(exprstr, i);
1549-
if (_PyUnicode_IsWhitespace(c) || c == '=') {
1550-
len--;
1551-
}
1552-
else {
1547+
/* Discard whitespace after the debug "=" but preserve whitespace before it. */
1548+
while (len > 0) {
1549+
Py_UCS4 c = PyUnicode_READ_CHAR(exprstr, len - 1);
1550+
if (!_PyUnicode_IsWhitespace(c)) {
15531551
break;
15541552
}
1553+
len--;
1554+
}
1555+
/* The debug marker may be absent from reconstructed lexer metadata. */
1556+
if (len == 0 || PyUnicode_READ_CHAR(exprstr, len - 1) != '=') {
1557+
return Py_NewRef(exprstr);
15551558
}
15561559

1557-
return PyUnicode_Substring(exprstr, 0, len);
1560+
return PyUnicode_Substring(exprstr, 0, len - 1);
15581561
}
15591562

15601563
expr_ty _PyPegen_interpolation(Parser *p, expr_ty expression, Token *debug, ResultTokenWithMetadata *conversion,
@@ -1585,7 +1588,9 @@ expr_ty _PyPegen_interpolation(Parser *p, expr_ty expression, Token *debug, Resu
15851588
}
15861589

15871590
assert(exprstr != NULL);
1588-
PyObject *final_exprstr = _strip_interpolation_expr(exprstr);
1591+
PyObject *final_exprstr = debug
1592+
? _strip_interpolation_debug_expr(exprstr)
1593+
: Py_NewRef(exprstr);
15891594
if (!final_exprstr || _PyArena_AddPyObject(arena, final_exprstr) < 0) {
15901595
Py_XDECREF(final_exprstr);
15911596
return NULL;

Parser/lexer/string.c

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,17 @@ _PyLexer_set_ftstring_expr(struct tok_state* tok, struct token *token, char c) {
7474
while (i < tok_mode->last_expr_size - tok_mode->last_expr_end) {
7575
char ch = tok_mode->last_expr_buffer[i];
7676

77+
// Copy escaped characters without interpreting the escaped
78+
// character as a quote or comment marker.
79+
if (ch == '\\') {
80+
result[j++] = ch;
81+
i++;
82+
if (i < tok_mode->last_expr_size - tok_mode->last_expr_end) {
83+
result[j++] = tok_mode->last_expr_buffer[i];
84+
}
85+
}
7786
// Handle string quotes
78-
if (ch == '"' || ch == '\'') {
87+
else if (ch == '"' || ch == '\'') {
7988
// See comment above to understand this part
8089
if (!in_string) {
8190
in_string = 1;

0 commit comments

Comments
 (0)