Skip to content

Commit 1cdd590

Browse files
authored
gh-157778: Include opening brace line in multiline f-string errors (#157779)
1 parent 6893326 commit 1cdd590

3 files changed

Lines changed: 43 additions & 2 deletions

File tree

‎Lib/test/test_fstring.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,34 @@ def test_unterminated_string(self):
617617
r"""f'{("x}'""",
618618
])
619619

620+
def test_unclosed_multiline_replacement_field(self):
621+
for prefix in ('f', 't', 'rf', 'rt'):
622+
for quote in ('"', "'"):
623+
triple = quote * 3
624+
cases = (
625+
# The apparent closing quotes open a string in the field.
626+
('var = "abc"\na = PREFIXQUOTE {var} extern "C" { QUOTE\n'
627+
'b = QUOTE string QUOTE', 2),
628+
# Parentheses and dictionaries inside the field must not
629+
# change which opening brace the diagnostic identifies.
630+
('a = PREFIXQUOTE{\n(QUOTE', 1),
631+
('a = PREFIXQUOTE{\n{0: QUOTE', 1),
632+
# Use the innermost format field or formatted string.
633+
('a = PREFIXQUOTE{0:\n{1\nQUOTE', 2),
634+
('a = f"{\nPREFIXQUOTE{1\nQUOTE', 2),
635+
# Account for parentheses outside the formatted string.
636+
('a = (PREFIXQUOTE{1\nQUOTE', 1),
637+
)
638+
for source, lineno in cases:
639+
source = source.replace('PREFIX', prefix).replace('QUOTE', triple)
640+
with self.subTest(source=source):
641+
with self.assertRaises(SyntaxError) as cm:
642+
compile(source, '<test>', 'exec')
643+
self.assertEqual(
644+
cm.exception.msg,
645+
f"{prefix[-1]}-string: expecting '}}' to close '{{' "
646+
f"on line {lineno}")
647+
620648
@unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI")
621649
def test_mismatched_parens(self):
622650
self.assertAllRaise(SyntaxError, r"closing parenthesis '\}' "
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Include the line number of the opening brace in the :exc:`SyntaxError` for
2+
an unclosed f-string or t-string replacement field when a matching quote
3+
on a later line is interpreted as the start of an unterminated string.

‎Parser/lexer/string.c‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,19 @@ _PyLexer_scan_string(struct tok_state *tok, struct token *token, int c)
355355

356356
const ftstring_state *state = _PyLexer_CurrentFTString(tok);
357357
if (state != NULL) {
358-
/* A matching quote belongs to the surrounding formatted
359-
* string, so the expression is missing its closing brace. */
358+
/* A matching quote may have been intended to close the
359+
* surrounding formatted string instead of opening a string
360+
* inside a replacement field. */
360361
if (state->quote == quote && state->quote_size == quote_size) {
362+
int level = state->paren_level + state->replacement_depth - 1;
363+
assert(level >= 0 && level < tok->level);
364+
assert(tok->parenstack[level] == '{');
365+
int lineno = tok->parenlinenostack[level];
366+
if (lineno != tok->lineno) {
367+
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
368+
"%c-string: expecting '}' to close '{' on line %d",
369+
_PyLexer_StringPrefix(state->kind), lineno));
370+
}
361371
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
362372
"%c-string: expecting '}'",
363373
_PyLexer_StringPrefix(state->kind)));

0 commit comments

Comments
 (0)