-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Error format support, and JSON output option #11396
Changes from 46 commits
393820c
282bd28
ccda5b0
c849a77
fd2feab
b188001
51c1acc
9177dab
bc5ceac
9d29ab0
bd6d48d
ba8d17f
a2bc04d
35974e4
1e5ec91
723219f
2228c0a
33d81b0
63001ea
d27be7e
efe5c5d
1872ae6
3abc9cb
6c9ab11
627ed8e
e425cbe
47f1b07
e00ad4a
c1fb6a2
aafe3aa
fae3215
89ad1d3
7a3f736
6d46f75
e71a372
8cca203
79e16a8
8bf4890
5899f26
880b8f3
0aafadf
4cab249
7fe71c3
ad8f1d6
e2fd45e
4b03c5c
e0e6896
a0dc6d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
"""Defines the different custom formats in which mypy can output.""" | ||
|
||
import json | ||
from abc import ABC, abstractmethod | ||
from typing import TYPE_CHECKING | ||
|
||
if TYPE_CHECKING: | ||
from mypy.errors import MypyError | ||
|
||
|
||
class ErrorFormatter(ABC): | ||
"""Base class to define how errors are formatted before being printed.""" | ||
|
||
@abstractmethod | ||
def report_error(self, error: "MypyError") -> str: | ||
raise NotImplementedError | ||
|
||
|
||
class JSONFormatter(ErrorFormatter): | ||
"""Formatter for basic JSON output format.""" | ||
|
||
def report_error(self, error: "MypyError") -> str: | ||
"""Prints out the errors as simple, static JSON lines.""" | ||
return json.dumps( | ||
{ | ||
"file": error.file_path, | ||
"line": error.line, | ||
"column": error.column, | ||
"message": error.message, | ||
"hint": error.hint or None, | ||
"code": None if error.errorcode is None else error.errorcode.code, | ||
} | ||
) | ||
|
||
|
||
OUTPUT_CHOICES = {"json": JSONFormatter()} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,7 @@ | |
from typing_extensions import Literal, TypeAlias as _TypeAlias | ||
|
||
from mypy import errorcodes as codes | ||
from mypy.error_formatter import ErrorFormatter | ||
from mypy.errorcodes import IMPORT, IMPORT_NOT_FOUND, IMPORT_UNTYPED, ErrorCode, mypy_error_codes | ||
from mypy.message_registry import ErrorMessage | ||
from mypy.options import Options | ||
|
@@ -834,7 +835,7 @@ def raise_error(self, use_stdout: bool = True) -> NoReturn: | |
) | ||
|
||
def format_messages( | ||
self, error_info: list[ErrorInfo], source_lines: list[str] | None | ||
self, error_tuples: list[ErrorTuple], source_lines: list[str] | None | ||
) -> list[str]: | ||
"""Return a string list that represents the error messages. | ||
|
||
|
@@ -843,9 +844,6 @@ def format_messages( | |
severity 'error'). | ||
""" | ||
a: list[str] = [] | ||
error_info = [info for info in error_info if not info.hidden] | ||
errors = self.render_messages(self.sort_messages(error_info)) | ||
errors = self.remove_duplicates(errors) | ||
for ( | ||
file, | ||
line, | ||
|
@@ -856,7 +854,7 @@ def format_messages( | |
message, | ||
allow_dups, | ||
code, | ||
) in errors: | ||
) in error_tuples: | ||
s = "" | ||
if file is not None: | ||
if self.options.show_column_numbers and line >= 0 and column >= 0: | ||
|
@@ -901,18 +899,28 @@ def format_messages( | |
a.append(" " * (DEFAULT_SOURCE_OFFSET + column) + marker) | ||
return a | ||
|
||
def file_messages(self, path: str) -> list[str]: | ||
def file_messages(self, path: str, formatter: ErrorFormatter | None = None) -> list[str]: | ||
"""Return a string list of new error messages from a given file. | ||
|
||
Use a form suitable for displaying to the user. | ||
""" | ||
if path not in self.error_info_map: | ||
return [] | ||
|
||
error_info = self.error_info_map[path] | ||
error_info = [info for info in error_info if not info.hidden] | ||
error_tuples = self.render_messages(self.sort_messages(error_info)) | ||
error_tuples = self.remove_duplicates(error_tuples) | ||
|
||
if formatter is not None: | ||
errors = create_errors(error_tuples) | ||
return [formatter.report_error(err) for err in errors] | ||
|
||
self.flushed_files.add(path) | ||
source_lines = None | ||
if self.options.pretty and self.read_source: | ||
source_lines = self.read_source(path) | ||
return self.format_messages(self.error_info_map[path], source_lines) | ||
return self.format_messages(error_tuples, source_lines) | ||
|
||
def new_messages(self) -> list[str]: | ||
"""Return a string list of new error messages. | ||
|
@@ -1278,3 +1286,56 @@ def report_internal_error( | |
# Exit. The caller has nothing more to say. | ||
# We use exit code 2 to signal that this is no ordinary error. | ||
raise SystemExit(2) | ||
|
||
|
||
class MypyError: | ||
def __init__( | ||
self, | ||
file_path: str, | ||
line: int, | ||
column: int, | ||
message: str, | ||
hint: str, | ||
errorcode: ErrorCode | None, | ||
) -> None: | ||
self.file_path = file_path | ||
self.line = line | ||
self.column = column | ||
self.message = message | ||
self.hint = hint | ||
self.errorcode = errorcode | ||
|
||
|
||
# (file_path, line, column) | ||
_ErrorLocation = Tuple[str, int, int] | ||
|
||
|
||
def create_errors(error_tuples: list[ErrorTuple]) -> list[MypyError]: | ||
errors: list[MypyError] = [] | ||
latest_error_at_location: dict[_ErrorLocation, MypyError] = {} | ||
|
||
for error_tuple in error_tuples: | ||
file_path, line, column, _, _, severity, message, _, errorcode = error_tuple | ||
if file_path is None: | ||
continue | ||
|
||
assert severity in ("error", "note") | ||
if severity == "note": | ||
error_location = (file_path, line, column) | ||
error = latest_error_at_location.get(error_location) | ||
if error is None: | ||
# No error tuple found for this hint. Ignoring it | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not instead generate a MypyError with some field that lets us indicate that this is a note? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed. |
||
continue | ||
|
||
if error.hint == "": | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we make hint into a list of strings instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's often that a single hint is wrapped into multiple lines. For internal representaiton we can keep it as a list of strings but for the user I think it makes most sense to display it as a single string. |
||
error.hint = message | ||
else: | ||
error.hint += "\n" + message | ||
|
||
else: | ||
error = MypyError(file_path, line, column, message, "", errorcode) | ||
errors.append(error) | ||
error_location = (file_path, line, column) | ||
latest_error_at_location[error_location] = error | ||
|
||
return errors |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -376,10 +376,12 @@ def __init__(self) -> None: | |
|
||
self.disable_bytearray_promotion = False | ||
self.disable_memoryview_promotion = False | ||
|
||
self.force_uppercase_builtins = False | ||
self.force_union_syntax = False | ||
|
||
# Sets custom output format | ||
self.output = "" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would None be a better default? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup. |
||
|
||
def use_lowercase_names(self) -> bool: | ||
if self.python_version >= (3, 9): | ||
return not self.force_uppercase_builtins | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
"""Test cases for `--output=json`. | ||
|
||
These cannot be run by the usual unit test runner because of the backslashes in | ||
the output, which get normalized to forward slashes by the test suite on Windows. | ||
""" | ||
|
||
from __future__ import annotations | ||
|
||
import os | ||
import os.path | ||
|
||
from mypy import api | ||
from mypy.defaults import PYTHON3_VERSION | ||
from mypy.test.config import test_temp_dir | ||
from mypy.test.data import DataDrivenTestCase, DataSuite | ||
|
||
|
||
class OutputJSONsuite(DataSuite): | ||
files = ["outputjson.test"] | ||
|
||
def run_case(self, testcase: DataDrivenTestCase) -> None: | ||
test_output_json(testcase) | ||
|
||
|
||
def test_output_json(testcase: DataDrivenTestCase) -> None: | ||
"""Runs Mypy in a subprocess, and ensures that `--output=json` works as intended.""" | ||
mypy_cmdline = ["--output=json"] | ||
mypy_cmdline.append(f"--python-version={'.'.join(map(str, PYTHON3_VERSION))}") | ||
|
||
# Write the program to a file. | ||
program_path = os.path.join(test_temp_dir, "main") | ||
mypy_cmdline.append(program_path) | ||
with open(program_path, "w", encoding="utf8") as file: | ||
for s in testcase.input: | ||
file.write(f"{s}\n") | ||
|
||
output = [] | ||
# Type check the program. | ||
out, err, returncode = api.run(mypy_cmdline) | ||
# split lines, remove newlines, and remove directory of test case | ||
for line in (out + err).rstrip("\n").splitlines(): | ||
if line.startswith(test_temp_dir + os.sep): | ||
output.append(line[len(test_temp_dir + os.sep) :].rstrip("\r\n")) | ||
else: | ||
output.append(line.rstrip("\r\n")) | ||
|
||
if returncode > 1: | ||
output.append("!!! Mypy crashed !!!") | ||
|
||
# Remove temp file. | ||
os.remove(program_path) | ||
|
||
# JSON encodes every `\` character into `\\`, so we need to remove `\\` from windows paths | ||
# and `/` from POSIX paths | ||
json_os_separator = os.sep.replace("\\", "\\\\") | ||
normalized_output = [line.replace(test_temp_dir + json_os_separator, "") for line in output] | ||
|
||
assert normalized_output == testcase.output |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
-- Test cases for `--output=json`. | ||
-- These cannot be run by the usual unit test runner because of the backslashes | ||
-- in the output, which get normalized to forward slashes by the test suite on | ||
-- Windows. | ||
|
||
[case testOutputJsonNoIssues] | ||
# flags: --output=json | ||
def foo() -> None: | ||
pass | ||
|
||
foo() | ||
[out] | ||
|
||
[case testOutputJsonSimple] | ||
# flags: --output=json | ||
def foo() -> None: | ||
pass | ||
|
||
foo(1) | ||
[out] | ||
{"file": "main", "line": 5, "column": 0, "message": "Too many arguments for \"foo\"", "hint": null, "code": "call-arg"} | ||
|
||
[case testOutputJsonWithHint] | ||
# flags: --output=json | ||
from typing import Optional, overload | ||
|
||
@overload | ||
def foo() -> None: ... | ||
@overload | ||
def foo(x: int) -> None: ... | ||
|
||
def foo(x: Optional[int] = None) -> None: | ||
... | ||
|
||
foo('42') | ||
|
||
def bar() -> None: ... | ||
bar('42') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a test case with |
||
[out] | ||
{"file": "main", "line": 12, "column": 0, "message": "No overload variant of \"foo\" matches argument type \"str\"", "hint": "Possible overload variants:\n def foo() -> None\n def foo(x: int) -> None", "code": "call-overload"} | ||
{"file": "main", "line": 15, "column": 0, "message": "Too many arguments for \"bar\"", "hint": null, "code": "call-arg"} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This module should define a dictionary
str -> ErrorFormatter
that can be used inbuild.py
.