diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index 2d6320549b03f62..e5db85c0e93f004 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -865,6 +865,26 @@ 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}{{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 +1878,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;