Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Lib/test/test_fstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<test>', '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 '\}' "
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 12 additions & 2 deletions Parser/lexer/string.c
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
PyObject *res;
if (comments != NULL && comments->count > 0) {
Py_ssize_t stripped_size = expr_len;
_PyTok_Off previous_end = state->expr_span.start;

Check warning on line 81 in Parser/lexer/string.c

View workflow job for this annotation

GitHub Actions / Cross build Linux

variable ‘previous_end’ set but not used [-Wunused-but-set-variable]

Check warning on line 81 in Parser/lexer/string.c

View workflow job for this annotation

GitHub Actions / Address sanitizer (ubuntu-26.04)

variable ‘previous_end’ set but not used [-Wunused-but-set-variable]

Check warning on line 81 in Parser/lexer/string.c

View workflow job for this annotation

GitHub Actions / Ubuntu (installed) / build, install and test

variable ‘previous_end’ set but not used [-Wunused-but-set-variable]
Py_ssize_t comment_count = 0;
for (Py_ssize_t i = 0; i < comments->count; i++) {
_PyTok_Span comment = comments->spans[i];
Expand Down Expand Up @@ -355,9 +355,19 @@

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)));
Expand Down
Loading