https://github.com/python/cpython/commit/871bd46968e65777a04f88676f372ec99936fda3
commit: 871bd46968e65777a04f88676f372ec99936fda3
branch: 3.15
author: Pengyu Lee <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-09-13T14:46:39+01:00
summary:
[3.15] gh-154719: Preserve trailing whitespace in t-string interpolation
expressions (#154762) (#157394)
files:
A
Misc/NEWS.d/next/Core_and_Builtins/2026-07-27-17-20-49.gh-issue-154719.eI88Gs.rst
M Lib/test/test_annotationlib.py
M Lib/test/test_fstring.py
M Lib/test/test_tstring.py
M Lib/test/test_unparse.py
M Parser/action_helpers.c
M Parser/lexer/lexer.c
diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py
index 7bdb4a9993f4e33..a7d3bca7402bf22 100644
--- a/Lib/test/test_annotationlib.py
+++ b/Lib/test/test_annotationlib.py
@@ -1869,7 +1869,7 @@ def nested():
self.assertEqual(type_repr(t'''{ 0
& 1
| 2
- }'''), 't"""{ 0\n & 1\n | 2}"""')
+ }'''), 't"""{ 0\n & 1\n | 2\n }"""')
self.assertEqual(
type_repr(Template("hi", Interpolation(42, "42"))), "t'hi{42}'"
)
diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py
index 9a8e56836fc671c..aa837a3289d7045 100644
--- a/Lib/test/test_fstring.py
+++ b/Lib/test/test_fstring.py
@@ -1668,6 +1668,14 @@ def __repr__(self):
self.assertEqual(f'{" # nooo "=}', '" # nooo "=\' # nooo \'')
self.assertEqual(f'{" \" # nooo \" "=}', '" \\" # nooo \\" "=\' " #
nooo " \'')
+ result = f'''{(
+ 1, # Force lexer metadata reconstruction.
+ "\"#")=}'''
+ self.assertEqual(
+ result,
+ '(\n 1, \n "\\"#")=(1, \'"#\')',
+ )
+
self.assertEqual(f'{ # some comment goes here
"""hello"""=}', ' \n """hello"""=\'hello\'')
self.assertEqual(f'{"""# this is not a comment
diff --git a/Lib/test/test_tstring.py b/Lib/test/test_tstring.py
index 74653c77c55de17..046cc1e58346089 100644
--- a/Lib/test/test_tstring.py
+++ b/Lib/test/test_tstring.py
@@ -136,10 +136,82 @@ def test_debug_specifier(self):
# Test white space in debug specifier
t = t"Value: {value = }"
self.assertTStringEqual(
- t, ("Value: value = ", ""), [(value, "value", "r")]
+ t, ("Value: value = ", ""), [(value, "value ", "r")]
)
self.assertEqual(fstring(t), "Value: value = 42")
+ # Explicit line continuations after the debug marker are part of
+ # the debug text, not the interpolation expression.
+ for template, strings, interpolation, rendered in (
+ (
+ t"""Value: {value =\
+}""",
+ ("Value: value =\\\n", ""),
+ (value, "value ", "r"),
+ "Value: value =\\\n42",
+ ),
+ (
+ t"""Value: {value =\
+!r}""",
+ ("Value: value =\\\n", ""),
+ (value, "value ", "r"),
+ "Value: value =\\\n42",
+ ),
+ (
+ t"""Value: {value =\
+:04}""",
+ ("Value: value =\\\n", ""),
+ (value, "value ", None, "04"),
+ "Value: value =\\\n0042",
+ ),
+ (
+ t"""Value: {value =\
+\
+}""",
+ ("Value: value =\\\n\\\n", ""),
+ (value, "value ", "r"),
+ "Value: value =\\\n\\\n42",
+ ),
+ ):
+ with self.subTest(template=template):
+ self.assertTStringEqual(template, strings, [interpolation])
+ self.assertEqual(fstring(template), rendered)
+
+ def test_interpolation_expression_whitespace(self):
+ x = 42
+ for template, expected in (
+ (t"{x}", "x"),
+ (t"{x }", "x "),
+ (t"{ x}", " x"),
+ (t"{ x }", " x "),
+ (t"{ x }", " x "),
+ (t"""{
+ x
+}""", "\n x\n"),
+ (t"{ x !r}", " x "),
+ (t"{ x :.2f}", " x "),
+ (t"{ x = }", " x "),
+ (t"{ x = !r}", " x "),
+ (t"{ x = :.2f}", " x "),
+ (t"{x == 42 = }", "x == 42 "),
+ ):
+ with self.subTest(template=template):
+ self.assertEqual(
+ template.interpolations[0].expression,
+ expected,
+ )
+
+ def test_interpolation_expression_with_reconstructed_metadata(self):
+ regular = t'''{(
+ 1, # Force lexer metadata reconstruction.
+ "\"#")}'''
+ debug = t'''{(
+ 1, # Force lexer metadata reconstruction.
+ "\"#")=}'''
+ expected = '(\n 1, \n "\\"#")'
+ self.assertEqual(regular.interpolations[0].expression, expected)
+ self.assertEqual(debug.interpolations[0].expression, expected)
+
def test_raw_tstrings(self):
path = r"C:\Users"
t = rt"{path}\Documents"
diff --git a/Lib/test/test_unparse.py b/Lib/test/test_unparse.py
index dcaad49ffab5d26..28faede2e2a1a5c 100644
--- a/Lib/test/test_unparse.py
+++ b/Lib/test/test_unparse.py
@@ -216,6 +216,15 @@ def test_tstrings(self):
self.check_ast_roundtrip('t""')
self.check_ast_roundtrip("t'{(lambda x: x)}'")
self.check_ast_roundtrip("t'{t'{x}'}'")
+ self.check_ast_roundtrip(
+ r"""t'''{(
+ 1, # Force lexer metadata reconstruction.
+ "\"#")}'''"""
+ )
+ self.check_ast_roundtrip(
+ r'''t"""Value: {value =\
+}"""'''
+ )
def test_tstring_with_nonsensical_str_field(self):
# `value` suggests that the original code is `t'{test1}`, but `str`
suggests otherwise
diff --git
a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-27-17-20-49.gh-issue-154719.eI88Gs.rst
b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-27-17-20-49.gh-issue-154719.eI88Gs.rst
new file mode 100644
index 000000000000000..d482a5214bd751f
--- /dev/null
+++
b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-27-17-20-49.gh-issue-154719.eI88Gs.rst
@@ -0,0 +1,5 @@
+Trailing whitespace in a t-string interpolation expression is now preserved
+in :attr:`string.templatelib.Interpolation.expression`, up to the closing ``}``
+or the conversion (``!``), format (``:``), or debug (``=``) delimiter.
+Explicit line continuations following a debug ``=`` remain part of the debug
+text and are excluded from
:attr:`~string.templatelib.Interpolation.expression`.
diff --git a/Parser/action_helpers.c b/Parser/action_helpers.c
index 809f3c0a7b270e8..71c4eca8f5cf77e 100644
--- a/Parser/action_helpers.c
+++ b/Parser/action_helpers.c
@@ -1540,21 +1540,38 @@ _get_interpolation_conversion(Parser *p, Token *debug,
ResultTokenWithMetadata *
}
static PyObject *
-_strip_interpolation_expr(PyObject *exprstr)
+_strip_interpolation_debug_expr(PyObject *exprstr)
{
Py_ssize_t len = PyUnicode_GET_LENGTH(exprstr);
- for (Py_ssize_t i = len - 1; i >= 0; i--) {
- Py_UCS4 c = PyUnicode_READ_CHAR(exprstr, i);
- if (_PyUnicode_IsWhitespace(c) || c == '=') {
+ /* Discard whitespace and explicit line continuations after the debug "="
+ but preserve whitespace before it. */
+ while (len > 0) {
+ int has_newline = 0;
+ while (len > 0) {
+ Py_UCS4 c = PyUnicode_READ_CHAR(exprstr, len - 1);
+ if (!_PyUnicode_IsWhitespace(c)) {
+ break;
+ }
+ if (c == '\r' || c == '\n') {
+ has_newline = 1;
+ }
len--;
}
- else {
+ if (!has_newline || len == 0 ||
+ PyUnicode_READ_CHAR(exprstr, len - 1) != '\\')
+ {
break;
}
+ len--;
+ }
+
+ /* Preserve unexpected metadata instead of dropping source text. */
+ if (len == 0 || PyUnicode_READ_CHAR(exprstr, len - 1) != '=') {
+ return Py_NewRef(exprstr);
}
- return PyUnicode_Substring(exprstr, 0, len);
+ return PyUnicode_Substring(exprstr, 0, len - 1);
}
expr_ty _PyPegen_interpolation(Parser *p, expr_ty expression, Token *debug,
ResultTokenWithMetadata *conversion,
@@ -1585,7 +1602,9 @@ expr_ty _PyPegen_interpolation(Parser *p, expr_ty
expression, Token *debug, Resu
}
assert(exprstr != NULL);
- PyObject *final_exprstr = _strip_interpolation_expr(exprstr);
+ PyObject *final_exprstr = debug
+ ? _strip_interpolation_debug_expr(exprstr)
+ : Py_NewRef(exprstr);
if (!final_exprstr || _PyArena_AddPyObject(arena, final_exprstr) < 0) {
Py_XDECREF(final_exprstr);
return NULL;
diff --git a/Parser/lexer/lexer.c b/Parser/lexer/lexer.c
index 7f25afec302c225..b8fb29e98117f82 100644
--- a/Parser/lexer/lexer.c
+++ b/Parser/lexer/lexer.c
@@ -177,8 +177,17 @@ set_ftstring_expr(struct tok_state* tok, struct token
*token, char c) {
while (i < tok_mode->last_expr_size - tok_mode->last_expr_end) {
char ch = tok_mode->last_expr_buffer[i];
+ // Copy escaped characters without interpreting the escaped
+ // character as a quote or comment marker.
+ if (ch == '\\') {
+ result[j++] = ch;
+ i++;
+ if (i < tok_mode->last_expr_size - tok_mode->last_expr_end) {
+ result[j++] = tok_mode->last_expr_buffer[i];
+ }
+ }
// Handle string quotes
- if (ch == '"' || ch == '\'') {
+ else if (ch == '"' || ch == '\'') {
// See comment above to understand this part
if (!in_string) {
in_string = 1;
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]