Skip to content

Commit b86a34f

Browse files
committed
Improve native parser version mismatch errors
1 parent 1804d87 commit b86a34f

3 files changed

Lines changed: 161 additions & 33 deletions

File tree

mypy/nativeparse.py

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import os
2222
import time
23+
from functools import cache
24+
from importlib import metadata
2325
from typing import Final, cast
2426

2527
import ast_serialize
@@ -161,6 +163,81 @@
161163

162164
TypeIgnores = list[tuple[int, list[str]]]
163165

166+
AST_SERIALIZE_REQUIREMENT: Final = ">=0.3.0,<1.0.0"
167+
AST_SERIALIZE_MIN_VERSION: Final = (0, 3, 0)
168+
AST_SERIALIZE_MAX_VERSION: Final = (1, 0, 0)
169+
170+
171+
class NativeParserError(Exception):
172+
"""Raised when the native parser cannot produce compatible serialized data."""
173+
174+
175+
@cache
176+
def ast_serialize_version() -> str | None:
177+
"""Return the installed ast-serialize package version, if available."""
178+
try:
179+
return metadata.version("ast-serialize")
180+
except metadata.PackageNotFoundError:
181+
return None
182+
183+
184+
def _parse_release(version: str) -> tuple[int, ...] | None:
185+
release = version.split("+", 1)[0].split("-", 1)[0]
186+
parts = []
187+
for part in release.split("."):
188+
digits = ""
189+
for char in part:
190+
if not char.isdigit():
191+
break
192+
digits += char
193+
if not digits:
194+
break
195+
parts.append(int(digits))
196+
return tuple(parts) if parts else None
197+
198+
199+
def _is_release_less(left: tuple[int, ...], right: tuple[int, ...]) -> bool:
200+
size = max(len(left), len(right))
201+
left += (0,) * (size - len(left))
202+
right += (0,) * (size - len(right))
203+
return left < right
204+
205+
206+
@cache
207+
def _check_ast_serialize_version() -> None:
208+
version = ast_serialize_version()
209+
if version is None:
210+
return
211+
release = _parse_release(version)
212+
if release is None:
213+
return
214+
if _is_release_less(release, AST_SERIALIZE_MIN_VERSION) or not _is_release_less(
215+
release, AST_SERIALIZE_MAX_VERSION
216+
):
217+
raise NativeParserError(
218+
f"Incompatible ast-serialize version {version} is installed; "
219+
f"mypy requires ast-serialize{AST_SERIALIZE_REQUIREMENT}. "
220+
"Upgrade ast-serialize or reinstall mypy's dependencies."
221+
)
222+
223+
224+
def _format_native_parser_exception(err: BaseException) -> str:
225+
detail = str(err)
226+
return f"{type(err).__name__}: {detail}" if detail else type(err).__name__
227+
228+
229+
def invalid_ast_serialize_data_message(err: BaseException) -> str:
230+
version = ast_serialize_version()
231+
installed = f" (installed ast-serialize: {version})" if version is not None else ""
232+
return (
233+
"The native parser produced serialized AST data that mypy cannot read. "
234+
"This usually means an incompatible ast-serialize version is installed"
235+
f"{installed}; mypy requires ast-serialize{AST_SERIALIZE_REQUIREMENT}. "
236+
"Upgrade ast-serialize or reinstall mypy's dependencies. "
237+
f"Original error: {_format_native_parser_exception(err)}"
238+
)
239+
240+
164241
# There is no way to create reasonable fallbacks at this stage,
165242
# they must be patched later.
166243
_dummy_fallback: Final = Instance(MISSING_FALLBACK, [], -1)
@@ -257,25 +334,29 @@ def parse_to_binary_ast(
257334
time.sleep(0.0001) # type: ignore[unreachable]
258335
if time.time() - t0 > 10.0:
259336
raise ImportError("Cannot import ast_serialize")
260-
ast_bytes, errors, ignores, import_bytes, ast_data = ast_serialize.parse(
261-
filename,
262-
skip_function_bodies=skip_function_bodies,
263-
python_version=options.python_version,
264-
platform=options.platform,
265-
always_true=options.always_true,
266-
always_false=options.always_false,
267-
cache_version=3,
268-
)
269-
return (
270-
ast_bytes,
271-
errors,
272-
ignores,
273-
import_bytes,
274-
ast_data["is_partial_package"],
275-
ast_data["uses_template_strings"],
276-
ast_data["source_hash"],
277-
ast_data["mypy_comments"],
278-
)
337+
_check_ast_serialize_version()
338+
try:
339+
ast_bytes, errors, ignores, import_bytes, ast_data = ast_serialize.parse(
340+
filename,
341+
skip_function_bodies=skip_function_bodies,
342+
python_version=options.python_version,
343+
platform=options.platform,
344+
always_true=options.always_true,
345+
always_false=options.always_false,
346+
cache_version=3,
347+
)
348+
return (
349+
ast_bytes,
350+
errors,
351+
ignores,
352+
import_bytes,
353+
ast_data["is_partial_package"],
354+
ast_data["uses_template_strings"],
355+
ast_data["source_hash"],
356+
ast_data["mypy_comments"],
357+
)
358+
except (AssertionError, KeyError, TypeError, ValueError) as err:
359+
raise NativeParserError(invalid_ast_serialize_data_message(err)) from err
279360

280361

281362
def read_statement(state: State, data: ReadBuffer) -> Statement:

mypy/parse.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import re
4+
from typing import NoReturn
45

56
from librt.internal import ReadBuffer
67

@@ -11,6 +12,14 @@
1112
from mypy.options import Options
1213

1314

15+
def _raise_native_parser_error(
16+
fnam: str, module: str | None, errors: Errors, options: Options, message: str
17+
) -> NoReturn:
18+
errors.set_file(fnam, module, options=options)
19+
errors.report(-1, None, message, blocker=True)
20+
errors.raise_error()
21+
22+
1423
def parse(
1524
source: str | bytes,
1625
fnam: str,
@@ -37,9 +46,12 @@ def parse(
3746
ignore_errors = options.ignore_errors or fnam in errors.ignored_files
3847
# If errors are ignored, we can drop many function bodies to speed up type checking.
3948
strip_function_bodies = ignore_errors and not options.preserve_asts
40-
tree, _, _ = mypy.nativeparse.native_parse(
41-
fnam, options, skip_function_bodies=strip_function_bodies
42-
)
49+
try:
50+
tree, _, _ = mypy.nativeparse.native_parse(
51+
fnam, options, skip_function_bodies=strip_function_bodies
52+
)
53+
except mypy.nativeparse.NativeParserError as err:
54+
_raise_native_parser_error(fnam, module, errors, options, str(err))
4355
# Set is_stub based on file extension
4456
tree.is_stub = fnam.endswith(".pyi")
4557
# Note: tree.imports is populated directly by load_from_raw() with deserialized
@@ -69,16 +81,34 @@ def load_from_raw(
6981
If imports_only is true, only deserialize imports and return a mostly
7082
empty AST.
7183
"""
72-
from mypy.nativeparse import State, deserialize_imports, read_statements
84+
from mypy.nativeparse import (
85+
State,
86+
deserialize_imports,
87+
invalid_ast_serialize_data_message,
88+
read_statements,
89+
)
7390

7491
state = State(options)
75-
if imports_only:
76-
defs = []
77-
else:
78-
data = ReadBuffer(raw_data.defs)
79-
n = read_int(data)
80-
defs = read_statements(state, data, n)
81-
imports = deserialize_imports(raw_data.imports)
92+
try:
93+
if imports_only:
94+
defs = []
95+
else:
96+
data = ReadBuffer(raw_data.defs)
97+
n = read_int(data)
98+
defs = read_statements(state, data, n)
99+
imports = deserialize_imports(raw_data.imports)
100+
except (
101+
AssertionError,
102+
EOFError,
103+
IndexError,
104+
KeyError,
105+
TypeError,
106+
UnicodeDecodeError,
107+
ValueError,
108+
) as err:
109+
_raise_native_parser_error(
110+
fnam, module, errors, options, invalid_ast_serialize_data_message(err)
111+
)
82112

83113
tree = MypyFile(defs, imports)
84114
tree.path = fnam
@@ -93,7 +123,8 @@ def load_from_raw(
93123
all_errors = raw_data.raw_errors + state.errors
94124
errors.set_file(fnam, module, options=options)
95125
for error in all_errors:
96-
# Note we never raise in this function, so it should not be called in coordinator.
126+
# Regular parse errors are reported here; invalid serialized native parser
127+
# data is converted to a blocking error above.
97128
report_parse_error(error, errors)
98129
if imports_only:
99130
# Preserve raw data when only de-serializing imports, it will be sent to

mypy/test/test_nativeparse.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626
read_int,
2727
)
2828
from mypy.config_parser import parse_mypy_comments
29-
from mypy.errors import CompileError
30-
from mypy.nodes import MypyFile, ParseError
29+
from mypy.errors import CompileError, Errors
30+
from mypy.nodes import FileRawData, MypyFile, ParseError
3131
from mypy.options import Options
32+
from mypy.parse import load_from_raw
3233
from mypy.test.data import DataDrivenTestCase, DataSuite
3334
from mypy.test.helpers import assert_string_arrays_equal
3435
from mypy.util import get_mypy_comments
@@ -271,6 +272,21 @@ def locs(start_line: int, start_column: int, end_line: int, end_column: int) ->
271272
+ [END_TAG, END_TAG]
272273
)
273274

275+
def test_incompatible_binary_data_reports_clear_error(self) -> None:
276+
raw_data = FileRawData(bytes([LITERAL_NONE]), b"", [], {}, False, False)
277+
options = Options()
278+
errors = Errors(options)
279+
280+
with self.assertRaises(CompileError) as cm:
281+
load_from_raw("bad.py", "bad", raw_data, errors, options)
282+
283+
self.assertEqual(cm.exception.module_with_blocker, "bad")
284+
self.assertEqual(len(cm.exception.messages), 1)
285+
message = cm.exception.messages[0]
286+
self.assertIn("bad.py: error: The native parser produced serialized AST data", message)
287+
self.assertIn("incompatible ast-serialize version", message)
288+
self.assertIn("Original error: AssertionError", message)
289+
274290

275291
@contextlib.contextmanager
276292
def temp_source(text: str) -> Iterator[str]:

0 commit comments

Comments
 (0)