Skip to content

Commit 589feb4

Browse files
committed
gh-159465: Improve incomplete input detection
1 parent fe7735f commit 589feb4

25 files changed

Lines changed: 330 additions & 102 deletions

File tree

‎Include/errcode.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ extern "C" {
3838
#define E_BADSINGLE 27 /* Ill-formed single statement input */
3939
#define E_INTERACT_STOP 28 /* Interactive mode stopped tokenization */
4040
#define E_COLUMNOVERFLOW 29 /* Column offset overflow */
41+
#define E_INCOMPLETE 30 /* An unfinished token in partial input */
4142

4243
#ifdef __cplusplus
4344
}

‎Include/internal/pycore_parser.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@ _Py_DECLARE_STR(empty, "")
2727
}, \
2828
}
2929

30+
/* If incomplete is non-NULL, initialize *incomplete to NULL. A returned AST
31+
can also return an owned exception there for a missing line terminator.
32+
Validate the AST before reporting that exception. */
3033
extern struct _mod* _PyParser_ASTFromString(
3134
const char *str,
3235
PyObject* filename,
3336
int mode,
3437
PyCompilerFlags *flags,
3538
PyArena *arena,
36-
PyObject *module);
39+
PyObject *module,
40+
PyObject **incomplete);
3741

3842
extern struct _mod* _PyParser_ASTFromFile(
3943
FILE *fp,

‎Lib/codeop.py‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,10 @@ def _maybe_compile(compiler, source, filename, symbol, flags):
6262
warnings.simplefilter("ignore", (SyntaxWarning, DeprecationWarning))
6363
try:
6464
compiler(source, filename, symbol, flags=flags)
65+
except _IncompleteInputError:
66+
return None
6567
except SyntaxError: # Let other compile() errors propagate.
66-
try:
67-
compiler(source + "\n", filename, symbol, flags=flags)
68-
return None
69-
except _IncompleteInputError:
70-
return None
71-
except SyntaxError:
72-
pass
73-
# fallthrough
68+
pass
7469

7570
return compiler(source, filename, symbol, incomplete_input=False)
7671

‎Lib/test/test_codeop.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,71 @@ def test_incomplete_string_diagnostics(self, symbol, prefix):
124124
self.assertEqual(cm.exception.args, (
125125
'incomplete input', ('<input>', 2, 9, text, 2, -1)))
126126

127+
@subTests('symbol', ('single', 'exec', 'eval'))
128+
def test_unfinished_token_in_invalid_position(self, symbol):
129+
for source in ('a b', '(a b', '1 """', '(1 """',
130+
'f"{lambda:\\', 't"{lambda:\\'):
131+
for compiler in COMPILERS:
132+
with self.subTest(source=source, compiler=compiler):
133+
with self.assertRaises(SyntaxError) as cm:
134+
compiler(source, '<input>', symbol)
135+
self.assertNotIsInstance(cm.exception, _IncompleteInputError)
136+
137+
@subTests('symbol', ('single', 'exec', 'eval'))
138+
def test_invalid_partial_string_diagnostic(self, symbol):
139+
with self.assertRaisesRegex(SyntaxError, 'invalid syntax'):
140+
Compile()('a"""', '<input>', symbol)
141+
142+
@subTests('symbol', ('single', 'exec', 'eval'))
143+
@subTests('prefix', ('', 'r', 'b', 'f', 't', 'rf', 'rt'))
144+
def test_string_continuation(self, symbol, prefix):
145+
source = prefix + '"first\\'
146+
compile(source + '\nsecond"\n', '<input>', symbol)
147+
self.assertIsNone(compile_command(source, symbol=symbol))
148+
with self.assertRaises(_IncompleteInputError):
149+
Compile()(source, '<input>', symbol)
150+
151+
@subTests('symbol', ('single', 'exec', 'eval'))
152+
@subTests('newline', ('\n', '\r', '\r\n'))
153+
def test_continued_final_line(self, symbol, newline):
154+
source = '1\\' + newline
155+
compile(source + '+ 2\n', '<input>', symbol)
156+
self.assertIsNone(compile_command(source, symbol=symbol))
157+
with self.assertRaises(_IncompleteInputError):
158+
Compile()(source, '<input>', symbol)
159+
with self.assertRaises(SyntaxError) as cm:
160+
Compile()('1 + \\' + newline, '<input>', symbol)
161+
# Appending LF to CR completes the CRLF line terminator.
162+
if newline != '\r':
163+
self.assertNotIsInstance(cm.exception, _IncompleteInputError)
164+
165+
@subTests('symbol', ('single', 'exec'))
166+
def test_eof_before_nested_body(self, symbol):
167+
for source in ('if True:\n if True:',
168+
'if True:\n def f():',
169+
'match 1:\n case 1:'):
170+
for tail in ('', '\n', '\n\n'):
171+
with self.subTest(source=source, tail=tail):
172+
compile(source + tail + '\n pass\n', '<input>', symbol)
173+
self.assertIsNone(compile_command(source + tail, symbol=symbol))
174+
with self.assertRaises(_IncompleteInputError):
175+
Compile()(source + tail, '<input>', symbol)
176+
177+
def test_incomplete_suite_semantic_errors(self):
178+
cases = (
179+
('if True: break', "'break' outside loop"),
180+
('if True: return 1', "'return' outside function"),
181+
('def f(x, x):\n pass', 'duplicate parameter'),
182+
)
183+
for source, message in cases:
184+
for compiler in COMPILERS:
185+
with self.subTest(source=source, compiler=compiler):
186+
with self.assertRaises(SyntaxError) as cm:
187+
compiler(source, '<input>', 'single')
188+
self.assertNotIsInstance(cm.exception, _IncompleteInputError)
189+
if compiler in RAW_COMPILERS:
190+
self.assertIn(message, str(cm.exception))
191+
127192
@subTests('compiler', COMPILERS)
128193
def test_incomplete(self, compiler):
129194
ai = functools.partial(self.assertIncomplete, compiler=compiler)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Improve incomplete input detection in :mod:`codeop` without an extra parser
2+
pass, and preserve syntax and compiler errors.

‎Modules/_testinternalcapi/tokenizer.c‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,35 @@ test_tokenizer_source(PyObject *Py_UNUSED(module),
9595
goto error;
9696
}
9797

98+
_PyTok_SourceClear(&source);
99+
if (check(_PyTok_SourceAppendLineWithNewline(&source, "", 0) == 0,
100+
"cannot append empty terminated line") < 0 ||
101+
check(_PyTok_SourceAppendLineWithNewline(&source, tail, 4) == 1,
102+
"wrong terminated line offset") < 0 ||
103+
check(source.len == 6 && source.nlines == 2 &&
104+
memcmp(source.bytes, "\ntail\n", 7) == 0,
105+
"wrong terminated source contents") < 0 ||
106+
check_system_error(_PyTok_SourceAppendLineWithNewline(
107+
&source, terminated_line, 2) < 0,
108+
"accepted newline in line payload") < 0) {
109+
goto error;
110+
}
111+
source.base_offset = PY_SSIZE_T_MAX - source.len;
112+
if (check(_PyTok_SourceAppendLineWithNewline(&source, "", 0) < 0 &&
113+
PyErr_ExceptionMatches(PyExc_MemoryError),
114+
"accepted overflowing appended newline") < 0) {
115+
goto error;
116+
}
117+
PyErr_Clear();
118+
source.base_offset = 0;
119+
source.nlines = INT_MAX - 1;
120+
if (check(_PyTok_SourceAppendLineWithNewline(&source, "", 0) < 0 &&
121+
PyErr_ExceptionMatches(PyExc_OverflowError),
122+
"accepted overflowing newline count") < 0) {
123+
goto error;
124+
}
125+
PyErr_Clear();
126+
98127
_PyTok_SourceClear(&source);
99128
Py_RETURN_NONE;
100129

‎Parser/action_helpers.c‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,11 +1497,11 @@ _PyPegen_joined_str(Parser *p, Token* a, asdl_expr_seq* raw_expressions, Token*b
14971497
}
14981498

14991499
expr_ty _PyPegen_decoded_constant_from_token(Parser* p, Token* tok) {
1500-
Py_ssize_t bsize;
1501-
char* bstr;
1502-
if (PyBytes_AsStringAndSize(tok->bytes, &bstr, &bsize) == -1) {
1503-
return NULL;
1500+
if (tok->bytes == NULL) {
1501+
return _PyPegen_raise_incomplete_input(p);
15041502
}
1503+
Py_ssize_t bsize = PyBytes_GET_SIZE(tok->bytes);
1504+
const char *bstr = PyBytes_AS_STRING(tok->bytes);
15051505

15061506
int is_raw = tok->is_raw;
15071507

@@ -1519,10 +1519,10 @@ expr_ty _PyPegen_decoded_constant_from_token(Parser* p, Token* tok) {
15191519
}
15201520

15211521
expr_ty _PyPegen_constant_from_token(Parser* p, Token* tok) {
1522-
const char* bstr = PyBytes_AsString(tok->bytes);
1523-
if (bstr == NULL) {
1524-
return NULL;
1522+
if (tok->bytes == NULL) {
1523+
return _PyPegen_raise_incomplete_input(p);
15251524
}
1525+
const char *bstr = PyBytes_AS_STRING(tok->bytes);
15261526
PyObject* str = PyUnicode_FromString(bstr);
15271527
if (str == NULL) {
15281528
return NULL;
@@ -1537,10 +1537,10 @@ expr_ty _PyPegen_constant_from_token(Parser* p, Token* tok) {
15371537
}
15381538

15391539
expr_ty _PyPegen_constant_from_string(Parser* p, Token* tok) {
1540-
const char* the_str = PyBytes_AsString(tok->bytes);
1541-
if (the_str == NULL) {
1542-
return NULL;
1540+
if (tok->bytes == NULL) {
1541+
return _PyPegen_raise_incomplete_input(p);
15431542
}
1543+
const char *the_str = PyBytes_AS_STRING(tok->bytes);
15441544
PyObject *s = _PyPegen_parse_string(p, tok);
15451545
if (s == NULL) {
15461546
_Pypegen_raise_decode_error(p);

‎Parser/lexer/layout.c‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ _PyLexer_ContinueLine(struct tok_state *tok)
2121
return -1;
2222
}
2323
c = tok_nextc(tok);
24+
if (tok->implicit_newline == _PyTokenizer_APPEND_NEWLINE) {
25+
tok->needs_newline = 1;
26+
}
2427
if (c == EOF) {
2528
tok->done = E_EOF;
2629
tok->cur = tok->inp;

‎Parser/lexer/lexer.c‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ _PyTokenizer_Get(struct tok_state *tok, struct token *token)
516516
Py_UNREACHABLE();
517517
}
518518
}
519-
if (tok_failed(tok)) {
519+
if (tok_failed(tok) && tok->done != E_INCOMPLETE) {
520520
result = ERRORTOKEN;
521521
}
522522
token->type = result;

‎Parser/lexer/state.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ struct tok_state {
105105
int debug;
106106
#endif
107107
int barry_as_bdfl;
108+
unsigned char allow_incomplete_input;
109+
unsigned char needs_newline;
108110
};
109111

110112
static inline ftstring_state *

0 commit comments

Comments
 (0)