https://github.com/python/cpython/commit/1a703ab9e6a050b40c469788ec7758713c6bf62b
commit: 1a703ab9e6a050b40c469788ec7758713c6bf62b
branch: main
author: Pengyu Lee <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-09-13T01:09:22+01:00
summary:
gh-154719: Preserve trailing whitespace in t-string interpolation expressions
(#154762)
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
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 201ae6e24794933..2d6320549b03f62 100644
--- a/Lib/test/test_fstring.py
+++ b/Lib/test/test_fstring.py
@@ -1695,6 +1695,14 @@ def __repr__(self):
self.assertEqual(f'''{f"{d["a#b"]}"=}''',
'f"{d["a#b"]}"=\'42\'')
+ 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 854860b5ea43065..67a8e0fc6bcffb1 100644
--- a/Lib/test/test_tstring.py
+++ b/Lib/test/test_tstring.py
@@ -136,10 +136,47 @@ 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)
+
class C:
def __format__(self, spec):
return f"FORMAT-{spec}"
@@ -149,6 +186,41 @@ def __format__(self, spec):
self.assertEqual(t.interpolations[0].format_spec,
"FORMAT-value=42")
+ 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"
@@ -314,12 +386,12 @@ def test_triple_quoted(self):
t = t'{"""a""""#" # outside
}'
- self.assertEqual(t.interpolations[0].expression, '"""a""""#"')
+ self.assertEqual(t.interpolations[0].expression, '"""a""""#" \n')
x, y = 1, 2
t = t'{x != y # outside
}'
- self.assertEqual(t.interpolations[0].expression, 'x != y')
+ self.assertEqual(t.interpolations[0].expression, 'x != y \n')
d = {'a#b': 42}
t = t'''{f"{d["a#b"]}"}'''
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 3e4d463b36ab872..3d2c53f631b8b40 100644
--- a/Parser/action_helpers.c
+++ b/Parser/action_helpers.c
@@ -1555,21 +1555,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,
@@ -1600,7 +1617,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;
_______________________________________________
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]