From 7ddc4a53b6f12782b236968f7235a86614b0687b Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 19 Sep 2026 02:34:20 +0100 Subject: [PATCH] gh-157778: Include opening brace line in multiline f-string errors --- Lib/test/test_fstring.py | 28 +++++++++++++++++++ ...12-00-00.gh-issue-157778.fstring-brace.rst | 3 ++ Parser/lexer/string.c | 14 ++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index 2d6320549b03f62..f4ee9534c45f4e9 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -617,6 +617,34 @@ def test_unterminated_string(self): r"""f'{("x}'""", ]) + def test_unclosed_multiline_replacement_field(self): + for prefix in ('f', 't', 'rf', 'rt'): + for quote in ('"', "'"): + triple = quote * 3 + cases = ( + # The apparent closing quotes open a string in the field. + ('var = "abc"\na = PREFIXQUOTE {var} extern "C" { QUOTE\n' + 'b = QUOTE string QUOTE', 2), + # Parentheses and dictionaries inside the field must not + # change which opening brace the diagnostic identifies. + ('a = PREFIXQUOTE{\n(QUOTE', 1), + ('a = PREFIXQUOTE{\n{0: QUOTE', 1), + # Use the innermost format field or formatted string. + ('a = PREFIXQUOTE{0:\n{1\nQUOTE', 2), + ('a = f"{\nPREFIXQUOTE{1\nQUOTE', 2), + # Account for parentheses outside the formatted string. + ('a = (PREFIXQUOTE{1\nQUOTE', 1), + ) + for source, lineno in cases: + source = source.replace('PREFIX', prefix).replace('QUOTE', triple) + with self.subTest(source=source): + with self.assertRaises(SyntaxError) as cm: + compile(source, '', 'exec') + self.assertEqual( + cm.exception.msg, + f"{prefix[-1]}-string: expecting '}}' to close '{{' " + f"on line {lineno}") + @unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI") def test_mismatched_parens(self): self.assertAllRaise(SyntaxError, r"closing parenthesis '\}' " diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst new file mode 100644 index 000000000000000..cb1bc8942d1b1b4 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst @@ -0,0 +1,3 @@ +Include the line number of the opening brace in the :exc:`SyntaxError` for +an unclosed f-string or t-string replacement field when a matching quote +on a later line is interpreted as the start of an unterminated string. diff --git a/Parser/lexer/string.c b/Parser/lexer/string.c index 945480ef86f7789..1fb684e337de972 100644 --- a/Parser/lexer/string.c +++ b/Parser/lexer/string.c @@ -355,9 +355,19 @@ _PyLexer_scan_string(struct tok_state *tok, struct token *token, int c) const ftstring_state *state = _PyLexer_CurrentFTString(tok); if (state != NULL) { - /* A matching quote belongs to the surrounding formatted - * string, so the expression is missing its closing brace. */ + /* A matching quote may have been intended to close the + * surrounding formatted string instead of opening a string + * inside a replacement field. */ if (state->quote == quote && state->quote_size == quote_size) { + int level = state->paren_level + state->replacement_depth - 1; + assert(level >= 0 && level < tok->level); + assert(tok->parenstack[level] == '{'); + int lineno = tok->parenlinenostack[level]; + if (lineno != tok->lineno) { + return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok, + "%c-string: expecting '}' to close '{' on line %d", + _PyLexer_StringPrefix(state->kind), lineno)); + } return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok, "%c-string: expecting '}'", _PyLexer_StringPrefix(state->kind)));