Skip to content

Commit 35fbd2a

Browse files
Add Error format support, and JSON output option (#11396)
### Description Resolves #10816 The changes this PR makes are relatively small. It currently: - Adds an `--output` option to mypy CLI - Adds a `ErrorFormatter` abstract base class, which can be subclassed to create new output formats - Adds a `MypyError` class that represents the external format of a mypy error. - Adds a check for `--output` being `'json'`, in which case the `JSONFormatter` is used to produce the reported output. #### Demo: ```console $ mypy mytest.py mytest.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int") mytest.py:3: error: Name "z" is not defined Found 2 errors in 1 file (checked 1 source file) $ mypy mytest.py --output=json {"file": "mytest.py", "line": 2, "column": 4, "severity": "error", "message": "Incompatible types in assignment (expression has type \"str\", variable has type \"int\")", "code": "assignment"} {"file": "mytest.py", "line": 3, "column": 4, "severity": "error", "message": "Name \"z\" is not defined", "code": "name-defined"} ``` --- A few notes regarding the changes: - I chose to re-use the intermediate `ErrorTuple`s created during error reporting, instead of using the more general `ErrorInfo` class, because a lot of machinery already exists in mypy for sorting and removing duplicate error reports, which produces `ErrorTuple`s at the end. The error sorting and duplicate removal logic could perhaps be separated out from the rest of the code, to be able to use `ErrorInfo` objects more freely. - `ErrorFormatter` doesn't really need to be an abstract class, but I think it would be better this way. If there's a different method that would be preferred, I'd be happy to know. - The `--output` CLI option is, most probably, not added in the correct place. Any help in how to do it properly would be appreciated, the mypy option parsing code seems very complex. - The ability to add custom output formats can be simply added by subclassing the `ErrorFormatter` class inside a mypy plugin, and adding a `name` field to the formatters. The mypy runtime can then check through the `__subclasses__` of the formatter and determine if such a formatter is present. The "checking for the `name` field" part of this code might be appropriate to add within this PR itself, instead of hard-coding `JSONFormatter`. Does that sound like a good idea? --------- Co-authored-by: Tushar Sadhwani <[email protected]> Co-authored-by: Tushar Sadhwani <[email protected]>
1 parent fb31409 commit 35fbd2a

File tree

8 files changed

+239
-16
lines changed

8 files changed

+239
-16
lines changed

mypy/build.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444

4545
import mypy.semanal_main
4646
from mypy.checker import TypeChecker
47+
from mypy.error_formatter import OUTPUT_CHOICES, ErrorFormatter
4748
from mypy.errors import CompileError, ErrorInfo, Errors, report_internal_error
4849
from mypy.graph_utils import prepare_sccs, strongly_connected_components, topsort
4950
from mypy.indirection import TypeIndirectionVisitor
@@ -253,6 +254,7 @@ def _build(
253254
plugin=plugin,
254255
plugins_snapshot=snapshot,
255256
errors=errors,
257+
error_formatter=None if options.output is None else OUTPUT_CHOICES.get(options.output),
256258
flush_errors=flush_errors,
257259
fscache=fscache,
258260
stdout=stdout,
@@ -607,6 +609,7 @@ def __init__(
607609
fscache: FileSystemCache,
608610
stdout: TextIO,
609611
stderr: TextIO,
612+
error_formatter: ErrorFormatter | None = None,
610613
) -> None:
611614
self.stats: dict[str, Any] = {} # Values are ints or floats
612615
self.stdout = stdout
@@ -615,6 +618,7 @@ def __init__(
615618
self.data_dir = data_dir
616619
self.errors = errors
617620
self.errors.set_ignore_prefix(ignore_prefix)
621+
self.error_formatter = error_formatter
618622
self.search_paths = search_paths
619623
self.source_set = source_set
620624
self.reports = reports
@@ -3463,11 +3467,8 @@ def process_stale_scc(graph: Graph, scc: list[str], manager: BuildManager) -> No
34633467
for id in stale:
34643468
graph[id].transitive_error = True
34653469
for id in stale:
3466-
manager.flush_errors(
3467-
manager.errors.simplify_path(graph[id].xpath),
3468-
manager.errors.file_messages(graph[id].xpath),
3469-
False,
3470-
)
3470+
errors = manager.errors.file_messages(graph[id].xpath, formatter=manager.error_formatter)
3471+
manager.flush_errors(manager.errors.simplify_path(graph[id].xpath), errors, False)
34713472
graph[id].write_cache()
34723473
graph[id].mark_as_rechecked()
34733474

mypy/error_formatter.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Defines the different custom formats in which mypy can output."""
2+
3+
import json
4+
from abc import ABC, abstractmethod
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from mypy.errors import MypyError
9+
10+
11+
class ErrorFormatter(ABC):
12+
"""Base class to define how errors are formatted before being printed."""
13+
14+
@abstractmethod
15+
def report_error(self, error: "MypyError") -> str:
16+
raise NotImplementedError
17+
18+
19+
class JSONFormatter(ErrorFormatter):
20+
"""Formatter for basic JSON output format."""
21+
22+
def report_error(self, error: "MypyError") -> str:
23+
"""Prints out the errors as simple, static JSON lines."""
24+
return json.dumps(
25+
{
26+
"file": error.file_path,
27+
"line": error.line,
28+
"column": error.column,
29+
"message": error.message,
30+
"hint": None if len(error.hints) == 0 else "\n".join(error.hints),
31+
"code": None if error.errorcode is None else error.errorcode.code,
32+
"severity": error.severity,
33+
}
34+
)
35+
36+
37+
OUTPUT_CHOICES = {"json": JSONFormatter()}

mypy/errors.py

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing_extensions import Literal, TypeAlias as _TypeAlias
99

1010
from mypy import errorcodes as codes
11+
from mypy.error_formatter import ErrorFormatter
1112
from mypy.errorcodes import IMPORT, IMPORT_NOT_FOUND, IMPORT_UNTYPED, ErrorCode, mypy_error_codes
1213
from mypy.message_registry import ErrorMessage
1314
from mypy.options import Options
@@ -834,7 +835,7 @@ def raise_error(self, use_stdout: bool = True) -> NoReturn:
834835
)
835836

836837
def format_messages(
837-
self, error_info: list[ErrorInfo], source_lines: list[str] | None
838+
self, error_tuples: list[ErrorTuple], source_lines: list[str] | None
838839
) -> list[str]:
839840
"""Return a string list that represents the error messages.
840841
@@ -843,9 +844,6 @@ def format_messages(
843844
severity 'error').
844845
"""
845846
a: list[str] = []
846-
error_info = [info for info in error_info if not info.hidden]
847-
errors = self.render_messages(self.sort_messages(error_info))
848-
errors = self.remove_duplicates(errors)
849847
for (
850848
file,
851849
line,
@@ -856,7 +854,7 @@ def format_messages(
856854
message,
857855
allow_dups,
858856
code,
859-
) in errors:
857+
) in error_tuples:
860858
s = ""
861859
if file is not None:
862860
if self.options.show_column_numbers and line >= 0 and column >= 0:
@@ -901,18 +899,28 @@ def format_messages(
901899
a.append(" " * (DEFAULT_SOURCE_OFFSET + column) + marker)
902900
return a
903901

904-
def file_messages(self, path: str) -> list[str]:
902+
def file_messages(self, path: str, formatter: ErrorFormatter | None = None) -> list[str]:
905903
"""Return a string list of new error messages from a given file.
906904
907905
Use a form suitable for displaying to the user.
908906
"""
909907
if path not in self.error_info_map:
910908
return []
909+
910+
error_info = self.error_info_map[path]
911+
error_info = [info for info in error_info if not info.hidden]
912+
error_tuples = self.render_messages(self.sort_messages(error_info))
913+
error_tuples = self.remove_duplicates(error_tuples)
914+
915+
if formatter is not None:
916+
errors = create_errors(error_tuples)
917+
return [formatter.report_error(err) for err in errors]
918+
911919
self.flushed_files.add(path)
912920
source_lines = None
913921
if self.options.pretty and self.read_source:
914922
source_lines = self.read_source(path)
915-
return self.format_messages(self.error_info_map[path], source_lines)
923+
return self.format_messages(error_tuples, source_lines)
916924

917925
def new_messages(self) -> list[str]:
918926
"""Return a string list of new error messages.
@@ -1278,3 +1286,56 @@ def report_internal_error(
12781286
# Exit. The caller has nothing more to say.
12791287
# We use exit code 2 to signal that this is no ordinary error.
12801288
raise SystemExit(2)
1289+
1290+
1291+
class MypyError:
1292+
def __init__(
1293+
self,
1294+
file_path: str,
1295+
line: int,
1296+
column: int,
1297+
message: str,
1298+
errorcode: ErrorCode | None,
1299+
severity: Literal["error", "note"],
1300+
) -> None:
1301+
self.file_path = file_path
1302+
self.line = line
1303+
self.column = column
1304+
self.message = message
1305+
self.errorcode = errorcode
1306+
self.severity = severity
1307+
self.hints: list[str] = []
1308+
1309+
1310+
# (file_path, line, column)
1311+
_ErrorLocation = Tuple[str, int, int]
1312+
1313+
1314+
def create_errors(error_tuples: list[ErrorTuple]) -> list[MypyError]:
1315+
errors: list[MypyError] = []
1316+
latest_error_at_location: dict[_ErrorLocation, MypyError] = {}
1317+
1318+
for error_tuple in error_tuples:
1319+
file_path, line, column, _, _, severity, message, _, errorcode = error_tuple
1320+
if file_path is None:
1321+
continue
1322+
1323+
assert severity in ("error", "note")
1324+
if severity == "note":
1325+
error_location = (file_path, line, column)
1326+
error = latest_error_at_location.get(error_location)
1327+
if error is None:
1328+
# This is purely a note, with no error correlated to it
1329+
error = MypyError(file_path, line, column, message, errorcode, severity="note")
1330+
errors.append(error)
1331+
continue
1332+
1333+
error.hints.append(message)
1334+
1335+
else:
1336+
error = MypyError(file_path, line, column, message, errorcode, severity="error")
1337+
errors.append(error)
1338+
error_location = (file_path, line, column)
1339+
latest_error_at_location[error_location] = error
1340+
1341+
return errors

mypy/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
parse_version,
1919
validate_package_allow_list,
2020
)
21+
from mypy.error_formatter import OUTPUT_CHOICES
2122
from mypy.errorcodes import error_codes
2223
from mypy.errors import CompileError
2324
from mypy.find_sources import InvalidSourceList, create_source_list
@@ -72,7 +73,9 @@ def main(
7273
if clean_exit:
7374
options.fast_exit = False
7475

75-
formatter = util.FancyFormatter(stdout, stderr, options.hide_error_codes)
76+
formatter = util.FancyFormatter(
77+
stdout, stderr, options.hide_error_codes, hide_success=bool(options.output)
78+
)
7679

7780
if options.install_types and (stdout is not sys.stdout or stderr is not sys.stderr):
7881
# Since --install-types performs user input, we want regular stdout and stderr.
@@ -156,7 +159,9 @@ def run_build(
156159
stdout: TextIO,
157160
stderr: TextIO,
158161
) -> tuple[build.BuildResult | None, list[str], bool]:
159-
formatter = util.FancyFormatter(stdout, stderr, options.hide_error_codes)
162+
formatter = util.FancyFormatter(
163+
stdout, stderr, options.hide_error_codes, hide_success=bool(options.output)
164+
)
160165

161166
messages = []
162167
messages_by_file = defaultdict(list)
@@ -525,6 +530,14 @@ def add_invertible_flag(
525530
stdout=stdout,
526531
)
527532

533+
general_group.add_argument(
534+
"-O",
535+
"--output",
536+
metavar="FORMAT",
537+
help="Set a custom output format",
538+
choices=OUTPUT_CHOICES,
539+
)
540+
528541
config_group = parser.add_argument_group(
529542
title="Config file",
530543
description="Use a config file instead of command line arguments. "

mypy/options.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,12 @@ def __init__(self) -> None:
376376

377377
self.disable_bytearray_promotion = False
378378
self.disable_memoryview_promotion = False
379-
380379
self.force_uppercase_builtins = False
381380
self.force_union_syntax = False
382381

382+
# Sets custom output format
383+
self.output: str | None = None
384+
383385
def use_lowercase_names(self) -> bool:
384386
if self.python_version >= (3, 9):
385387
return not self.force_uppercase_builtins

mypy/test/testoutput.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Test cases for `--output=json`.
2+
3+
These cannot be run by the usual unit test runner because of the backslashes in
4+
the output, which get normalized to forward slashes by the test suite on Windows.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import os.path
11+
12+
from mypy import api
13+
from mypy.defaults import PYTHON3_VERSION
14+
from mypy.test.config import test_temp_dir
15+
from mypy.test.data import DataDrivenTestCase, DataSuite
16+
17+
18+
class OutputJSONsuite(DataSuite):
19+
files = ["outputjson.test"]
20+
21+
def run_case(self, testcase: DataDrivenTestCase) -> None:
22+
test_output_json(testcase)
23+
24+
25+
def test_output_json(testcase: DataDrivenTestCase) -> None:
26+
"""Runs Mypy in a subprocess, and ensures that `--output=json` works as intended."""
27+
mypy_cmdline = ["--output=json"]
28+
mypy_cmdline.append(f"--python-version={'.'.join(map(str, PYTHON3_VERSION))}")
29+
30+
# Write the program to a file.
31+
program_path = os.path.join(test_temp_dir, "main")
32+
mypy_cmdline.append(program_path)
33+
with open(program_path, "w", encoding="utf8") as file:
34+
for s in testcase.input:
35+
file.write(f"{s}\n")
36+
37+
output = []
38+
# Type check the program.
39+
out, err, returncode = api.run(mypy_cmdline)
40+
# split lines, remove newlines, and remove directory of test case
41+
for line in (out + err).rstrip("\n").splitlines():
42+
if line.startswith(test_temp_dir + os.sep):
43+
output.append(line[len(test_temp_dir + os.sep) :].rstrip("\r\n"))
44+
else:
45+
output.append(line.rstrip("\r\n"))
46+
47+
if returncode > 1:
48+
output.append("!!! Mypy crashed !!!")
49+
50+
# Remove temp file.
51+
os.remove(program_path)
52+
53+
# JSON encodes every `\` character into `\\`, so we need to remove `\\` from windows paths
54+
# and `/` from POSIX paths
55+
json_os_separator = os.sep.replace("\\", "\\\\")
56+
normalized_output = [line.replace(test_temp_dir + json_os_separator, "") for line in output]
57+
58+
assert normalized_output == testcase.output

mypy/util.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,12 @@ class FancyFormatter:
563563
This currently only works on Linux and Mac.
564564
"""
565565

566-
def __init__(self, f_out: IO[str], f_err: IO[str], hide_error_codes: bool) -> None:
566+
def __init__(
567+
self, f_out: IO[str], f_err: IO[str], hide_error_codes: bool, hide_success: bool = False
568+
) -> None:
567569
self.hide_error_codes = hide_error_codes
570+
self.hide_success = hide_success
571+
568572
# Check if we are in a human-facing terminal on a supported platform.
569573
if sys.platform not in ("linux", "darwin", "win32", "emscripten"):
570574
self.dummy_term = True
@@ -793,6 +797,9 @@ def format_success(self, n_sources: int, use_color: bool = True) -> str:
793797
n_sources is total number of files passed directly on command line,
794798
i.e. excluding stubs and followed imports.
795799
"""
800+
if self.hide_success:
801+
return ""
802+
796803
msg = f"Success: no issues found in {n_sources} source file{plural_s(n_sources)}"
797804
if not use_color:
798805
return msg

test-data/unit/outputjson.test

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- Test cases for `--output=json`.
2+
-- These cannot be run by the usual unit test runner because of the backslashes
3+
-- in the output, which get normalized to forward slashes by the test suite on
4+
-- Windows.
5+
6+
[case testOutputJsonNoIssues]
7+
# flags: --output=json
8+
def foo() -> None:
9+
pass
10+
11+
foo()
12+
[out]
13+
14+
[case testOutputJsonSimple]
15+
# flags: --output=json
16+
def foo() -> None:
17+
pass
18+
19+
foo(1)
20+
[out]
21+
{"file": "main", "line": 5, "column": 0, "message": "Too many arguments for \"foo\"", "hint": null, "code": "call-arg", "severity": "error"}
22+
23+
[case testOutputJsonWithHint]
24+
# flags: --output=json
25+
from typing import Optional, overload
26+
27+
@overload
28+
def foo() -> None: ...
29+
@overload
30+
def foo(x: int) -> None: ...
31+
32+
def foo(x: Optional[int] = None) -> None:
33+
...
34+
35+
reveal_type(foo)
36+
37+
foo('42')
38+
39+
def bar() -> None: ...
40+
bar('42')
41+
[out]
42+
{"file": "main", "line": 12, "column": 12, "message": "Revealed type is \"Overload(def (), def (x: builtins.int))\"", "hint": null, "code": "misc", "severity": "note"}
43+
{"file": "main", "line": 14, "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", "severity": "error"}
44+
{"file": "main", "line": 17, "column": 0, "message": "Too many arguments for \"bar\"", "hint": null, "code": "call-arg", "severity": "error"}

0 commit comments

Comments
 (0)