diff --git a/mypy/build.py b/mypy/build.py index 8b1f72cb594d..cfca57f0477e 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -36,7 +36,8 @@ from mypy.indirection import TypeIndirectionVisitor from mypy.errors import Errors, CompileError, ErrorInfo, report_internal_error from mypy.util import ( - DecodeError, decode_python_encoding, is_sub_path, get_mypy_comments, module_prefix + DecodeError, decode_python_encoding, is_sub_path, get_mypy_comments, module_prefix, + read_py_file ) if TYPE_CHECKING: from mypy.report import Reports # Avoid unconditional slow import @@ -197,9 +198,12 @@ def _build(sources: List[BuildSource], reports = Reports(data_dir, options.report_dirs) source_set = BuildSourceSet(sources) + cached_read = fscache.read errors = Errors(options.show_error_context, options.show_column_numbers, - options.show_error_codes) + options.show_error_codes, + options.pretty, + lambda path: read_py_file(path, cached_read, options.python_version)) plugin, snapshot = load_plugins(options, errors, stdout) # Construct a build manager object to hold state during the build. diff --git a/mypy/dmypy/client.py b/mypy/dmypy/client.py index 3dc73ecdf22b..ce69c90ad2fd 100644 --- a/mypy/dmypy/client.py +++ b/mypy/dmypy/client.py @@ -18,7 +18,7 @@ from mypy.dmypy_util import DEFAULT_STATUS_FILE, receive from mypy.ipc import IPCClient, IPCException from mypy.dmypy_os import alive, kill -from mypy.util import check_python_version +from mypy.util import check_python_version, get_terminal_width from mypy.version import __version__ @@ -469,6 +469,7 @@ def request(status_file: str, command: str, *, timeout: Optional[int] = None, # Tell the server whether this request was initiated from a human-facing terminal, # so that it can format the type checking output accordingly. args['is_tty'] = sys.stdout.isatty() + args['terminal_width'] = get_terminal_width() bdata = json.dumps(args).encode('utf8') _, name = get_status(status_file) try: diff --git a/mypy/dmypy_server.py b/mypy/dmypy_server.py index 9349ddc781a8..3a3f43ec137e 100644 --- a/mypy/dmypy_server.py +++ b/mypy/dmypy_server.py @@ -263,6 +263,7 @@ def run_command(self, command: str, data: Dict[str, object]) -> Dict[str, object if command not in {'check', 'recheck', 'run'}: # Only the above commands use some error formatting. del data['is_tty'] + del data['terminal_width'] elif int(os.getenv('MYPY_FORCE_COLOR', '0')): data['is_tty'] = True return method(self, **data) @@ -290,7 +291,8 @@ def cmd_stop(self) -> Dict[str, object]: os.unlink(self.status_file) return {} - def cmd_run(self, version: str, args: Sequence[str], is_tty: bool) -> Dict[str, object]: + def cmd_run(self, version: str, args: Sequence[str], + is_tty: bool, terminal_width: int) -> Dict[str, object]: """Check a list of files, triggering a restart if needed.""" try: # Process options can exit on improper arguments, so we need to catch that and @@ -323,18 +325,20 @@ def cmd_run(self, version: str, args: Sequence[str], is_tty: bool) -> Dict[str, return {'out': '', 'err': str(err), 'status': 2} except SystemExit as e: return {'out': stdout.getvalue(), 'err': stderr.getvalue(), 'status': e.code} - return self.check(sources, is_tty) + return self.check(sources, is_tty, terminal_width) - def cmd_check(self, files: Sequence[str], is_tty: bool) -> Dict[str, object]: + def cmd_check(self, files: Sequence[str], + is_tty: bool, terminal_width: int) -> Dict[str, object]: """Check a list of files.""" try: sources = create_source_list(files, self.options, self.fscache) except InvalidSourceList as err: return {'out': '', 'err': str(err), 'status': 2} - return self.check(sources, is_tty) + return self.check(sources, is_tty, terminal_width) def cmd_recheck(self, is_tty: bool, + terminal_width: int, remove: Optional[List[str]] = None, update: Optional[List[str]] = None) -> Dict[str, object]: """Check the same list of files we checked most recently. @@ -360,21 +364,23 @@ def cmd_recheck(self, t1 = time.time() manager = self.fine_grained_manager.manager manager.log("fine-grained increment: cmd_recheck: {:.3f}s".format(t1 - t0)) - res = self.fine_grained_increment(sources, is_tty, remove, update) + res = self.fine_grained_increment(sources, is_tty, terminal_width, + remove, update) self.fscache.flush() self.update_stats(res) return res - def check(self, sources: List[BuildSource], is_tty: bool) -> Dict[str, Any]: + def check(self, sources: List[BuildSource], + is_tty: bool, terminal_width: int) -> Dict[str, Any]: """Check using fine-grained incremental mode. If is_tty is True format the output nicely with colors and summary line - (unless disabled in self.options). + (unless disabled in self.options). Also pass the terminal_width to formatter. """ if not self.fine_grained_manager: - res = self.initialize_fine_grained(sources, is_tty) + res = self.initialize_fine_grained(sources, is_tty, terminal_width) else: - res = self.fine_grained_increment(sources, is_tty) + res = self.fine_grained_increment(sources, is_tty, terminal_width) self.fscache.flush() self.update_stats(res) return res @@ -387,7 +393,7 @@ def update_stats(self, res: Dict[str, Any]) -> None: manager.stats = {} def initialize_fine_grained(self, sources: List[BuildSource], - is_tty: bool) -> Dict[str, Any]: + is_tty: bool, terminal_width: int) -> Dict[str, Any]: self.fswatcher = FileSystemWatcher(self.fscache) t0 = time.time() self.update_sources(sources) @@ -449,12 +455,13 @@ def initialize_fine_grained(self, sources: List[BuildSource], print_memory_profile(run_gc=False) status = 1 if messages else 0 - messages = self.pretty_messages(messages, len(sources), is_tty) + messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width) return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status} def fine_grained_increment(self, sources: List[BuildSource], is_tty: bool, + terminal_width: int, remove: Optional[List[str]] = None, update: Optional[List[str]] = None, ) -> Dict[str, Any]: @@ -484,12 +491,16 @@ def fine_grained_increment(self, status = 1 if messages else 0 self.previous_sources = sources - messages = self.pretty_messages(messages, len(sources), is_tty) + messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width) return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status} def pretty_messages(self, messages: List[str], n_sources: int, - is_tty: bool = False) -> List[str]: + is_tty: bool = False, terminal_width: Optional[int] = None) -> List[str]: use_color = self.options.color_output and is_tty + fit_width = self.options.pretty and is_tty + if fit_width: + messages = self.formatter.fit_in_terminal(messages, + fixed_terminal_width=terminal_width) if self.options.error_summary: summary = None # type: Optional[str] if messages: diff --git a/mypy/errors.py b/mypy/errors.py index 780c11a2458b..77cf3fabbc42 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -3,7 +3,7 @@ import traceback from collections import OrderedDict, defaultdict -from typing import Tuple, List, TypeVar, Set, Dict, Optional, TextIO +from typing import Tuple, List, TypeVar, Set, Dict, Optional, TextIO, Callable from typing_extensions import Final from mypy.scope import Scope @@ -11,6 +11,7 @@ from mypy.version import __version__ as mypy_version from mypy.errorcodes import ErrorCode from mypy import errorcodes as codes +from mypy.util import DEFAULT_SOURCE_OFFSET T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -156,10 +157,15 @@ class Errors: def __init__(self, show_error_context: bool = False, show_column_numbers: bool = False, - show_error_codes: bool = False) -> None: + show_error_codes: bool = False, + pretty: bool = False, + read_source: Optional[Callable[[str], Optional[List[str]]]] = None) -> None: self.show_error_context = show_error_context self.show_column_numbers = show_column_numbers self.show_error_codes = show_error_codes + self.pretty = pretty + # We use fscache to read source code when showing snippets. + self.read_source = read_source self.initialize() def initialize(self) -> None: @@ -179,7 +185,11 @@ def reset(self) -> None: self.initialize() def copy(self) -> 'Errors': - new = Errors(self.show_error_context, self.show_column_numbers) + new = Errors(self.show_error_context, + self.show_column_numbers, + self.show_error_codes, + self.pretty, + self.read_source) new.file = self.file new.import_ctx = self.import_ctx[:] new.type_name = self.type_name[:] @@ -402,10 +412,13 @@ def raise_error(self) -> None: use_stdout=True, module_with_blocker=self.blocker_module()) - def format_messages(self, error_info: List[ErrorInfo]) -> List[str]: + def format_messages(self, error_info: List[ErrorInfo], + source_lines: Optional[List[str]]) -> List[str]: """Return a string list that represents the error messages. - Use a form suitable for displaying to the user. + Use a form suitable for displaying to the user. If self.pretty + is True also append a relevant trimmed source code line (only for + severity 'error'). """ a = [] # type: List[str] errors = self.render_messages(self.sort_messages(error_info)) @@ -427,6 +440,17 @@ def format_messages(self, error_info: List[ErrorInfo]) -> List[str]: # displaying duplicate error codes. s = '{} [{}]'.format(s, code.code) a.append(s) + if self.pretty: + # Add source code fragment and a location marker. + if severity == 'error' and source_lines and line > 0: + source_line = source_lines[line - 1] + if column < 0: + # Something went wrong, take first non-empty column. + column = len(source_line) - len(source_line.lstrip()) + # Note, currently coloring uses the offset to detect source snippets, + # so these offsets should not be arbitrary. + a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) + a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + '^') return a def file_messages(self, path: str) -> List[str]: @@ -437,7 +461,11 @@ def file_messages(self, path: str) -> List[str]: if path not in self.error_info_map: return [] self.flushed_files.add(path) - return self.format_messages(self.error_info_map[path]) + source_lines = None + if self.pretty: + assert self.read_source + source_lines = self.read_source(path) + return self.format_messages(self.error_info_map[path], source_lines) def new_messages(self) -> List[str]: """Return a string list of new error messages. diff --git a/mypy/main.py b/mypy/main.py index 46f91f61b4e8..2dd2a064594f 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -66,6 +66,8 @@ def main(script_path: Optional[str], formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes) def flush_errors(new_messages: List[str], serious: bool) -> None: + if options.pretty: + new_messages = formatter.fit_in_terminal(new_messages) messages.extend(new_messages) f = stderr if serious else stdout try: @@ -582,6 +584,11 @@ 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('--pretty', default=False, + help="Use visually nicer output in error messages:" + " Use soft word wrap, show source code snippets," + " and error location markers", + group=error_group) add_invertible_flag('--no-color-output', dest='color_output', default=True, help="Do not colorize error messages", group=error_group) diff --git a/mypy/options.py b/mypy/options.py index be95c29440db..d3163e9419e1 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -244,6 +244,8 @@ def __init__(self) -> None: self.shadow_file = None # type: Optional[List[List[str]]] self.show_column_numbers = False # type: bool self.show_error_codes = False + # Use soft word wrap and show trimmed source snippets with error location markers. + self.pretty = False self.dump_graph = False self.dump_deps = False self.logical_deps = False diff --git a/mypy/test/testfinegrained.py b/mypy/test/testfinegrained.py index 464f2d837e5c..736cfe000bb0 100644 --- a/mypy/test/testfinegrained.py +++ b/mypy/test/testfinegrained.py @@ -197,7 +197,7 @@ def get_options(self, return options def run_check(self, server: Server, sources: List[BuildSource]) -> List[str]: - response = server.check(sources, is_tty=False) + response = server.check(sources, is_tty=False, terminal_width=-1) out = cast(str, response['out'] or response['err']) return out.splitlines() diff --git a/mypy/test/testformatter.py b/mypy/test/testformatter.py new file mode 100644 index 000000000000..623c7a62753f --- /dev/null +++ b/mypy/test/testformatter.py @@ -0,0 +1,51 @@ +from unittest import TestCase, main + +from mypy.util import trim_source_line, split_words + + +class FancyErrorFormattingTestCases(TestCase): + def test_trim_source(self) -> None: + assert trim_source_line('0123456789abcdef', + max_len=16, col=5, min_width=2) == ('0123456789abcdef', 0) + + # Locations near start. + assert trim_source_line('0123456789abcdef', + max_len=7, col=0, min_width=2) == ('0123456...', 0) + assert trim_source_line('0123456789abcdef', + max_len=7, col=4, min_width=2) == ('0123456...', 0) + + # Middle locations. + assert trim_source_line('0123456789abcdef', + max_len=7, col=5, min_width=2) == ('...1234567...', -2) + assert trim_source_line('0123456789abcdef', + max_len=7, col=6, min_width=2) == ('...2345678...', -1) + assert trim_source_line('0123456789abcdef', + max_len=7, col=8, min_width=2) == ('...456789a...', 1) + + # Locations near the end. + assert trim_source_line('0123456789abcdef', + max_len=7, col=11, min_width=2) == ('...789abcd...', 4) + assert trim_source_line('0123456789abcdef', + max_len=7, col=13, min_width=2) == ('...9abcdef', 6) + assert trim_source_line('0123456789abcdef', + max_len=7, col=15, min_width=2) == ('...9abcdef', 6) + + def test_split_words(self) -> None: + assert split_words('Simple message') == ['Simple', 'message'] + assert split_words('Message with "Some[Long, Types]"' + ' in it') == ['Message', 'with', + '"Some[Long, Types]"', 'in', 'it'] + assert split_words('Message with "Some[Long, Types]"' + ' and [error-code]') == ['Message', 'with', '"Some[Long, Types]"', + 'and', '[error-code]'] + assert split_words('"Type[Stands, First]" then words') == ['"Type[Stands, First]"', + 'then', 'words'] + assert split_words('First words "Then[Stands, Type]"') == ['First', 'words', + '"Then[Stands, Type]"'] + assert split_words('"Type[Only, Here]"') == ['"Type[Only, Here]"'] + assert split_words('OneWord') == ['OneWord'] + assert split_words(' ') == ['', ''] + + +if __name__ == '__main__': + main() diff --git a/mypy/util.py b/mypy/util.py index 7284d0639ca2..14306a39d369 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -4,8 +4,11 @@ import re import subprocess import sys +import os -from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO +from typing import ( + TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO, Callable +) from typing_extensions import Final, Type, Literal try: @@ -20,6 +23,21 @@ ENCODING_RE = \ re.compile(br'([ \t\v]*#.*(\r\n?|\n))??[ \t\v]*#.*coding[:=][ \t]*([-\w.]+)') # type: Final +# This works in most default terminals works (because it is ANSI standard). The problem +# this tries to solve is that although it is a basic ANSI "feature", terminfo files +# for most default terminals don't have dim termcap entry, so curses doesn't report it. +# Potentially, we can choose a grey color that would look good on both white and black +# background, but it is not easy, and again most default terminals are 8-color, not 256-color, +# so we can't get the color code from curses. +PLAIN_ANSI_DIM = '\x1b[2m' # type: Final + +DEFAULT_SOURCE_OFFSET = 4 # type: Final +DEFAULT_COLUMNS = 80 # type: Final + +# At least this number of columns will be shown on each side of +# error location when printing source code snippet. +MINIMUM_WIDTH = 20 + default_python2_interpreter = \ ['python2', 'python', '/usr/bin/python', 'C:\\Python27\\python.exe'] # type: Final @@ -92,8 +110,7 @@ class DecodeError(Exception): def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str: """Read the Python file with while obeying PEP-263 encoding detection. - Returns: - A tuple: the source as a string, and the hash calculated from the binary representation. + Returns the source as a string. """ # check for BOM UTF-8 encoding and strip it out if present if source.startswith(b'\xef\xbb\xbf'): @@ -110,6 +127,56 @@ def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str: return source_text +def read_py_file(path: str, read: Callable[[str], bytes], + pyversion: Tuple[int, int]) -> Optional[List[str]]: + """Try reading a Python file as list of source lines. + + Return None if something goes wrong. + """ + try: + source = read(path) + except (IOError, OSError): + return None + else: + try: + source_lines = decode_python_encoding(source, pyversion).splitlines() + except DecodeError: + return None + return source_lines + + +def trim_source_line(line: str, max_len: int, col: int, min_width: int) -> Tuple[str, int]: + """Trim a line of source code to fit into max_len. + + Show 'min_width' characters on each side of 'col' (an error location). If either + start or end is trimmed, this is indicated by adding '...' there. + A typical result looks like this: + ...some_variable = function_to_call(one_arg, other_arg) or... + + Return the trimmed string and the column offset to to adjust error location. + """ + if max_len < 2 * min_width + 1: + # In case the window is too tiny it is better to still show something. + max_len = 2 * min_width + 1 + + # Trivial case: line already fits in. + if len(line) <= max_len: + return line, 0 + + # If column is not too large so that there is still min_width after it, + # the line doesn't need to be trimmed at the start. + if col + min_width < max_len: + return line[:max_len] + '...', 0 + + # Otherwise, if the column is not too close to the end, trim both sides. + if col < len(line) - min_width - 1: + offset = col - max_len + min_width + 1 + return '...' + line[offset:col + min_width + 1] + '...', offset - 3 + + # Finally, if the column is near the end, just trim the start. + return '...' + line[-max_len:], len(line) - max_len - 3 + + def get_mypy_comments(source: str) -> List[Tuple[int, str]]: PREFIX = '# mypy: ' # Don't bother splitting up the lines unless we know it is useful @@ -329,13 +396,75 @@ def count_stats(errors: List[str]) -> Tuple[int, int]: return len(errors), len(files) +def split_words(msg: str) -> List[str]: + """Split line of text into words (but not within quoted groups).""" + next_word = '' + res = [] # type: List[str] + allow_break = True + for c in msg: + if c == ' ' and allow_break: + res.append(next_word) + next_word = '' + continue + if c == '"': + allow_break = not allow_break + next_word += c + res.append(next_word) + return res + + +def get_terminal_width() -> int: + """Get current terminal width if possible, otherwise return the default one.""" + try: + cols, _ = os.get_terminal_size() + return cols + except OSError: + return DEFAULT_COLUMNS + + +def soft_wrap(msg: str, max_len: int, first_offset: int, + num_indent: int = 0) -> str: + """Wrap a long error message into few lines. + + Breaks will only happen between words, and never inside a quoted group + (to avoid breaking types such as "Union[int, str]"). The 'first_offset' is + the width before the start of first line. + + Pad every next line with 'num_indent' spaces. Every line will be at most 'max_len' + characters, except if it is a single word or quoted group. + + For example: + first_offset + ------------------------ + path/to/file: error: 58: Some very long error message + that needs to be split in separate lines. + "Long[Type, Names]" are never split. + ^^^^-------------------------------------------------- + num_indent max_len + """ + words = split_words(msg) + next_line = words.pop(0) + lines = [] # type: List[str] + while words: + next_word = words.pop(0) + max_line_len = max_len - num_indent if lines else max_len - first_offset + # Add 1 to account for space between words. + if len(next_line) + len(next_word) + 1 <= max_line_len: + next_line += ' ' + next_word + else: + lines.append(next_line) + next_line = next_word + lines.append(next_line) + padding = '\n' + ' ' * num_indent + return padding.join(lines) + + 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: + 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'): @@ -363,19 +492,25 @@ def __init__(self, f_out: IO[str], f_err: IO[str], if self.dummy_term: return + self.NORMAL = curses.tigetstr('sgr0').decode() self.BOLD = bold.decode() self.UNDER = under.decode() + dim = curses.tigetstr('dim') + # TODO: more reliable way to get gray color good for both dark and light schemes. + self.DIM = dim.decode() if dim else PLAIN_ANSI_DIM + 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: + bold: bool = False, underline: bool = False, dim: bool = False) -> str: + """Apply simple color and style (underlined or bold).""" if self.dummy_term: return text if bold: @@ -384,10 +519,42 @@ def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'non start = '' if underline: start += self.UNDER + if dim: + start += self.DIM return start + self.colors[color] + text + self.NORMAL + def fit_in_terminal(self, messages: List[str], + fixed_terminal_width: Optional[int] = None) -> List[str]: + """Improve readability by wrapping error messages and trimming source code.""" + width = fixed_terminal_width or get_terminal_width() + new_messages = messages.copy() + for i, error in enumerate(messages): + if ': error:' in error: + loc, msg = error.split('error:', maxsplit=1) + msg = soft_wrap(msg, width, first_offset=len(loc) + len('error: ')) + new_messages[i] = loc + 'error:' + msg + if error.startswith(' ' * DEFAULT_SOURCE_OFFSET) and '^' not in error: + # TODO: detecting source code highlights through an indent can be surprising. + # Restore original error message and error location. + error = error[DEFAULT_SOURCE_OFFSET:] + column = messages[i+1].index('^') - DEFAULT_SOURCE_OFFSET + + # Let source have some space also on the right side, plus 6 + # to accommodate ... on each side. + max_len = width - DEFAULT_SOURCE_OFFSET - 6 + source_line, offset = trim_source_line(error, max_len, column, MINIMUM_WIDTH) + + new_messages[i] = ' ' * DEFAULT_SOURCE_OFFSET + source_line + # Also adjust the error marker position. + new_messages[i+1] = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^' + return new_messages + def colorize(self, error: str) -> str: - """Colorize an output line by highlighting the status and error code.""" + """Colorize an output line by highlighting the status and error code. + + If fixed_terminal_width is given, use it instead of calling get_terminal_width() + (used by the daemon). + """ if ': error:' in error: loc, msg = error.split('error:', maxsplit=1) if not self.show_error_codes: @@ -401,10 +568,19 @@ def colorize(self, error: str) -> str: elif ': note:' in error: loc, msg = error.split('note:', maxsplit=1) return loc + self.style('note:', 'blue') + self.underline_link(msg) + elif error.startswith(' ' * DEFAULT_SOURCE_OFFSET): + # TODO: detecting source code highlights through an indent can be surprising. + if '^' not in error: + return self.style(error, 'none', dim=True) + return self.style(error, 'red') else: return error def highlight_quote_groups(self, msg: str) -> str: + """Make groups quoted with double quotes bold (including quotes). + + This is used to highlight types, attribute names etc. + """ if msg.count('"') % 2: # Broken error message, don't do any formatting. return msg @@ -418,6 +594,10 @@ def highlight_quote_groups(self, msg: str) -> str: return out def underline_link(self, note: str) -> str: + """Underline a link in a note message (if any). + + This assumes there is at most one link in the message. + """ match = re.search(r'https?://\S*', note) if not match: return note @@ -428,6 +608,11 @@ def underline_link(self, note: str) -> str: note[end:]) def format_success(self, n_sources: int, use_color: bool = True) -> str: + """Format short summary in case of success. + + n_sources is total number of files passed directly on command line, + i.e. excluding stubs and followed imports. + """ msg = 'Success: no issues found in {}' \ ' source file{}'.format(n_sources, 's' if n_sources != 1 else '') if not use_color: @@ -436,6 +621,7 @@ def format_success(self, n_sources: int, use_color: bool = True) -> str: def format_error(self, n_errors: int, n_files: int, n_sources: int, use_color: bool = True) -> str: + """Format a short summary in case of errors.""" 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 '', diff --git a/mypy_self_check.ini b/mypy_self_check.ini index 576c755b0146..cb9eb4b2aa35 100644 --- a/mypy_self_check.ini +++ b/mypy_self_check.ini @@ -15,5 +15,6 @@ warn_unused_ignores = True warn_unused_configs = True show_traceback = True show_error_codes = True +pretty = True always_false = MYPYC plugins = misc/proper_plugin.py diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 8a75579ff119..e303f992344a 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1387,3 +1387,41 @@ Found 2 errors in 2 files (checked 2 source files) [out] mypy: can't read file 'missing.py': No such file or directory == Return code: 2 + +[case testShowSourceCodeSnippetsWrappedFormatting] +# cmd: mypy --pretty --python-version=3.6 some_file.py +[file some_file.py] +from typing import Union + +42 + 'no way' + +class OneCustomClassName: + def some_interesting_method(self, arg: AnotherCustomClassDefinedBelow) -> AnotherCustomClassDefinedBelow: + ... + +class AnotherCustomClassDefinedBelow: + def another_even_more_interesting_method(self, arg: Union[int, str, float]) -> None: + self.very_important_attribute_with_long_name: OneCustomClassName = OneCustomClassName().some_interesting_method(arg) +[out] +some_file.py:3: error: Unsupported operand types for + ("int" and "str") + 42 + 'no way' + ^ +some_file.py:11: error: Incompatible types in assignment (expression has type +"AnotherCustomClassDefinedBelow", variable has type "OneCustomClassName") + self.very_important_attribute_with_long_name: OneCustomClassNa... + ^ +some_file.py:11: error: Argument 1 to "some_interesting_method" of +"OneCustomClassName" has incompatible type "Union[int, str, float]"; expected +"AnotherCustomClassDefinedBelow" + ...t_attribute_with_long_name: OneCustomClassName = OneCustomClassName().... + ^ + +[case testShowSourceCodeSnippetsBlockingError] +# cmd: mypy --pretty --show-error-codes some_file.py +[file some_file.py] +it_looks_like_we_started_typing_something_but_then. = did_not_notice(an_extra_dot) +[out] +some_file.py:1: error: invalid syntax [syntax] + ...ooks_like_we_started_typing_something_but_then. = did_not_notice(an_ex... + ^ +== Return code: 2 diff --git a/test-data/unit/daemon.test b/test-data/unit/daemon.test index 70ce344bc175..d993901d5828 100644 --- a/test-data/unit/daemon.test +++ b/test-data/unit/daemon.test @@ -52,6 +52,32 @@ Daemon stopped [file foo.py] def f(): pass +[case testDaemonRunRestartPretty] +$ dmypy run -- foo.py --follow-imports=error --pretty +Daemon started +Success: no issues found in 1 source file +$ dmypy run -- foo.py --follow-imports=error --pretty +Success: no issues found in 1 source file +$ {python} -c "print('[mypy]')" >mypy.ini +$ {python} -c "print('disallow_untyped_defs = True')" >>mypy.ini +$ dmypy run -- foo.py --follow-imports=error --pretty +Restarting: configuration changed +Daemon stopped +Daemon started +foo.py:1: error: Function is missing a return type annotation + def f(): pass + ^ +foo.py:1: note: Use "-> None" if function does not return a value +Found 1 error in 1 file (checked 1 source file) +== Return code: 1 +$ {python} -c "print('def f() -> None: pass')" >foo.py +$ dmypy run -- foo.py --follow-imports=error --pretty +Success: no issues found in 1 source file +$ dmypy stop +Daemon stopped +[file foo.py] +def f(): pass + [case testDaemonRunRestartPluginVersion] $ dmypy run -- foo.py --no-error-summary Daemon started diff --git a/test-data/unit/fine-grained-blockers.test b/test-data/unit/fine-grained-blockers.test index 677c8d2d9e14..25587dd5671c 100644 --- a/test-data/unit/fine-grained-blockers.test +++ b/test-data/unit/fine-grained-blockers.test @@ -24,6 +24,29 @@ a.py:1: error: invalid syntax main:2: error: Too few arguments for "f" == +[case testParseErrorShowSource] +# flags: --pretty --show-error-codes +import a +a.f() +[file a.py] +def f() -> None: pass +[file a.py.2] +def f(x: int) -> +[file a.py.3] +def f(x: int) -> None: pass +[file a.py.4] +def f() -> None: pass +[out] +== +a.py:1: error: invalid syntax [syntax] + def f(x: int) -> + ^ +== +main:3: error: Too few arguments for "f" [call-arg] + a.f() + ^ +== + [case testParseErrorMultipleTimes] import a a.f() diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index b55d765d4bce..ad2cba3dbf15 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -81,6 +81,24 @@ class A: == main:4: error: Too few arguments for "g" of "A" +[case testReprocessMethodShowSource] +# flags: --pretty --show-error-codes +import m +class B: + def f(self, a: m.A) -> None: + a.g() # E +[file m.py] +class A: + def g(self) -> None: pass +[file m.py.2] +class A: + def g(self, a: A) -> None: pass +[out] +== +main:5: error: Too few arguments for "g" of "A" [call-arg] + a.g() # E + ^ + [case testFunctionMissingModuleAttribute] import m def h() -> None: