From f4c4b3d7f6d30c6d073719b5c1dadc4d4f9299c4 Mon Sep 17 00:00:00 2001 From: lipengyu Date: Mon, 14 Sep 2026 20:46:30 +0800 Subject: [PATCH 1/2] gh-157491: Resume format-specifier parsing after nested replacement fields Restore format-specifier lexer mode after closing a nested replacement field in an f-string or t-string. This prevents braces following the nested field from being treated as literal formatted-string content and preserves the specific diagnostic for newlines in single-quoted format specifiers. --- Lib/test/test_fstring.py | 22 +++++++++++++++++++ Lib/test/test_tokenize.py | 6 +---- Lib/test/test_tstring.py | 18 ++++++++++++++- ...-09-14-11-22-44.gh-issue-157491.5BfNWA.rst | 2 ++ Parser/lexer/string.c | 4 +++- 5 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-14-11-22-44.gh-issue-157491.5BfNWA.rst diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index 2d6320549b03f62..d50b58c26176326 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -865,6 +865,27 @@ def test_format_specifier_expressions(self): """f'{"s"!{"r"}}'""", ]) + def test_nested_replacement_field_restores_format_specifier(self): + class CaptureFormat: + def __format__(self, format_spec): + self.format_spec = format_spec + return "" + + x = CaptureFormat() + y = "Y" + z = "Z" + + def check_format_spec(result, expected): + self.assertEqual(x.format_spec, expected) + self.assertEqual(result, "") + + check_format_spec(f'{x:{y}}', "Y") + check_format_spec(f'{x:{{y}}}', "{'Y'}") + check_format_spec(f'{x:{y}{{z}}}', "Y{'Z'}") + check_format_spec(f'{x:{y!s}{{z}}}', "Y{'Z'}") + check_format_spec(f'{x:{y=}{{z}}}', "y='Y'{'Z'}") + check_format_spec(f'''{x:{y}{{z}}}''', "Y{'Z'}") + def test_custom_format_specifier(self): class CustomFormat: def __format__(self, format_spec): @@ -1858,6 +1879,7 @@ def test_gh129093(self): def test_newlines_in_format_specifiers(self): cases = [ """f'{1:d\n}'""", + """f'{x:{y}\n}'""", """f'__{ 1:d }__'""", diff --git a/Lib/test/test_tokenize.py b/Lib/test/test_tokenize.py index 53215eceeb8aed3..7c88b7d464b6dba 100644 --- a/Lib/test/test_tokenize.py +++ b/Lib/test/test_tokenize.py @@ -2552,11 +2552,7 @@ def test_degraded_fstring_format_spec(self): self.assertEqual( [(tok.string, tok.start, tok.end) for tok in tokens if tok.type == token.FSTRING_MIDDLE], - [ - ("{", (1, 8), (1, 9)), - ("3", (1, 10), (1, 11)), - ("}", (1, 12), (1, 13)), - ], + [("", (1, 13), (1, 13))], ) tokens = self._get_tokens('f"{x:{y}}"') diff --git a/Lib/test/test_tstring.py b/Lib/test/test_tstring.py index 67a8e0fc6bcffb1..70ebbf1b19a77de 100644 --- a/Lib/test/test_tstring.py +++ b/Lib/test/test_tstring.py @@ -88,6 +88,19 @@ def test_format_specifiers(self): ) self.assertEqual(fstring(t), "Pi: 3.14") + x = object() + y = "Y" + z = "Z" + for template, expected in ( + (t"{x:{y}{{z}}}", "Y{'Z'}"), + (t"{x:{y!s}{{z}}}", "Y{'Z'}"), + (t"{x:{y=}{{z}}}", "y='Y'{'Z'}"), + (t'''{x:{y}{{z}}}''', "Y{'Z'}"), + ): + with self.subTest(template=template): + self.assertEqual(template.interpolations[0].format_spec, + expected) + def test_conversions(self): # Test !s conversion (str) obj = object() @@ -313,7 +326,10 @@ def test_syntax_errors(self): ("t'{lambda:1}'", "t-string: lambda expressions are not allowed " "without parentheses"), ("t'{x:{;}}'", "t-string: expecting a valid expression after '{'"), - ("t'{1:d\n}'", "t-string: newlines are not allowed in format specifiers") + ("t'{1:d\n}'", + "t-string: newlines are not allowed in format specifiers"), + ("t'{x:{y}\n}'", + "t-string: newlines are not allowed in format specifiers"), ): with self.subTest(case), self.assertRaisesRegex(SyntaxError, err): eval(case) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-14-11-22-44.gh-issue-157491.5BfNWA.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-14-11-22-44.gh-issue-157491.5BfNWA.rst new file mode 100644 index 000000000000000..6fd2f557d6dc083 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-14-11-22-44.gh-issue-157491.5BfNWA.rst @@ -0,0 +1,2 @@ +Correctly parse braces and report invalid newlines after nested replacement +fields in f-string and t-string format specifiers. diff --git a/Parser/lexer/string.c b/Parser/lexer/string.c index 945480ef86f7789..d816441387f0e11 100644 --- a/Parser/lexer/string.c +++ b/Parser/lexer/string.c @@ -188,7 +188,9 @@ _PyLexer_close_ftstring_expr(struct tok_state *tok, ftstring_state *state, } if (c == '}' && depth == state->replacement_depth - 1) { state->replacement_depth--; - state->mode = FTSTRING_MODE_MIDDLE; + state->mode = state->replacement_depth + ? FTSTRING_MODE_FORMAT_SPEC + : FTSTRING_MODE_MIDDLE; state->debug_expr = 0; } return 0; From 66a6eed3b3d77908d5960ba416b34045166b9165 Mon Sep 17 00:00:00 2001 From: lipengyu Date: Tue, 15 Sep 2026 20:16:34 +0800 Subject: [PATCH 2/2] Remove redundant test --- Lib/test/test_fstring.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index d50b58c26176326..e5db85c0e93f004 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -879,7 +879,6 @@ def check_format_spec(result, expected): self.assertEqual(x.format_spec, expected) self.assertEqual(result, "") - check_format_spec(f'{x:{y}}', "Y") check_format_spec(f'{x:{{y}}}', "{'Y'}") check_format_spec(f'{x:{y}{{z}}}', "Y{'Z'}") check_format_spec(f'{x:{y!s}{{z}}}', "Y{'Z'}")