diff --git a/mypy/main.py b/mypy/main.py index d7ebb7921fff..bcc165a8bc4c 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -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: @@ -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. # @@ -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)) diff --git a/mypy/options.py b/mypy/options.py index d751597ba93d..be95c29440db 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -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]] diff --git a/mypy/test/helpers.py b/mypy/test/helpers.py index dc0e4beea84b..2a4b56d2230c 100644 --- a/mypy/test/helpers.py +++ b/mypy/test/helpers.py @@ -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 diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py index 34981ee6cba6..92129d3a78c8 100644 --- a/mypy/test/testcmdline.py +++ b/mypy/test/testcmdline.py @@ -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() diff --git a/mypy/test/testpep561.py b/mypy/test/testpep561.py index 77d38db00f1b..f1976b295774 100644 --- a/mypy/test/testpep561.py +++ b/mypy/test/testpep561.py @@ -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) diff --git a/mypy/test/testpythoneval.py b/mypy/test/testpythoneval.py index 153eb4f78c7b..b64fd2a28c61 100644 --- a/mypy/test/testpythoneval.py +++ b/mypy/test/testpythoneval.py @@ -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: diff --git a/mypy/util.py b/mypy/util.py index af8d1ef68e7b..6fb297c94262 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -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 + import _curses # noqa + CURSES_ENABLED = True +except ImportError: + CURSES_ENABLED = False T = TypeVar('T') @@ -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) diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index ce50419d58b4..8a75579ff119 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -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] @@ -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 diff --git a/test-data/unit/daemon.test b/test-data/unit/daemon.test index df0d4a0e8c0b..84db6b9cf4cc 100644 --- a/test-data/unit/daemon.test +++ b/test-data/unit/daemon.test @@ -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)"