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;