Skip to content

Commit 8dec429

Browse files
committed
gh-159465: Preserve escape errors in unfinished f-strings and t-strings
1 parent 6af6553 commit 8dec429

9 files changed

Lines changed: 101 additions & 9 deletions

File tree

‎Lib/test/test_codeop.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,56 @@ 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'\x', r'\u12'):
184+
for body in (escape, escape + '{0}', '{0}' + escape,
185+
'{0:' + escape):
186+
source = prefix + quote + body + '\\'
187+
for compiler in COMPILERS:
188+
with self.subTest(source=source, compiler=compiler):
189+
with self.assertRaises(SyntaxError) as cm:
190+
compiler(source, '<input>', symbol)
191+
self.assertNotIsInstance(cm.exception, _IncompleteInputError)
192+
193+
@subTests('symbol', ('single', 'exec', 'eval'))
194+
@subTests('prefix', ('f', 't'))
195+
@subTests('quote', ('"', '"""'))
196+
def test_valid_escape_in_incomplete_string(self, symbol, prefix, quote):
197+
for escape in (r'\x41', r'\u00e9', r'\U0001f600', r'\\xZZ'):
198+
source = prefix + quote + escape + '\\'
199+
with self.subTest(escape=escape):
200+
self.assertIsNone(compile_command(source, symbol=symbol))
201+
with self.assertRaises(_IncompleteInputError):
202+
Compile()(source, '<input>', symbol)
203+
for compiler in COMPILERS:
204+
self.assertValid(source + '\n' + quote, symbol,
205+
compiler=compiler)
206+
207+
source = 'r' + prefix + quote + r'\xZZ' + '\\'
208+
self.assertIsNone(compile_command(source, symbol=symbol))
209+
with self.assertRaises(_IncompleteInputError):
210+
Compile()(source, '<input>', symbol)
211+
for compiler in COMPILERS:
212+
self.assertValid(source + '\n' + quote, symbol, compiler=compiler)
213+
214+
@subTests('symbol', ('single', 'exec', 'eval'))
215+
@subTests('prefix', ('f', 't'))
216+
def test_unfinished_escape_in_incomplete_string(self, symbol, prefix):
217+
for escape, suffix in ((r'\x', '41'), (r'\u12', '34'),
218+
(r'\U0001', 'f600'),
219+
(r'\N{LATIN', ' SMALL LETTER A}')):
220+
source = prefix + '"""' + escape
221+
with self.subTest(escape=escape):
222+
self.assertIsNone(compile_command(source, symbol=symbol))
223+
with self.assertRaises(_IncompleteInputError):
224+
Compile()(source, '<input>', symbol)
225+
for compiler in COMPILERS:
226+
self.assertValid(source + suffix + '"""', symbol,
227+
compiler=compiler)
228+
179229
@subTests('symbol', ('single', 'exec', 'eval'))
180230
@subTests('newline', ('\n', '\r', '\r\n'))
181231
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: 10 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,16 @@ _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->is_raw = raw;
477+
return type;
469478
}
470479

471480
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/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. */

‎Parser/tokenizer/tokenizer.h‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ enum {
2424
struct token {
2525
int type;
2626
int level;
27-
int is_raw;
27+
unsigned char is_raw;
28+
unsigned char is_incomplete;
2829
_PyTok_Span span;
2930
_PyTok_Loc start_loc;
3031
_PyTok_Loc end_loc;
@@ -69,8 +70,9 @@ typedef struct {
6970

7071
/* Get replaces the initialized token, releasing its previous metadata.
7172
Errors are returned as ERRORTOKEN, with or without a Python exception.
72-
In incomplete-input mode, an unfinished string retains its token type
73-
with an absent span and E_INCOMPLETE status. No Python exception is set. */
73+
In incomplete-input mode, an unfinished string retains its token type,
74+
sets is_incomplete and has E_INCOMPLETE status. Only f-string and t-string
75+
middle tokens retain their text span. No Python exception is set. */
7476
void _PyTokenizer_Get(struct tok_state *, struct token *);
7577
void _PyTokenizer_Free(struct tok_state *);
7678
int _PyTokenizer_Traverse(struct tok_state *, visitproc, void *);

0 commit comments

Comments
 (0)