Skip to content

Commit 178603c

Browse files
authored
Add support for nicer error output (#7425)
Fixes #7368 Fixes #7410 This adds some basic support for output styling on Mac and Linux. The formatter does some basic error message parsing because passing them forward in a structured form would be a bigger change.
1 parent 17fbde1 commit 178603c

File tree

9 files changed

+211
-3
lines changed

9 files changed

+211
-3
lines changed

mypy/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,15 @@ def main(script_path: Optional[str],
6363
fscache=fscache)
6464

6565
messages = []
66+
formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes)
6667

6768
def flush_errors(new_messages: List[str], serious: bool) -> None:
6869
messages.extend(new_messages)
6970
f = stderr if serious else stdout
7071
try:
7172
for msg in new_messages:
73+
if options.color_output:
74+
msg = formatter.colorize(msg)
7275
f.write(msg + '\n')
7376
f.flush()
7477
except BrokenPipeError:
@@ -105,6 +108,14 @@ def flush_errors(new_messages: List[str], serious: bool) -> None:
105108
code = 0
106109
if messages:
107110
code = 2 if blockers else 1
111+
if options.error_summary:
112+
if messages:
113+
n_errors, n_files = util.count_stats(messages)
114+
if n_errors:
115+
stdout.write(formatter.format_error(n_errors, n_files, len(sources)) + '\n')
116+
else:
117+
stdout.write(formatter.format_success(len(sources)) + '\n')
118+
stdout.flush()
108119
if options.fast_exit:
109120
# Exit without freeing objects -- it's faster.
110121
#
@@ -568,6 +579,12 @@ def add_invertible_flag(flag: str,
568579
add_invertible_flag('--show-error-codes', default=False,
569580
help="Show error codes in error messages",
570581
group=error_group)
582+
add_invertible_flag('--no-color-output', dest='color_output', default=True,
583+
help="Do not colorize error messages",
584+
group=error_group)
585+
add_invertible_flag('--no-error-summary', dest='error_summary', default=True,
586+
help="Do not show error stats summary",
587+
group=error_group)
571588

572589
strict_help = "Strict mode; enables the following flags: {}".format(
573590
", ".join(strict_flag_names))

mypy/options.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ def __init__(self) -> None:
138138
# Show "note: In function "foo":" messages.
139139
self.show_error_context = False
140140

141+
# Use nicer output (when possible).
142+
self.color_output = True
143+
self.error_summary = True
144+
141145
# Files in which to allow strict-Optional related errors
142146
# TODO: Kill this in favor of show_none_errors
143147
self.strict_optional_whitelist = None # type: Optional[List[str]]

mypy/test/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ def parse_options(program_text: str, testcase: DataDrivenTestCase,
387387
options = Options()
388388
# TODO: Enable strict optional in test cases by default (requires *many* test case changes)
389389
options.strict_optional = False
390+
options.error_summary = False
390391

391392
# Allow custom python version to override testcase_pyversion
392393
if (not flag_list or

mypy/test/testcmdline.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
4747
args = parse_args(testcase.input[0])
4848
args.append('--show-traceback')
4949
args.append('--no-site-packages')
50+
if '--error-summary' not in args:
51+
args.append('--no-error-summary')
5052
# Type check the program.
5153
fixed = [python3_path, '-m', 'mypy']
5254
env = os.environ.copy()

mypy/test/testpep561.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def check_mypy_run(self,
114114
old_dir = os.getcwd()
115115
os.chdir(venv_dir)
116116
try:
117+
cmd_line.append('--no-error-summary')
117118
if python_executable != sys.executable:
118119
cmd_line.append('--python-executable={}'.format(python_executable))
119120
out, err, returncode = mypy.api.run(cmd_line)

mypy/test/testpythoneval.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def test_python_evaluation(testcase: DataDrivenTestCase, cache_dir: str) -> None
5757
'--no-site-packages',
5858
'--no-strict-optional',
5959
'--no-silence-site-packages',
60+
'--no-error-summary',
6061
]
6162
py2 = testcase.name.lower().endswith('python2')
6263
if py2:

mypy/util.py

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
import subprocess
66
import sys
77

8-
from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container
9-
from typing_extensions import Final, Type
8+
from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO
9+
from typing_extensions import Final, Type, Literal
10+
11+
try:
12+
import curses
13+
import _curses # noqa
14+
CURSES_ENABLED = True
15+
except ImportError:
16+
CURSES_ENABLED = False
1017

1118
T = TypeVar('T')
1219

@@ -313,3 +320,121 @@ def check_python_version(program: str) -> None:
313320
if sys.version_info[:3] == (3, 5, 0):
314321
sys.exit("Running {name} with Python 3.5.0 is not supported; "
315322
"please upgrade to 3.5.1 or newer".format(name=program))
323+
324+
325+
def count_stats(errors: List[str]) -> Tuple[int, int]:
326+
"""Count total number of errors and files in error list."""
327+
errors = [e for e in errors if ': error:' in e]
328+
files = {e.split(':')[0] for e in errors}
329+
return len(errors), len(files)
330+
331+
332+
class FancyFormatter:
333+
"""Apply color and bold font to terminal output.
334+
335+
This currently only works on Linux and Mac.
336+
"""
337+
def __init__(self, f_out: IO[str], f_err: IO[str],
338+
show_error_codes: bool) -> None:
339+
self.show_error_codes = show_error_codes
340+
# Check if we are in a human-facing terminal on a supported platform.
341+
if sys.platform not in ('linux', 'darwin'):
342+
self.dummy_term = True
343+
return
344+
force_color = int(os.getenv('MYPY_FORCE_COLOR', '0'))
345+
if not force_color and (not f_out.isatty() or not f_err.isatty()):
346+
self.dummy_term = True
347+
return
348+
349+
# We in a human-facing terminal, check if it supports enough styling.
350+
if not CURSES_ENABLED:
351+
self.dummy_term = True
352+
return
353+
try:
354+
curses.setupterm()
355+
except curses.error:
356+
# Most likely terminfo not found.
357+
self.dummy_term = True
358+
return
359+
bold = curses.tigetstr('bold')
360+
under = curses.tigetstr('smul')
361+
set_color = curses.tigetstr('setaf')
362+
self.dummy_term = not (bold and under and set_color)
363+
if self.dummy_term:
364+
return
365+
366+
self.BOLD = bold.decode()
367+
self.UNDER = under.decode()
368+
self.BLUE = curses.tparm(set_color, curses.COLOR_BLUE).decode()
369+
self.GREEN = curses.tparm(set_color, curses.COLOR_GREEN).decode()
370+
self.RED = curses.tparm(set_color, curses.COLOR_RED).decode()
371+
self.YELLOW = curses.tparm(set_color, curses.COLOR_YELLOW).decode()
372+
self.NORMAL = curses.tigetstr('sgr0').decode()
373+
self.colors = {'red': self.RED, 'green': self.GREEN,
374+
'blue': self.BLUE, 'yellow': self.YELLOW,
375+
'none': ''}
376+
377+
def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'none'],
378+
bold: bool = False, underline: bool = False) -> str:
379+
if self.dummy_term:
380+
return text
381+
if bold:
382+
start = self.BOLD
383+
else:
384+
start = ''
385+
if underline:
386+
start += self.UNDER
387+
return start + self.colors[color] + text + self.NORMAL
388+
389+
def colorize(self, error: str) -> str:
390+
"""Colorize an output line by highlighting the status and error code."""
391+
if ': error:' in error:
392+
loc, msg = error.split('error:', maxsplit=1)
393+
if not self.show_error_codes:
394+
return (loc + self.style('error:', 'red', bold=True) +
395+
self.highlight_quote_groups(msg))
396+
codepos = msg.rfind('[')
397+
code = msg[codepos:]
398+
msg = msg[:codepos]
399+
return (loc + self.style('error:', 'red', bold=True) +
400+
self.highlight_quote_groups(msg) + self.style(code, 'yellow'))
401+
elif ': note:' in error:
402+
loc, msg = error.split('note:', maxsplit=1)
403+
return loc + self.style('note:', 'blue') + self.underline_link(msg)
404+
else:
405+
return error
406+
407+
def highlight_quote_groups(self, msg: str) -> str:
408+
if msg.count('"') % 2:
409+
# Broken error message, don't do any formatting.
410+
return msg
411+
parts = msg.split('"')
412+
out = ''
413+
for i, part in enumerate(parts):
414+
if i % 2 == 0:
415+
out += self.style(part, 'none')
416+
else:
417+
out += self.style('"' + part + '"', 'none', bold=True)
418+
return out
419+
420+
def underline_link(self, note: str) -> str:
421+
match = re.search(r'https?://\S*', note)
422+
if not match:
423+
return note
424+
start = match.start()
425+
end = match.end()
426+
return (note[:start] +
427+
self.style(note[start:end], 'none', underline=True) +
428+
note[end:])
429+
430+
def format_success(self, n_sources: int) -> str:
431+
return self.style('Success: no issues found in {}'
432+
' source file{}'.format(n_sources, 's' if n_sources != 1 else ''),
433+
'green', bold=True)
434+
435+
def format_error(self, n_errors: int, n_files: int, n_sources: int) -> str:
436+
msg = 'Found {} error{} in {} file{}' \
437+
' (checked {} source file{})'.format(n_errors, 's' if n_errors != 1 else '',
438+
n_files, 's' if n_files != 1 else '',
439+
n_sources, 's' if n_sources != 1 else '')
440+
return self.style(msg, 'red', bold=True)

test-data/unit/cmdline.test

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1322,7 +1322,6 @@ fail
13221322
a/b.py:1: error: Name 'fail' is not defined
13231323
c.py:1: error: Name 'fail' is not defined
13241324

1325-
13261325
[case testIniFilesCmdlineOverridesConfig]
13271326
# cmd: mypy override.py
13281327
[file mypy.ini]
@@ -1331,3 +1330,60 @@ files = config.py
13311330
[out]
13321331
mypy: can't read file 'override.py': No such file or directory
13331332
== Return code: 2
1333+
1334+
[case testErrorSummaryOnSuccess]
1335+
# cmd: mypy --error-summary good.py
1336+
[file good.py]
1337+
x = 2 + 2
1338+
[out]
1339+
Success: no issues found in 1 source file
1340+
== Return code: 0
1341+
1342+
[case testErrorSummaryOnFail]
1343+
# cmd: mypy --error-summary bad.py
1344+
[file bad.py]
1345+
42 + 'no'
1346+
[out]
1347+
bad.py:1: error: Unsupported operand types for + ("int" and "str")
1348+
Found 1 error in 1 file (checked 1 source file)
1349+
1350+
[case testErrorSummaryOnFailNotes]
1351+
# cmd: mypy --error-summary bad.py
1352+
[file bad.py]
1353+
from typing import List
1354+
x = [] # type: List[float]
1355+
y = [] # type: List[int]
1356+
x = y
1357+
[out]
1358+
bad.py:4: error: Incompatible types in assignment (expression has type "List[int]", variable has type "List[float]")
1359+
bad.py:4: note: "List" is invariant -- see http://mypy.readthedocs.io/en/latest/common_issues.html#variance
1360+
bad.py:4: note: Consider using "Sequence" instead, which is covariant
1361+
Found 1 error in 1 file (checked 1 source file)
1362+
1363+
[case testErrorSummaryOnFailTwoErrors]
1364+
# cmd: mypy --error-summary bad.py foo.py
1365+
[file bad.py]
1366+
42 + 'no'
1367+
42 + 'no'
1368+
[file foo.py]
1369+
[out]
1370+
bad.py:1: error: Unsupported operand types for + ("int" and "str")
1371+
bad.py:2: error: Unsupported operand types for + ("int" and "str")
1372+
Found 2 errors in 1 file (checked 2 source files)
1373+
1374+
[case testErrorSummaryOnFailTwoFiles]
1375+
# cmd: mypy --error-summary bad.py bad2.py
1376+
[file bad.py]
1377+
42 + 'no'
1378+
[file bad2.py]
1379+
42 + 'no'
1380+
[out]
1381+
bad2.py:1: error: Unsupported operand types for + ("int" and "str")
1382+
bad.py:1: error: Unsupported operand types for + ("int" and "str")
1383+
Found 2 errors in 2 files (checked 2 source files)
1384+
1385+
[case testErrorSummaryOnBadUsage]
1386+
# cmd: mypy --error-summary missing.py
1387+
[out]
1388+
mypy: can't read file 'missing.py': No such file or directory
1389+
== Return code: 2

test-data/unit/daemon.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ Daemon stopped
146146
$ {python} -c "print('x=1')" >foo.py
147147
$ {python} -c "print('x=1')" >bar.py
148148
$ mypy --local-partial-types --cache-fine-grained --follow-imports=error --no-sqlite-cache --python-version=3.6 -- foo.py bar.py
149+
Success: no issues found in 2 source files
149150
$ {python} -c "import shutil; shutil.copy('.mypy_cache/3.6/bar.meta.json', 'asdf.json')"
150151
-- update bar's timestamp but don't change the file
151152
$ {python} -c "import time;time.sleep(1)"

0 commit comments

Comments
 (0)