Skip to content

Commit 612ce88

Browse files
authored
gh-159465: Preserve escape errors in unfinished f-strings and t-strings (#159669)
1 parent 7715c92 commit 612ce88

11 files changed

Lines changed: 170 additions & 17 deletions

File tree

‎Lib/codeop.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,19 @@ def _maybe_compile(compiler, source, filename, symbol, flags):
5757
if symbol != "eval":
5858
source = "pass" # Replace it with a 'pass' statement
5959

60+
incomplete_input = False
6061
# Disable compiler warnings when checking for incomplete input.
6162
with warnings.catch_warnings():
6263
warnings.simplefilter("ignore", (SyntaxWarning, DeprecationWarning))
6364
try:
6465
compiler(source, filename, symbol, flags=flags)
6566
except _IncompleteInputError:
6667
return None
67-
except SyntaxError: # Let other compile() errors propagate.
68-
pass
68+
except SyntaxError as error: # Let other compile() errors propagate.
69+
# Preserve escape errors that a missing closing quote can hide.
70+
incomplete_input = error.msg.startswith("(unicode error)")
6971

70-
return compiler(source, filename, symbol, incomplete_input=False)
72+
return compiler(source, filename, symbol, incomplete_input=incomplete_input)
7173

7274
def _compile(source, filename, symbol, incomplete_input=True, *, flags=0):
7375
if incomplete_input:

‎Lib/test/test_codeop.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,100 @@ def test_string_continuation(self, symbol, prefix):
176176
with self.assertRaises(_IncompleteInputError):
177177
Compile()(source, '<input>', symbol)
178178

179+
@subTests('symbol', ('single', 'exec', 'eval'))
180+
@subTests('prefix', ('f', 't'))
181+
@subTests('quote', ('"', '"""'))
182+
def test_invalid_escape_in_incomplete_string(self, symbol, prefix, quote):
183+
for escape in (r'\xZZ', r'\uZZZZ', r'\U00110000', r'\N{NOPE}',
184+
r'\x', r'\u12'):
185+
for body in (escape, escape + '{0}', '{0}' + escape,
186+
'{0:' + escape):
187+
source = prefix + quote + body + '\\'
188+
for compiler in COMPILERS:
189+
with self.subTest(source=source, compiler=compiler):
190+
with self.assertRaisesRegex(SyntaxError, r'\(unicode error\)') as cm:
191+
compiler(source, '<input>', symbol)
192+
self.assertGreater(cm.exception.end_offset, 0)
193+
194+
@subTests('symbol', ('single', 'exec', 'eval'))
195+
@subTests('prefix', ('f', 't'))
196+
@subTests('newline', ('\n', '\r', '\r\n'))
197+
def test_incomplete_escape_locations(self, symbol, prefix, newline):
198+
cases = (
199+
(r'\N{NOPE}', (1, 5, 1, 13)),
200+
('é\n{0}é' + r'\xZZ', (2, 4, 2, 9)),
201+
('éé\n中' + r'\xZZ', (1, 5, 2, 6)),
202+
('\n{0:' + r'\xZZ', (2, 4, 2, 8)),
203+
)
204+
for body, location in cases:
205+
source = (prefix + '"""' + body).replace('\n', newline)
206+
for compiler in COMPILERS:
207+
with self.subTest(source=source, compiler=compiler):
208+
with self.assertRaisesRegex(SyntaxError, r'\(unicode error\)') as cm:
209+
compiler(source, '<input>', symbol)
210+
error = cm.exception
211+
self.assertEqual((error.lineno, error.offset,
212+
error.end_lineno, error.end_offset), location)
213+
self.assertEqual(error.text.rstrip('\n'),
214+
source.splitlines()[error.lineno - 1])
215+
216+
@subTests('compiler', WRAPPING_COMPILERS)
217+
def test_non_decode_error_diagnostic(self, compiler):
218+
source = '+\\\n'
219+
with self.assertRaises(SyntaxError) as expected:
220+
compile(source, '<input>', 'single')
221+
with self.assertRaises(SyntaxError) as actual:
222+
compiler(source, '<input>', 'single')
223+
self.assertEqual(actual.exception.args, expected.exception.args)
224+
225+
@subTests('compiler', COMPILERS)
226+
@subTests('prefix', ('f', 't'))
227+
def test_incomplete_escape_warning(self, compiler, prefix):
228+
source = r"'\e'; " + prefix + '"""' + r'\N{NOPE}'
229+
with warnings.catch_warnings(record=True, action='always') as caught:
230+
with self.assertRaisesRegex(SyntaxError, 'unknown Unicode character name'):
231+
compiler(source, '<input>', 'single')
232+
self.assertEqual([warning.category for warning in caught], [SyntaxWarning])
233+
with warnings.catch_warnings(action='error', category=SyntaxWarning):
234+
with self.assertRaisesRegex(SyntaxError, 'invalid escape sequence'):
235+
compiler(source, '<input>', 'single')
236+
237+
@subTests('symbol', ('single', 'exec', 'eval'))
238+
@subTests('prefix', ('f', 't'))
239+
@subTests('quote', ('"', '"""'))
240+
def test_valid_escape_in_incomplete_string(self, symbol, prefix, quote):
241+
for escape in (r'\x41', r'\u00e9', r'\U0001f600', r'\\xZZ'):
242+
source = prefix + quote + escape + '\\'
243+
with self.subTest(escape=escape):
244+
self.assertIsNone(compile_command(source, symbol=symbol))
245+
with self.assertRaises(_IncompleteInputError):
246+
Compile()(source, '<input>', symbol)
247+
for compiler in COMPILERS:
248+
self.assertValid(source + '\n' + quote, symbol,
249+
compiler=compiler)
250+
251+
source = 'r' + prefix + quote + r'\xZZ' + '\\'
252+
self.assertIsNone(compile_command(source, symbol=symbol))
253+
with self.assertRaises(_IncompleteInputError):
254+
Compile()(source, '<input>', symbol)
255+
for compiler in COMPILERS:
256+
self.assertValid(source + '\n' + quote, symbol, compiler=compiler)
257+
258+
@subTests('symbol', ('single', 'exec', 'eval'))
259+
@subTests('prefix', ('f', 't'))
260+
def test_unfinished_escape_in_incomplete_string(self, symbol, prefix):
261+
for escape, suffix in ((r'\x', '41'), (r'\u12', '34'),
262+
(r'\U0001', 'f600'),
263+
(r'\N{LATIN', ' SMALL LETTER A}')):
264+
source = prefix + '"""' + escape
265+
with self.subTest(escape=escape):
266+
self.assertIsNone(compile_command(source, symbol=symbol))
267+
with self.assertRaises(_IncompleteInputError):
268+
Compile()(source, '<input>', symbol)
269+
for compiler in COMPILERS:
270+
self.assertValid(source + suffix + '"""', symbol,
271+
compiler=compiler)
272+
179273
@subTests('symbol', ('single', 'exec', 'eval'))
180274
@subTests('newline', ('\n', '\r', '\r\n'))
181275
def test_continued_final_line(self, symbol, newline):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Preserve :exc:`SyntaxError` for malformed escapes in unfinished f-strings and
2+
t-strings passed to :func:`codeop.compile_command`.

‎Parser/action_helpers.c‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,8 +1507,15 @@ expr_ty _PyPegen_decoded_constant_from_token(Parser* p, Token* tok) {
15071507

15081508
PyObject* str = _PyPegen_decode_string(p, is_raw, bstr, bsize, tok);
15091509
if (str == NULL) {
1510+
if ((p->flags & PyPARSE_ALLOW_INCOMPLETE_INPUT) || tok->is_incomplete) {
1511+
_Pypegen_raise_decode_error(p);
1512+
}
15101513
return NULL;
15111514
}
1515+
if (tok->is_incomplete) {
1516+
Py_DECREF(str);
1517+
return _PyPegen_raise_incomplete_input(p);
1518+
}
15121519
if (_PyArena_AddPyObject(p->arena, str) < 0) {
15131520
Py_DECREF(str);
15141521
return NULL;
@@ -1523,6 +1530,21 @@ expr_ty _PyPegen_constant_from_token(Parser* p, Token* tok) {
15231530
return _PyPegen_raise_incomplete_input(p);
15241531
}
15251532
const char *bstr = PyBytes_AS_STRING(tok->bytes);
1533+
Py_ssize_t bsize = PyBytes_GET_SIZE(tok->bytes);
1534+
if (((p->flags & PyPARSE_ALLOW_INCOMPLETE_INPUT) || tok->is_incomplete) &&
1535+
!tok->is_raw && memchr(bstr, '\\', bsize) != NULL) {
1536+
// Check escapes before the string is complete, without emitting warnings.
1537+
PyObject *decoded = _PyPegen_decode_string(
1538+
p, 0, bstr, bsize, tok->is_incomplete ? tok : NULL);
1539+
if (decoded == NULL) {
1540+
_Pypegen_raise_decode_error(p);
1541+
return NULL;
1542+
}
1543+
Py_DECREF(decoded);
1544+
}
1545+
if (tok->is_incomplete) {
1546+
return _PyPegen_raise_incomplete_input(p);
1547+
}
15261548
PyObject* str = PyUnicode_FromString(bstr);
15271549
if (str == NULL) {
15281550
return NULL;

‎Parser/lexer/state.c‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ _PyLexer_token_setup(struct tok_state *tok, struct token *token, int type, _PyTo
8686
{
8787
token->level = tok->level;
8888
token->is_raw = 0;
89+
token->is_incomplete = 0;
8990
assert((start == -1 && end == -1) || (start >= 0 && end >= start));
9091
token->span = (_PyTok_Span){start, end};
9192
if (start >= 0) {

‎Parser/lexer/string.c‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ incomplete_string_token(struct tok_state *tok, struct token *token,
2727
{
2828
string_error_token(tok, token, start, location);
2929
tok->done = E_INCOMPLETE;
30+
token->is_incomplete = 1;
3031
return type;
3132
}
3233

@@ -464,8 +465,18 @@ _PyLexer_get_ftstring(struct tok_state *tok, ftstring_state *current, struct tok
464465
return MAKE_TOKEN(ERRORTOKEN);
465466
}
466467
if (c == EOF && tok->allow_incomplete_input) {
467-
return incomplete_string_token(tok, token,
468+
int type = incomplete_string_token(tok, token,
468469
FTSTRING_MIDDLE(current), current->start, current->start_loc);
470+
// Preserve the actual prefix for incremental escape decoding.
471+
_PyTok_Off end = tok->cur;
472+
if (end > tok->start && tok->implicit_newline) {
473+
end--;
474+
}
475+
token->span = (_PyTok_Span){tok->start, end};
476+
token->start_loc = tok->start_loc;
477+
token->end_loc = (_PyTok_Loc){tok->lineno, (int)(end - tok->line_start)};
478+
token->is_raw = raw;
479+
return type;
469480
}
470481

471482
if (in_format_spec && c == '\n') {

‎Parser/pegen.c‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ initialize_token(Parser *p, Token *parser_token, struct token *new_token, int to
279279

280280
parser_token->level = new_token->level;
281281
parser_token->is_raw = new_token->is_raw;
282+
parser_token->is_incomplete = new_token->is_incomplete;
282283
parser_token->lineno = new_token->start_loc.lineno;
283284
parser_token->col_offset = new_token->end_loc.lineno == p->starting_lineno
284285
? p->starting_col_offset + new_token->start_loc.byte_col

‎Parser/pegen.h‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ typedef struct _memo {
3939
typedef struct {
4040
int type;
4141
// Text required by grammar actions or helpers; otherwise NULL.
42-
// An unfinished string token also has NULL text. Its grammar action
43-
// requests more input only if the token is valid at that position.
42+
// Unfinished f-string and t-string middle tokens retain their text so
43+
// their grammar action can check escapes before requesting more input.
4444
PyObject *bytes;
4545
int level;
46-
int is_raw;
46+
unsigned char is_raw;
47+
unsigned char is_incomplete;
4748
int lineno, col_offset, end_lineno, end_col_offset;
4849
Memo *memo;
4950
// Filter over the rule types present in `memo` (bit `type & 63` is set

‎Parser/pegen_errors.c‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,17 @@ _PyPegen_raise_error_known_location(Parser *p, PyObject *errtype,
278278
PyObject *tmp = NULL;
279279
p->error_indicator = 1;
280280
_PyTokenizer_Info info = _PyTokenizer_GetInfo(p->tok);
281-
_PyTok_Loc location = info.diagnostic.location.lineno != 0
282-
? info.diagnostic.location : info.location;
283-
_PyTok_Span text_span = info.diagnostic.location.lineno != 0
284-
? info.diagnostic.text_span : info.line_span;
281+
_PyTok_Loc location = info.location;
282+
_PyTok_Span text_span = info.line_span;
283+
284+
if (errtype == PyExc_IncompleteInputError &&
285+
info.diagnostic.location.lineno != 0) {
286+
location = info.diagnostic.location;
287+
text_span = info.diagnostic.text_span;
288+
lineno = end_lineno = location.lineno;
289+
col_offset = location.byte_col;
290+
end_col_offset = -1;
291+
}
285292

286293
if (end_lineno == CURRENT_POS) {
287294
end_lineno = location.lineno;
@@ -346,7 +353,13 @@ _PyPegen_raise_error_known_location(Parser *p, PyObject *errtype,
346353
}
347354

348355
if (end_col_offset > 0) {
349-
end_col_number = _PyPegen_byte_offset_to_character_offset(error_line, end_col_offset);
356+
PyObject *end_line = end_lineno != lineno && _PyTokenizer_RetainedSource(p->tok) != NULL
357+
? get_error_line_from_source(p, end_lineno) : Py_NewRef(error_line);
358+
if (end_line == NULL) {
359+
goto error;
360+
}
361+
end_col_number = _PyPegen_byte_offset_to_character_offset(end_line, end_col_offset);
362+
Py_DECREF(end_line);
350363
if (end_col_number < 0) {
351364
goto error;
352365
}

‎Parser/string_parser.c‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,17 @@ decode_unicode_with_escapes(Parser *parser, const char *s, size_t len, Token *t)
194194

195195
int first_invalid_escape_char;
196196
const char *first_invalid_escape_ptr;
197-
v = _PyUnicode_DecodeUnicodeEscapeInternal2(buf, (Py_ssize_t)len, NULL, NULL,
197+
// Allow a trailing unfinished escape in a partial token.
198+
int incomplete = t != NULL && t->is_incomplete;
199+
Py_ssize_t consumed;
200+
v = _PyUnicode_DecodeUnicodeEscapeInternal2(buf, (Py_ssize_t)len, NULL,
201+
incomplete ? &consumed : NULL,
198202
&first_invalid_escape_char,
199203
&first_invalid_escape_ptr);
200204

201205
// HACK: later we can simply pass the line no, since we don't preserve the tokens
202206
// when we are decoding the string but we preserve the line numbers.
203-
if (v != NULL && first_invalid_escape_ptr != NULL && t != NULL) {
207+
if (v != NULL && first_invalid_escape_ptr != NULL && t != NULL && !incomplete) {
204208
if (warn_invalid_escape_sequence(parser, buf, first_invalid_escape_ptr, t) < 0) {
205209
/* We have not deallocated the buffer before because
206210
* first_invalid_escape_ptr points inside buf. */

0 commit comments

Comments
 (0)