Skip to content

Add support for nicer error output #7425

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

Merged
merged 6 commits into from
Aug 31, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions mypy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,15 @@ def main(script_path: Optional[str],
fscache=fscache)

messages = []
formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes)

def flush_errors(new_messages: List[str], serious: bool) -> None:
messages.extend(new_messages)
f = stderr if serious else stdout
try:
for msg in new_messages:
if options.color_output:
msg = formatter.colorize(msg)
f.write(msg + '\n')
f.flush()
except BrokenPipeError:
Expand Down Expand Up @@ -105,6 +108,14 @@ def flush_errors(new_messages: List[str], serious: bool) -> None:
code = 0
if messages:
code = 2 if blockers else 1
if options.error_summary:
if messages:
n_errors, n_files = util.count_stats(messages)
if n_errors:
stdout.write(formatter.format_error(n_errors, n_files, len(sources)) + '\n')
else:
stdout.write(formatter.format_success(len(sources)) + '\n')
stdout.flush()
if options.fast_exit:
# Exit without freeing objects -- it's faster.
#
Expand Down Expand Up @@ -568,6 +579,12 @@ def add_invertible_flag(flag: str,
add_invertible_flag('--show-error-codes', default=False,
help="Show error codes in error messages",
group=error_group)
add_invertible_flag('--no-color-output', dest='color_output', default=True,
help="Do not colorize error messages",
group=error_group)
add_invertible_flag('--no-error-summary', dest='error_summary', default=True,
help="Do not show error stats summary",
group=error_group)

strict_help = "Strict mode; enables the following flags: {}".format(
", ".join(strict_flag_names))
Expand Down
4 changes: 4 additions & 0 deletions mypy/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ def __init__(self) -> None:
# Show "note: In function "foo":" messages.
self.show_error_context = False

# Use nicer output (when possible).
self.color_output = True
self.error_summary = True

# Files in which to allow strict-Optional related errors
# TODO: Kill this in favor of show_none_errors
self.strict_optional_whitelist = None # type: Optional[List[str]]
Expand Down
1 change: 1 addition & 0 deletions mypy/test/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ def parse_options(program_text: str, testcase: DataDrivenTestCase,
options = Options()
# TODO: Enable strict optional in test cases by default (requires *many* test case changes)
options.strict_optional = False
options.error_summary = False

# Allow custom python version to override testcase_pyversion
if (not flag_list or
Expand Down
2 changes: 2 additions & 0 deletions mypy/test/testcmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
args = parse_args(testcase.input[0])
args.append('--show-traceback')
args.append('--no-site-packages')
if '--error-summary' not in args:
args.append('--no-error-summary')
# Type check the program.
fixed = [python3_path, '-m', 'mypy']
env = os.environ.copy()
Expand Down
1 change: 1 addition & 0 deletions mypy/test/testpep561.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def check_mypy_run(self,
old_dir = os.getcwd()
os.chdir(venv_dir)
try:
cmd_line.append('--no-error-summary')
if python_executable != sys.executable:
cmd_line.append('--python-executable={}'.format(python_executable))
out, err, returncode = mypy.api.run(cmd_line)
Expand Down
1 change: 1 addition & 0 deletions mypy/test/testpythoneval.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def test_python_evaluation(testcase: DataDrivenTestCase, cache_dir: str) -> None
'--no-site-packages',
'--no-strict-optional',
'--no-silence-site-packages',
'--no-error-summary',
]
py2 = testcase.name.lower().endswith('python2')
if py2:
Expand Down
129 changes: 127 additions & 2 deletions mypy/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@
import subprocess
import sys

from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container
from typing_extensions import Final, Type
from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO
from typing_extensions import Final, Type, Literal

try:
import curses
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using curses which doesn't support Windows well, perhaps we could use colorama instead? If you don't want to change things in this PR I can open one to redo this in colorama if you want (maybe after the daemon changes).

import _curses # noqa
CURSES_ENABLED = True
except ImportError:
CURSES_ENABLED = False

T = TypeVar('T')

Expand Down Expand Up @@ -313,3 +320,121 @@ def check_python_version(program: str) -> None:
if sys.version_info[:3] == (3, 5, 0):
sys.exit("Running {name} with Python 3.5.0 is not supported; "
"please upgrade to 3.5.1 or newer".format(name=program))


def count_stats(errors: List[str]) -> Tuple[int, int]:
"""Count total number of errors and files in error list."""
errors = [e for e in errors if ': error:' in e]
files = {e.split(':')[0] for e in errors}
return len(errors), len(files)


class FancyFormatter:
"""Apply color and bold font to terminal output.

This currently only works on Linux and Mac.
"""
def __init__(self, f_out: IO[str], f_err: IO[str],
show_error_codes: bool) -> None:
self.show_error_codes = show_error_codes
# Check if we are in a human-facing terminal on a supported platform.
if sys.platform not in ('linux', 'darwin'):
self.dummy_term = True
return
force_color = int(os.getenv('MYPY_FORCE_COLOR', '0'))
if not force_color and (not f_out.isatty() or not f_err.isatty()):
self.dummy_term = True
return

# We in a human-facing terminal, check if it supports enough styling.
if not CURSES_ENABLED:
self.dummy_term = True
return
try:
curses.setupterm()
except curses.error:
# Most likely terminfo not found.
self.dummy_term = True
return
bold = curses.tigetstr('bold')
under = curses.tigetstr('smul')
set_color = curses.tigetstr('setaf')
self.dummy_term = not (bold and under and set_color)
if self.dummy_term:
return

self.BOLD = bold.decode()
self.UNDER = under.decode()
self.BLUE = curses.tparm(set_color, curses.COLOR_BLUE).decode()
self.GREEN = curses.tparm(set_color, curses.COLOR_GREEN).decode()
self.RED = curses.tparm(set_color, curses.COLOR_RED).decode()
self.YELLOW = curses.tparm(set_color, curses.COLOR_YELLOW).decode()
self.NORMAL = curses.tigetstr('sgr0').decode()
self.colors = {'red': self.RED, 'green': self.GREEN,
'blue': self.BLUE, 'yellow': self.YELLOW,
'none': ''}

def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'none'],
bold: bool = False, underline: bool = False) -> str:
if self.dummy_term:
return text
if bold:
start = self.BOLD
else:
start = ''
if underline:
start += self.UNDER
return start + self.colors[color] + text + self.NORMAL

def colorize(self, error: str) -> str:
"""Colorize an output line by highlighting the status and error code."""
if ': error:' in error:
loc, msg = error.split('error:', maxsplit=1)
if not self.show_error_codes:
return (loc + self.style('error:', 'red', bold=True) +
self.highlight_quote_groups(msg))
codepos = msg.rfind('[')
code = msg[codepos:]
msg = msg[:codepos]
return (loc + self.style('error:', 'red', bold=True) +
self.highlight_quote_groups(msg) + self.style(code, 'yellow'))
elif ': note:' in error:
loc, msg = error.split('note:', maxsplit=1)
return loc + self.style('note:', 'blue') + self.underline_link(msg)
else:
return error

def highlight_quote_groups(self, msg: str) -> str:
if msg.count('"') % 2:
# Broken error message, don't do any formatting.
return msg
parts = msg.split('"')
out = ''
for i, part in enumerate(parts):
if i % 2 == 0:
out += self.style(part, 'none')
else:
out += self.style('"' + part + '"', 'none', bold=True)
return out

def underline_link(self, note: str) -> str:
match = re.search(r'https?://\S*', note)
if not match:
return note
start = match.start()
end = match.end()
return (note[:start] +
self.style(note[start:end], 'none', underline=True) +
note[end:])

def format_success(self, n_sources: int) -> str:
return self.style('Success: no issues found in {}'
' source file{}'.format(n_sources, 's' if n_sources != 1 else ''),
'green', bold=True)

def format_error(self, n_errors: int, n_files: int, n_sources: int) -> str:
msg = 'Found {} error{} in {} file{}' \
' (checked {} source file{})'.format(n_errors, 's' if n_errors != 1 else '',
n_files, 's' if n_files != 1 else '',
n_sources, 's' if n_sources != 1 else '')
return self.style(msg, 'red', bold=True)
58 changes: 57 additions & 1 deletion test-data/unit/cmdline.test
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,6 @@ fail
a/b.py:1: error: Name 'fail' is not defined
c.py:1: error: Name 'fail' is not defined


[case testIniFilesCmdlineOverridesConfig]
# cmd: mypy override.py
[file mypy.ini]
Expand All @@ -1331,3 +1330,60 @@ files = config.py
[out]
mypy: can't read file 'override.py': No such file or directory
== Return code: 2

[case testErrorSummaryOnSuccess]
# cmd: mypy --error-summary good.py
[file good.py]
x = 2 + 2
[out]
Success: no issues found in 1 source file
== Return code: 0

[case testErrorSummaryOnFail]
# cmd: mypy --error-summary bad.py
[file bad.py]
42 + 'no'
[out]
bad.py:1: error: Unsupported operand types for + ("int" and "str")
Found 1 error in 1 file (checked 1 source file)

[case testErrorSummaryOnFailNotes]
# cmd: mypy --error-summary bad.py
[file bad.py]
from typing import List
x = [] # type: List[float]
y = [] # type: List[int]
x = y
[out]
bad.py:4: error: Incompatible types in assignment (expression has type "List[int]", variable has type "List[float]")
bad.py:4: note: "List" is invariant -- see http://mypy.readthedocs.io/en/latest/common_issues.html#variance
bad.py:4: note: Consider using "Sequence" instead, which is covariant
Found 1 error in 1 file (checked 1 source file)

[case testErrorSummaryOnFailTwoErrors]
# cmd: mypy --error-summary bad.py foo.py
[file bad.py]
42 + 'no'
42 + 'no'
[file foo.py]
[out]
bad.py:1: error: Unsupported operand types for + ("int" and "str")
bad.py:2: error: Unsupported operand types for + ("int" and "str")
Found 2 errors in 1 file (checked 2 source files)

[case testErrorSummaryOnFailTwoFiles]
# cmd: mypy --error-summary bad.py bad2.py
[file bad.py]
42 + 'no'
[file bad2.py]
42 + 'no'
[out]
bad2.py:1: error: Unsupported operand types for + ("int" and "str")
bad.py:1: error: Unsupported operand types for + ("int" and "str")
Found 2 errors in 2 files (checked 2 source files)

[case testErrorSummaryOnBadUsage]
# cmd: mypy --error-summary missing.py
[out]
mypy: can't read file 'missing.py': No such file or directory
== Return code: 2
1 change: 1 addition & 0 deletions test-data/unit/daemon.test
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Daemon stopped
$ {python} -c "print('x=1')" >foo.py
$ {python} -c "print('x=1')" >bar.py
$ mypy --local-partial-types --cache-fine-grained --follow-imports=error --no-sqlite-cache --python-version=3.6 -- foo.py bar.py
Success: no issues found in 2 source files
$ {python} -c "import shutil; shutil.copy('.mypy_cache/3.6/bar.meta.json', 'asdf.json')"
-- update bar's timestamp but don't change the file
$ {python} -c "import time;time.sleep(1)"
Expand Down