From e43735775ed8708b35994890af85f603711e1044 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 19:00:42 +0100 Subject: [PATCH 01/18] Start working on source code snippets --- mypy/build.py | 6 ++++- mypy/errors.py | 30 +++++++++++++++++++--- mypy/main.py | 6 ++++- mypy/nodes.py | 2 +- mypy/options.py | 1 + mypy/util.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 102 insertions(+), 11 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index f69488a943b97..18ec52e09911b 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -2985,7 +2985,11 @@ def process_stale_scc(graph: Graph, scc: List[str], manager: BuildManager) -> No for id in stale: graph[id].transitive_error = True for id in stale: - manager.flush_errors(manager.errors.file_messages(graph[id].xpath), False) + manager.flush_errors(manager.errors.file_messages( + graph[id].xpath, + manager.fscache.read_cache.get(graph[id].xpath), + manager.options.show_source_code, + manager.options.python_version), False) graph[id].write_cache() graph[id].mark_as_rechecked() diff --git a/mypy/errors.py b/mypy/errors.py index 2329483ab5cbc..070bb8ea0c83e 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -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 decode_python_encoding, DecodeError, trim_source_line T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -161,6 +162,8 @@ def __init__(self, self.show_column_numbers = show_column_numbers self.show_error_codes = show_error_codes self.initialize() + # Source code lines for given path. + self.source_cache = {} # type: Dict[str, List[str]] def initialize(self) -> None: self.error_info_map = OrderedDict() @@ -403,7 +406,8 @@ 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], + add_snippets: bool = False) -> List[str]: """Return a string list that represents the error messages. Use a form suitable for displaying to the user. @@ -423,12 +427,21 @@ def format_messages(self, error_info: List[ErrorInfo]) -> List[str]: s = '{}: {}: {}'.format(srcloc, severity, message) else: s = message - if self.show_error_codes and code: + if self.show_error_codes and code and not add_snippets: s = '{} [{}]'.format(s, code.code) a.append(s) + if add_snippets: + if severity == 'error' and file in self.source_cache and line > 0: + source_line = self.source_cache[file][line - 1] + source_line, offset = trim_source_line(source_line, 80, column, 20) + a.append(' ' * 4 + source_line) + a.append(' ' * (4 + column - offset) + '^~~~~~~~' + + ' [{}]'.format(code.code) if code else '') return a - def file_messages(self, path: str) -> List[str]: + def file_messages(self, path: str, source: Optional[bytes] = None, + add_snippets: bool = False, + pyversion: Optional[Tuple[int, int]] = None) -> List[str]: """Return a string list of new error messages from a given file. Use a form suitable for displaying to the user. @@ -436,7 +449,16 @@ 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]) + if add_snippets: + short_path = remove_path_prefix(path, self.ignore_prefix) + if short_path not in self.source_cache and source is not None: + assert pyversion is not None + try: + self.source_cache[short_path] = decode_python_encoding(source, + pyversion).splitlines() + except DecodeError: + pass + return self.format_messages(self.error_info_map[path], add_snippets) 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 bcc165a8bc4ce..8e1451a283fe7 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -63,7 +63,8 @@ def main(script_path: Optional[str], fscache=fscache) messages = [] - formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes) + formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes, + options.show_source_code) def flush_errors(new_messages: List[str], serious: bool) -> None: messages.extend(new_messages) @@ -579,6 +580,9 @@ 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('--show-source-code', default=False, + help="Show source code snippets 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) diff --git a/mypy/nodes.py b/mypy/nodes.py index 79343f4ad2123..92d5d5dd7097d 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -895,7 +895,7 @@ class ClassDef(Statement): defs = None # type: Block type_vars = None # type: List[mypy.types.TypeVarDef] # Base class expressions (not semantically analyzed -- can be arbitrary expressions) - base_type_exprs = None # type: List[Expression] + base_type_exprs = None # type: List[Node] # Special base classes like Generic[...] get moved here during semantic analysis removed_base_type_exprs = None # type: List[Expression] info = None # type: TypeInfo # Related TypeInfo diff --git a/mypy/options.py b/mypy/options.py index be95c29440dba..41c82144a71e7 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -244,6 +244,7 @@ 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 + self.show_source_code = False self.dump_graph = False self.dump_deps = False self.logical_deps = False diff --git a/mypy/util.py b/mypy/util.py index 6fb297c94262a..29572b0dc5499 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -4,6 +4,7 @@ import re import subprocess import sys +import os from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO from typing_extensions import Final, Type, Literal @@ -92,8 +93,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 +110,15 @@ def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str: return source_text +def trim_source_line(line: str, max_len: int, col: int, min_width: int) -> Tuple[str, int]: + if len(line) < max_len: + return line, 0 + if col < max_len - min_width: + return line[:max_len] + '...', 0 + offset = col - max_len + min_width + return '...' + line[offset:col + min_width] + '...', offset - 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,14 +338,51 @@ def count_stats(errors: List[str]) -> Tuple[int, int]: return len(errors), len(files) +def split_words(msg: str) -> List[str]: + 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 soft_wrap(msg: str, max_len: int, pad: int) -> str: + words = split_words(msg) + next_line = words.pop(0) + lines = [] # type: List[str] + while words: + if len(next_line) > max_len: + lines.append(next_line) + next_line = words.pop(0) + continue + next_word = words[0] + if len(next_line) + len(next_word) + 1 < max_len: + next_line += ' ' + words.pop(0) + else: + lines.append(next_line) + next_line = words.pop(0) + lines.append(next_line) + padding = '\n' + ' ' * pad + 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: + show_error_codes: bool, show_source_code: bool) -> None: self.show_error_codes = show_error_codes + self.show_source_code = show_source_code # Check if we are in a human-facing terminal on a supported platform. if sys.platform not in ('linux', 'darwin'): self.dummy_term = True @@ -365,6 +411,8 @@ def __init__(self, f_out: IO[str], f_err: IO[str], self.BOLD = bold.decode() self.UNDER = under.decode() + dim = curses.tigetstr('dim') + self.DIM = dim.decode() if dim else '' 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() @@ -390,7 +438,15 @@ 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: + try: + cols, _ = os.get_terminal_size() + except OSError: + cols = 9000 + pad = len(loc) + len('error: ') + cols = max(80, cols) + max_len = cols - pad - 1 + msg = soft_wrap(msg, max_len, pad) + if not self.show_error_codes or self.show_source_code: return (loc + self.style('error:', 'red', bold=True) + self.highlight_quote_groups(msg)) codepos = msg.rfind('[') @@ -401,6 +457,10 @@ 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 self.show_source_code and error.startswith(' ' * 4): + if '^~~~' not in error: + return self.style(self.DIM + error, 'none') + return self.style(error, 'yellow') else: return error From 22d9abdd5ddf0453fc45ba9e33f614a0f3f7b9a1 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 22:14:04 +0100 Subject: [PATCH 02/18] Make some cleanup --- mypy/errors.py | 23 +++++++++++----- mypy/util.py | 74 ++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/mypy/errors.py b/mypy/errors.py index 070bb8ea0c83e..c504e01b7f7d6 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -11,7 +11,10 @@ from mypy.version import __version__ as mypy_version from mypy.errorcodes import ErrorCode from mypy import errorcodes as codes -from mypy.util import decode_python_encoding, DecodeError, trim_source_line +from mypy.util import ( + decode_python_encoding, DecodeError, trim_source_line, DEFAULT_SOURCE_OFFSET, + WIGGLY_LINE, DEFAULT_COLUMNS, MINIMUM_WIDTH +) T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -410,7 +413,9 @@ def format_messages(self, error_info: List[ErrorInfo], add_snippets: bool = False) -> 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 add_snippets + 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,15 +432,18 @@ def format_messages(self, error_info: List[ErrorInfo], s = '{}: {}: {}'.format(srcloc, severity, message) else: s = message + # If we show source code snippet, the error code is shown under it + # (with a pointer to error location), instead of after the error message. if self.show_error_codes and code and not add_snippets: s = '{} [{}]'.format(s, code.code) a.append(s) if add_snippets: if severity == 'error' and file in self.source_cache and line > 0: - source_line = self.source_cache[file][line - 1] - source_line, offset = trim_source_line(source_line, 80, column, 20) - a.append(' ' * 4 + source_line) - a.append(' ' * (4 + column - offset) + '^~~~~~~~' + + source_line, offset = trim_source_line(self.source_cache[file][line - 1], + DEFAULT_COLUMNS, column, MINIMUM_WIDTH) + a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) + # Also append a marker pointing to the error start location. + a.append(' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + WIGGLY_LINE + ' [{}]'.format(code.code) if code else '') return a @@ -444,7 +452,8 @@ def file_messages(self, path: str, source: Optional[bytes] = None, pyversion: Optional[Tuple[int, int]] = None) -> List[str]: """Return a string list of new error messages from a given file. - Use a form suitable for displaying to the user. + Use a form suitable for displaying to the user. If add_snippets is True, + then one must also pass the source code and Python version. """ if path not in self.error_info_map: return [] diff --git a/mypy/util.py b/mypy/util.py index 29572b0dc5499..04d328195b0bb 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -21,6 +21,15 @@ ENCODING_RE = \ re.compile(br'([ \t\v]*#.*(\r\n?|\n))??[ \t\v]*#.*coding[:=][ \t]*([-\w.]+)') # type: Final +PLAIN_ANSI_DIM = '\x1b[2m' # type: Final +DEFAULT_SOURCE_OFFSET = 4 # type: Final +WIGGLY_LINE = '^~~~~~~~' # type: Final +DEFAULT_COLUMNS = 80 + +# At least these 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 @@ -111,6 +120,15 @@ def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str: 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' of characters around '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 len(line) < max_len: return line, 0 if col < max_len - min_width: @@ -339,6 +357,7 @@ def count_stats(errors: List[str]) -> Tuple[int, int]: 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 @@ -354,21 +373,33 @@ def split_words(msg: str) -> List[str]: return res +def get_term_columns() -> int: + try: + cols, _ = os.get_terminal_size() + return cols + except OSError: + return DEFAULT_COLUMNS + + def soft_wrap(msg: str, max_len: int, pad: int) -> 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]"). Pad every next line + with 'pad' spaces. Every line will be at most 'max_len' characters (before + padding), except if it is a single word or quoted group. + """ words = split_words(msg) next_line = words.pop(0) lines = [] # type: List[str] while words: - if len(next_line) > max_len: - lines.append(next_line) - next_line = words.pop(0) - continue - next_word = words[0] + next_word = words.pop(0) + # Add 1 to account for space between words. if len(next_line) + len(next_word) + 1 < max_len: - next_line += ' ' + words.pop(0) + next_line += ' ' + next_word else: lines.append(next_line) - next_line = words.pop(0) + next_line = next_word lines.append(next_line) padding = '\n' + ' ' * pad return padding.join(lines) @@ -412,7 +443,8 @@ def __init__(self, f_out: IO[str], f_err: IO[str], self.BOLD = bold.decode() self.UNDER = under.decode() dim = curses.tigetstr('dim') - self.DIM = dim.decode() if dim else '' + # 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() @@ -424,6 +456,7 @@ def __init__(self, f_out: IO[str], f_err: IO[str], def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'none'], bold: bool = False, underline: bool = False) -> str: + """Apply simple color and style (underlined or bold).""" if self.dummy_term: return text if bold: @@ -438,14 +471,11 @@ 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) - try: - cols, _ = os.get_terminal_size() - except OSError: - cols = 9000 pad = len(loc) + len('error: ') - cols = max(80, cols) - max_len = cols - pad - 1 + max_len = get_term_columns() - pad - 1 # compensate for space after 'error:' msg = soft_wrap(msg, max_len, pad) + # If show_source_code is true, the error code is shown on a separate line, + # see the last branch below. if not self.show_error_codes or self.show_source_code: return (loc + self.style('error:', 'red', bold=True) + self.highlight_quote_groups(msg)) @@ -458,13 +488,17 @@ def colorize(self, error: str) -> str: loc, msg = error.split('note:', maxsplit=1) return loc + self.style('note:', 'blue') + self.underline_link(msg) elif self.show_source_code and error.startswith(' ' * 4): - if '^~~~' not in error: + if WIGGLY_LINE not in error: return self.style(self.DIM + error, 'none') return self.style(error, 'yellow') 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 @@ -478,6 +512,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 @@ -488,11 +526,17 @@ def underline_link(self, note: str) -> str: note[end:]) def format_success(self, n_sources: int) -> 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. + """ 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: + """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 '', From 1f3fb3ac45cffe04df25a14da2c51c14fbb6c58e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 22:42:21 +0100 Subject: [PATCH 03/18] Add couple tests --- mypy/util.py | 6 ++-- test-data/unit/cmdline.test | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/mypy/util.py b/mypy/util.py index 04d328195b0bb..e50a409813d62 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -455,7 +455,7 @@ def __init__(self, f_out: IO[str], f_err: IO[str], '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 @@ -465,6 +465,8 @@ 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 colorize(self, error: str) -> str: @@ -489,7 +491,7 @@ def colorize(self, error: str) -> str: return loc + self.style('note:', 'blue') + self.underline_link(msg) elif self.show_source_code and error.startswith(' ' * 4): if WIGGLY_LINE not in error: - return self.style(self.DIM + error, 'none') + return self.style(error, 'none', dim=True) return self.style(error, 'yellow') else: return error diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 8a75579ff119c..9b50670c7e42d 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1387,3 +1387,58 @@ 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 testShowSourceCodeSnippetsDefaultFormatting] +# cmd: mypy --show-source-code --no-color-output 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' + ^~~~~~~~ [operator] +some_file.py:11: error: Incompatible types in assignment (expression has type "AnotherCustomClassDefinedBelow", variable has type "OneCustomClassName") + self.very_important_attribute_with_long_name: OneCustomClassName = OneCu... + ^~~~~~~~ [assignment] +some_file.py:11: error: Argument 1 to "some_interesting_method" of "OneCustomClassName" has incompatible type "Union[int, str, float]"; expected "AnotherCustomClassDefinedBelow" + ...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassName()... + ^~~~~~~~ [arg-type] + +[case testShowSourceCodeSnippetsWrappedFormatting] +# cmd: mypy --show-source-code 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' + ^~~~~~~~ [operator] +some_file.py:11: error: Incompatible types in assignment (expression has type + "AnotherCustomClassDefinedBelow", variable has type + "OneCustomClassName") + self.very_important_attribute_with_long_name: OneCustomClassName = OneCu... + ^~~~~~~~ [assignment] +some_file.py:11: error: Argument 1 to "some_interesting_method" of + "OneCustomClassName" has incompatible type + "Union[int, str, float]"; expected + "AnotherCustomClassDefinedBelow" + ...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassName()... + ^~~~~~~~ [arg-type] From 4d5353f1d8d1eb3d1ef75f16ebc5cabf6bde3513 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 22:47:00 +0100 Subject: [PATCH 04/18] Undo the dedicated error; update self-check to see these columns --- mypy/nodes.py | 2 +- mypy_self_check.ini | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index 92d5d5dd7097d..79343f4ad2123 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -895,7 +895,7 @@ class ClassDef(Statement): defs = None # type: Block type_vars = None # type: List[mypy.types.TypeVarDef] # Base class expressions (not semantically analyzed -- can be arbitrary expressions) - base_type_exprs = None # type: List[Node] + base_type_exprs = None # type: List[Expression] # Special base classes like Generic[...] get moved here during semantic analysis removed_base_type_exprs = None # type: List[Expression] info = None # type: TypeInfo # Related TypeInfo diff --git a/mypy_self_check.ini b/mypy_self_check.ini index 576c755b01464..e1cad0a03200b 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 +show_source_code = True always_false = MYPYC plugins = misc/proper_plugin.py From 316af1f4c20d809a48bffbedb7327efcb125debc Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 23:23:47 +0100 Subject: [PATCH 05/18] Only do wrapping when showing source --- mypy/util.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mypy/util.py b/mypy/util.py index e50a409813d62..b09426c227e0b 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -440,16 +440,18 @@ 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': ''} @@ -473,11 +475,12 @@ 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) - pad = len(loc) + len('error: ') - max_len = get_term_columns() - pad - 1 # compensate for space after 'error:' - msg = soft_wrap(msg, max_len, pad) - # If show_source_code is true, the error code is shown on a separate line, - # see the last branch below. + if self.show_source_code: + # Improve readability by wrapping lines when showing source code. + pad = len(loc) + len('error: ') + max_len = get_term_columns() - pad - 1 # compensate for space after 'error:' + msg = soft_wrap(msg, max_len, pad) + # If show_source_code is true, the error code is shown on a separate line. if not self.show_error_codes or self.show_source_code: return (loc + self.style('error:', 'red', bold=True) + self.highlight_quote_groups(msg)) From 29585dc417839bf5defe1dbdbae9226408759d8c Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 23:30:11 +0100 Subject: [PATCH 06/18] Minor fixes --- mypy/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy/util.py b/mypy/util.py index b09426c227e0b..2e4901ece1096 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -26,7 +26,7 @@ WIGGLY_LINE = '^~~~~~~~' # type: Final DEFAULT_COLUMNS = 80 -# At least these number of columns will be shown on each side of +# At least this number of columns will be shown on each side of # error location when printing source code snippet. MINIMUM_WIDTH = 20 @@ -492,7 +492,7 @@ 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 self.show_source_code and error.startswith(' ' * 4): + elif self.show_source_code and error.startswith(' ' * DEFAULT_SOURCE_OFFSET): if WIGGLY_LINE not in error: return self.style(error, 'none', dim=True) return self.style(error, 'yellow') From 52fd9a8e69bd9bd4b4b436590e65a596d4f10eff Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 1 Sep 2019 00:28:51 +0100 Subject: [PATCH 07/18] Fix tests on Python 3.5 --- test-data/unit/cmdline.test | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 9b50670c7e42d..3bd0578a5ef99 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1389,7 +1389,7 @@ mypy: can't read file 'missing.py': No such file or directory == Return code: 2 [case testShowSourceCodeSnippetsDefaultFormatting] -# cmd: mypy --show-source-code --no-color-output some_file.py +# cmd: mypy --show-source-code --no-color-output --python-version=3.6 some_file.py [file some_file.py] from typing import Union @@ -1414,7 +1414,7 @@ some_file.py:11: error: Argument 1 to "some_interesting_method" of "OneCustomCla ^~~~~~~~ [arg-type] [case testShowSourceCodeSnippetsWrappedFormatting] -# cmd: mypy --show-source-code some_file.py +# cmd: mypy --show-source-code --python-version=3.6 some_file.py [file some_file.py] from typing import Union From 281c655c8eed3bc9fdc42449f0a11d542b7e2d47 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 1 Sep 2019 15:47:08 +0100 Subject: [PATCH 08/18] Support also blocking errors and daemon --- mypy/build.py | 10 ++------ mypy/errors.py | 51 ++++++++++++++++++++----------------- test-data/unit/cmdline.test | 10 ++++++++ 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 18ec52e09911b..51855a5effa1f 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -197,9 +197,7 @@ def _build(sources: List[BuildSource], reports = Reports(data_dir, options.report_dirs) source_set = BuildSourceSet(sources) - errors = Errors(options.show_error_context, - options.show_column_numbers, - options.show_error_codes) + errors = Errors(fscache, options) plugin, snapshot = load_plugins(options, errors, stdout) # Construct a build manager object to hold state during the build. @@ -2985,11 +2983,7 @@ def process_stale_scc(graph: Graph, scc: List[str], manager: BuildManager) -> No for id in stale: graph[id].transitive_error = True for id in stale: - manager.flush_errors(manager.errors.file_messages( - graph[id].xpath, - manager.fscache.read_cache.get(graph[id].xpath), - manager.options.show_source_code, - manager.options.python_version), False) + manager.flush_errors(manager.errors.file_messages(graph[id].xpath), False) graph[id].write_cache() graph[id].mark_as_rechecked() diff --git a/mypy/errors.py b/mypy/errors.py index c504e01b7f7d6..5b89d6b968a8b 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -15,6 +15,7 @@ decode_python_encoding, DecodeError, trim_source_line, DEFAULT_SOURCE_OFFSET, WIGGLY_LINE, DEFAULT_COLUMNS, MINIMUM_WIDTH ) +from mypy.fscache import FileSystemCache T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -157,16 +158,15 @@ class Errors: target_module = None # type: Optional[str] scope = None # type: Optional[Scope] - def __init__(self, - show_error_context: bool = False, - show_column_numbers: bool = False, - show_error_codes: bool = False) -> None: - self.show_error_context = show_error_context - self.show_column_numbers = show_column_numbers - self.show_error_codes = show_error_codes + def __init__(self, fscache: Optional[FileSystemCache] = None, + options: Optional[Options] = None) -> None: + self.show_error_context = options.show_error_context if options else False + self.show_column_numbers = options.show_column_numbers if options else False + self.show_error_codes = options.show_error_codes if options else False + # We use fscache to read source code when showing snippets. + self.fscache = fscache + self.options = options self.initialize() - # Source code lines for given path. - self.source_cache = {} # type: Dict[str, List[str]] def initialize(self) -> None: self.error_info_map = OrderedDict() @@ -185,7 +185,7 @@ def reset(self) -> None: self.initialize() def copy(self) -> 'Errors': - new = Errors(self.show_error_context, self.show_column_numbers) + new = Errors(self.fscache, self.options) new.file = self.file new.import_ctx = self.import_ctx[:] new.type_name = self.type_name[:] @@ -410,7 +410,7 @@ def raise_error(self) -> None: module_with_blocker=self.blocker_module()) def format_messages(self, error_info: List[ErrorInfo], - add_snippets: bool = False) -> List[str]: + 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. If add_snippets @@ -420,6 +420,7 @@ def format_messages(self, error_info: List[ErrorInfo], a = [] # type: List[str] errors = self.render_messages(self.sort_messages(error_info)) errors = self.remove_duplicates(errors) + add_snippets = self.options and self.options.show_source_code for file, line, column, severity, message, code in errors: s = '' if file is not None: @@ -438,8 +439,8 @@ def format_messages(self, error_info: List[ErrorInfo], s = '{} [{}]'.format(s, code.code) a.append(s) if add_snippets: - if severity == 'error' and file in self.source_cache and line > 0: - source_line, offset = trim_source_line(self.source_cache[file][line - 1], + if severity == 'error' and source_lines and line > 0: + source_line, offset = trim_source_line(source_lines[line - 1], DEFAULT_COLUMNS, column, MINIMUM_WIDTH) a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) # Also append a marker pointing to the error start location. @@ -447,9 +448,7 @@ def format_messages(self, error_info: List[ErrorInfo], ' [{}]'.format(code.code) if code else '') return a - def file_messages(self, path: str, source: Optional[bytes] = None, - add_snippets: bool = False, - pyversion: Optional[Tuple[int, int]] = None) -> List[str]: + def file_messages(self, path: str) -> List[str]: """Return a string list of new error messages from a given file. Use a form suitable for displaying to the user. If add_snippets is True, @@ -458,16 +457,20 @@ def file_messages(self, path: str, source: Optional[bytes] = None, if path not in self.error_info_map: return [] self.flushed_files.add(path) - if add_snippets: - short_path = remove_path_prefix(path, self.ignore_prefix) - if short_path not in self.source_cache and source is not None: - assert pyversion is not None + if self.options and self.options.show_source_code: + source_lines = None + if self.fscache: try: - self.source_cache[short_path] = decode_python_encoding(source, - pyversion).splitlines() - except DecodeError: + source = self.fscache.read(path) + except OSError: pass - return self.format_messages(self.error_info_map[path], add_snippets) + else: + try: + source_lines = decode_python_encoding( + source, self.options.python_version).splitlines() + except DecodeError: + pass + 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/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 3bd0578a5ef99..184a9d302267e 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1442,3 +1442,13 @@ some_file.py:11: error: Argument 1 to "some_interesting_method" of "AnotherCustomClassDefinedBelow" ...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassName()... ^~~~~~~~ [arg-type] + +[case testShowSourceCodeSnippetsBlockingError] +# cmd: mypy --show-source-code 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 + it_looks_like_we_started_typing_something_but_then. = did_not_notice(an_extra_do... + ^~~~~~~~ [syntax] +== Return code: 2 From bd4987b815ea5dd29e637ed03f2fd25387393c5d Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 1 Sep 2019 15:54:57 +0100 Subject: [PATCH 09/18] Add couple tests --- test-data/unit/fine-grained-blockers.test | 23 +++++++++++++++++++++++ test-data/unit/fine-grained.test | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/test-data/unit/fine-grained-blockers.test b/test-data/unit/fine-grained-blockers.test index 677c8d2d9e146..90b1211c705a2 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: --show-source-code +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 + def f(x: int) -> + ^~~~~~~~ [syntax] +== +main:3: error: Too few arguments for "f" + a.f() + ^~~~~~~~ [call-arg] +== + [case testParseErrorMultipleTimes] import a a.f() diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index b55d765d4bce9..a25b5a373bf60 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: --show-source-code +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" + a.g() # E + ^~~~~~~~ [call-arg] + [case testFunctionMissingModuleAttribute] import m def h() -> None: From b257dde7c51161649abf418e0e8978c31b8ff495 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 1 Sep 2019 16:06:47 +0100 Subject: [PATCH 10/18] Fix bug --- mypy/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/errors.py b/mypy/errors.py index 5b89d6b968a8b..16d530355805e 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -457,8 +457,8 @@ def file_messages(self, path: str) -> List[str]: if path not in self.error_info_map: return [] self.flushed_files.add(path) + source_lines = None if self.options and self.options.show_source_code: - source_lines = None if self.fscache: try: source = self.fscache.read(path) From f477808b0b00cbf62d2667ef2d647f4ce7de598e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Mon, 2 Sep 2019 22:08:04 +0100 Subject: [PATCH 11/18] Address some CR --- mypy/build.py | 10 ++++++-- mypy/dmypy_server.py | 3 ++- mypy/errors.py | 57 +++++++++++++++++++------------------------- mypy/util.py | 31 +++++++++++++++++++----- 4 files changed, 59 insertions(+), 42 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index ae6a9810886e3..1dc730ba66e32 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,7 +198,12 @@ def _build(sources: List[BuildSource], reports = Reports(data_dir, options.report_dirs) source_set = BuildSourceSet(sources) - errors = Errors(fscache, options) + cached_read = fscache.read + errors = Errors(options.show_error_context, + options.show_column_numbers, + options.show_error_codes, + options.show_source_code, + 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_server.py b/mypy/dmypy_server.py index 9349ddc781a8b..fa9bdc82e10a3 100644 --- a/mypy/dmypy_server.py +++ b/mypy/dmypy_server.py @@ -191,7 +191,8 @@ def __init__(self, options: Options, # Since the object is created in the parent process we can check # the output terminal options here. - self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.show_error_codes) + self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.show_error_codes, + options.show_source_code) def _response_metadata(self) -> Dict[str, str]: py_version = '{}_{}'.format(self.options.python_version[0], self.options.python_version[1]) diff --git a/mypy/errors.py b/mypy/errors.py index 751e73790ab77..4e628eb5ebc70 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,11 +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 ( - decode_python_encoding, DecodeError, trim_source_line, DEFAULT_SOURCE_OFFSET, - WIGGLY_LINE, DEFAULT_COLUMNS, MINIMUM_WIDTH -) -from mypy.fscache import FileSystemCache +from mypy.util import trim_source_line, DEFAULT_SOURCE_OFFSET, DEFAULT_COLUMNS, MINIMUM_WIDTH T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -158,14 +154,18 @@ class Errors: target_module = None # type: Optional[str] scope = None # type: Optional[Scope] - def __init__(self, fscache: Optional[FileSystemCache] = None, - options: Optional[Options] = None) -> None: - self.show_error_context = options.show_error_context if options else False - self.show_column_numbers = options.show_column_numbers if options else False - self.show_error_codes = options.show_error_codes if options else False + def __init__(self, + show_error_context: bool = False, + show_column_numbers: bool = False, + show_error_codes: bool = False, + show_source_code: 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.show_source_code = show_source_code # We use fscache to read source code when showing snippets. - self.fscache = fscache - self.options = options + self.read_source = read_source self.initialize() def initialize(self) -> None: @@ -185,7 +185,11 @@ def reset(self) -> None: self.initialize() def copy(self) -> 'Errors': - new = Errors(self.fscache, self.options) + new = Errors(self.show_error_context, + self.show_column_numbers, + self.show_error_codes, + self.show_source_code, + self.read_source) new.file = self.file new.import_ctx = self.import_ctx[:] new.type_name = self.type_name[:] @@ -419,7 +423,6 @@ def format_messages(self, error_info: List[ErrorInfo], a = [] # type: List[str] errors = self.render_messages(self.sort_messages(error_info)) errors = self.remove_duplicates(errors) - add_snippets = self.options and self.options.show_source_code for file, line, column, severity, message, code in errors: s = '' if file is not None: @@ -432,21 +435,18 @@ def format_messages(self, error_info: List[ErrorInfo], s = '{}: {}: {}'.format(srcloc, severity, message) else: s = message - # If we show source code snippet, the error code is shown under it - # (with a pointer to error location), instead of after the error message. - if self.show_error_codes and code and not add_snippets and severity != 'note': + if self.show_error_codes and code and severity != 'note': # If note has an error code, it is related to a previous error. Avoid # displaying duplicate error codes. s = '{} [{}]'.format(s, code.code) a.append(s) - if add_snippets: + if self.show_source_code: if severity == 'error' and source_lines and line > 0: source_line, offset = trim_source_line(source_lines[line - 1], DEFAULT_COLUMNS, column, MINIMUM_WIDTH) a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) # Also append a marker pointing to the error start location. - a.append(' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + WIGGLY_LINE + - ' [{}]'.format(code.code) if code else '') + a.append(' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^') return a def file_messages(self, path: str) -> List[str]: @@ -459,18 +459,9 @@ def file_messages(self, path: str) -> List[str]: return [] self.flushed_files.add(path) source_lines = None - if self.options and self.options.show_source_code: - if self.fscache: - try: - source = self.fscache.read(path) - except OSError: - pass - else: - try: - source_lines = decode_python_encoding( - source, self.options.python_version).splitlines() - except DecodeError: - pass + if self.show_source_code: + 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]: diff --git a/mypy/util.py b/mypy/util.py index 449d77c796797..404e0cd389baf 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -6,7 +6,9 @@ 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: @@ -23,8 +25,7 @@ PLAIN_ANSI_DIM = '\x1b[2m' # type: Final DEFAULT_SOURCE_OFFSET = 4 # type: Final -WIGGLY_LINE = '^~~~~~~~' # type: Final -DEFAULT_COLUMNS = 80 +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. @@ -119,6 +120,24 @@ 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. @@ -481,7 +500,7 @@ def colorize(self, error: str) -> str: max_len = get_term_columns() - pad - 1 # compensate for space after 'error:' msg = soft_wrap(msg, max_len, pad) # If show_source_code is true, the error code is shown on a separate line. - if not self.show_error_codes or self.show_source_code: + if not self.show_error_codes: return (loc + self.style('error:', 'red', bold=True) + self.highlight_quote_groups(msg)) codepos = msg.rfind('[') @@ -493,9 +512,9 @@ def colorize(self, error: str) -> str: loc, msg = error.split('note:', maxsplit=1) return loc + self.style('note:', 'blue') + self.underline_link(msg) elif self.show_source_code and error.startswith(' ' * DEFAULT_SOURCE_OFFSET): - if WIGGLY_LINE not in error: + if '^' not in error: return self.style(error, 'none', dim=True) - return self.style(error, 'yellow') + return self.style(error, 'red') else: return error From 69a859b13cf84ab17e498c523b93a1e6a0a4577d Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 3 Sep 2019 13:53:23 +0100 Subject: [PATCH 12/18] More CR --- mypy/build.py | 2 +- mypy/dmypy/client.py | 3 ++- mypy/dmypy_server.py | 37 ++++++++++++++++----------- mypy/errors.py | 24 ++++++++++++------ mypy/main.py | 9 ++++--- mypy/options.py | 3 ++- mypy/util.py | 59 +++++++++++++++++++++++++++++++------------- mypy_self_check.ini | 2 +- 8 files changed, 92 insertions(+), 47 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 1dc730ba66e32..cfca57f0477e7 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -202,7 +202,7 @@ def _build(sources: List[BuildSource], errors = Errors(options.show_error_context, options.show_column_numbers, options.show_error_codes, - options.show_source_code, + options.pretty, lambda path: read_py_file(path, cached_read, options.python_version)) plugin, snapshot = load_plugins(options, errors, stdout) diff --git a/mypy/dmypy/client.py b/mypy/dmypy/client.py index 3dc73ecdf22b6..ce69c90ad2fdd 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 fa9bdc82e10a3..975e47fff6b74 100644 --- a/mypy/dmypy_server.py +++ b/mypy/dmypy_server.py @@ -192,7 +192,7 @@ def __init__(self, options: Options, # Since the object is created in the parent process we can check # the output terminal options here. self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.show_error_codes, - options.show_source_code) + options.pretty) def _response_metadata(self) -> Dict[str, str]: py_version = '{}_{}'.format(self.options.python_version[0], self.options.python_version[1]) @@ -264,6 +264,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) @@ -291,7 +292,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 @@ -324,18 +326,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. @@ -361,21 +365,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 path 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 @@ -388,7 +394,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) @@ -450,12 +456,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]: @@ -485,11 +492,11 @@ 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 if self.options.error_summary: summary = None # type: Optional[str] @@ -504,7 +511,7 @@ def pretty_messages(self, messages: List[str], n_sources: int, # Create new list to avoid appending multiple summaries on successive runs. messages = messages + [summary] if use_color: - messages = [self.formatter.colorize(m) for m in messages] + messages = [self.formatter.colorize(m, terminal_width) for m in messages] return messages def update_sources(self, sources: List[BuildSource]) -> None: diff --git a/mypy/errors.py b/mypy/errors.py index 4e628eb5ebc70..899228c407950 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -11,7 +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 trim_source_line, DEFAULT_SOURCE_OFFSET, DEFAULT_COLUMNS, MINIMUM_WIDTH +from mypy.util import trim_source_line, DEFAULT_SOURCE_OFFSET, get_terminal_width, MINIMUM_WIDTH T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -158,14 +158,17 @@ def __init__(self, show_error_context: bool = False, show_column_numbers: bool = False, show_error_codes: bool = False, - show_source_code: 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.show_source_code = show_source_code + self.pretty = pretty # We use fscache to read source code when showing snippets. self.read_source = read_source + # If this is not None, don't try calling get_terminal_width(). + # This is used by the daemon, where the call may return a wrong value. + self.fixed_terminal_width = None # type: Optional[int] self.initialize() def initialize(self) -> None: @@ -188,7 +191,7 @@ def copy(self) -> 'Errors': new = Errors(self.show_error_context, self.show_column_numbers, self.show_error_codes, - self.show_source_code, + self.pretty, self.read_source) new.file = self.file new.import_ctx = self.import_ctx[:] @@ -196,6 +199,7 @@ def copy(self) -> 'Errors': new.function_or_member = self.function_or_member[:] new.target_module = self.target_module new.scope = self.scope + new.fixed_terminal_width = self.fixed_terminal_width return new def total_errors(self) -> int: @@ -440,10 +444,16 @@ def format_messages(self, error_info: List[ErrorInfo], # displaying duplicate error codes. s = '{} [{}]'.format(s, code.code) a.append(s) - if self.show_source_code: + if self.pretty: + # Add source code fragment and a location marker. if severity == 'error' and source_lines and line > 0: + width = self.fixed_terminal_width or get_terminal_width() + # Let source have some space also on the right side. + width -= DEFAULT_SOURCE_OFFSET source_line, offset = trim_source_line(source_lines[line - 1], - DEFAULT_COLUMNS, column, MINIMUM_WIDTH) + width, column, MINIMUM_WIDTH) + # Note, currently coloring uses the offset to detect source snippets, + # so these offsets should not be arbitrary. a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) # Also append a marker pointing to the error start location. a.append(' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^') @@ -459,7 +469,7 @@ def file_messages(self, path: str) -> List[str]: return [] self.flushed_files.add(path) source_lines = None - if self.show_source_code: + if self.pretty: assert self.read_source source_lines = self.read_source(path) return self.format_messages(self.error_info_map[path], source_lines) diff --git a/mypy/main.py b/mypy/main.py index f7311bc4f4323..d5334ada0952b 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -63,8 +63,7 @@ def main(script_path: Optional[str], fscache=fscache) messages = [] - formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes, - options.show_source_code) + formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes, options.pretty) def flush_errors(new_messages: List[str], serious: bool) -> None: messages.extend(new_messages) @@ -583,8 +582,10 @@ 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('--show-source-code', default=False, - help="Show source code snippets in error messages", + 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", diff --git a/mypy/options.py b/mypy/options.py index 41c82144a71e7..d3163e9419e17 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -244,7 +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 - self.show_source_code = 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/util.py b/mypy/util.py index 404e0cd389baf..026c4af747ee6 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -23,7 +23,14 @@ 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 @@ -392,7 +399,8 @@ def split_words(msg: str) -> List[str]: return res -def get_term_columns() -> int: +def get_terminal_width() -> int: + """Get current terminal width if possible, otherwise return the default one.""" try: cols, _ = os.get_terminal_size() return cols @@ -400,27 +408,40 @@ def get_term_columns() -> int: return DEFAULT_COLUMNS -def soft_wrap(msg: str, max_len: int, pad: int) -> str: +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]"). Pad every next line - with 'pad' spaces. Every line will be at most 'max_len' characters (before - padding), except if it is a single word or 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_len: + 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' + ' ' * pad + padding = '\n' + ' ' * num_indent return padding.join(lines) @@ -430,9 +451,9 @@ class FancyFormatter: This currently only works on Linux and Mac. """ def __init__(self, f_out: IO[str], f_err: IO[str], - show_error_codes: bool, show_source_code: bool) -> None: + show_error_codes: bool, pretty: bool) -> None: self.show_error_codes = show_error_codes - self.show_source_code = show_source_code + self.pretty = pretty # Check if we are in a human-facing terminal on a supported platform. if sys.platform not in ('linux', 'darwin'): self.dummy_term = True @@ -490,16 +511,19 @@ def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'non start += self.DIM 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.""" + def colorize(self, error: str, fixed_terminal_width: Optional[int] = None) -> str: + """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 self.show_source_code: + if self.pretty: # Improve readability by wrapping lines when showing source code. - pad = len(loc) + len('error: ') - max_len = get_term_columns() - pad - 1 # compensate for space after 'error:' - msg = soft_wrap(msg, max_len, pad) - # If show_source_code is true, the error code is shown on a separate line. + max_len = (fixed_terminal_width or get_terminal_width()) + # -1 to compensate for space after 'error:' + msg = soft_wrap(msg, max_len - 1, first_offset=len(loc) + len('error: ')) if not self.show_error_codes: return (loc + self.style('error:', 'red', bold=True) + self.highlight_quote_groups(msg)) @@ -511,7 +535,8 @@ 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 self.show_source_code and error.startswith(' ' * DEFAULT_SOURCE_OFFSET): + elif self.pretty and 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') diff --git a/mypy_self_check.ini b/mypy_self_check.ini index e1cad0a03200b..cb9eb4b2aa35d 100644 --- a/mypy_self_check.ini +++ b/mypy_self_check.ini @@ -15,6 +15,6 @@ warn_unused_ignores = True warn_unused_configs = True show_traceback = True show_error_codes = True -show_source_code = True +pretty = True always_false = MYPYC plugins = misc/proper_plugin.py From a44c239c06473d3ae6ea1e494190d07101b338db Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 3 Sep 2019 16:04:28 +0100 Subject: [PATCH 13/18] Separate fit in terminal from colorizing --- mypy/dmypy_server.py | 2 +- mypy/errors.py | 16 +++------------- mypy/main.py | 4 +++- mypy/nodes.py | 2 +- mypy/test/testfinegrained.py | 2 +- mypy/util.py | 35 +++++++++++++++++++++++++---------- 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/mypy/dmypy_server.py b/mypy/dmypy_server.py index 975e47fff6b74..0d671949e9210 100644 --- a/mypy/dmypy_server.py +++ b/mypy/dmypy_server.py @@ -511,7 +511,7 @@ def pretty_messages(self, messages: List[str], n_sources: int, # Create new list to avoid appending multiple summaries on successive runs. messages = messages + [summary] if use_color: - messages = [self.formatter.colorize(m, terminal_width) for m in messages] + messages = [self.formatter.colorize(m) for m in messages] return messages def update_sources(self, sources: List[BuildSource]) -> None: diff --git a/mypy/errors.py b/mypy/errors.py index 899228c407950..c3ff5b34ccc4c 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -11,7 +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 trim_source_line, DEFAULT_SOURCE_OFFSET, get_terminal_width, MINIMUM_WIDTH +from mypy.util import DEFAULT_SOURCE_OFFSET T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -166,9 +166,6 @@ def __init__(self, self.pretty = pretty # We use fscache to read source code when showing snippets. self.read_source = read_source - # If this is not None, don't try calling get_terminal_width(). - # This is used by the daemon, where the call may return a wrong value. - self.fixed_terminal_width = None # type: Optional[int] self.initialize() def initialize(self) -> None: @@ -199,7 +196,6 @@ def copy(self) -> 'Errors': new.function_or_member = self.function_or_member[:] new.target_module = self.target_module new.scope = self.scope - new.fixed_terminal_width = self.fixed_terminal_width return new def total_errors(self) -> int: @@ -447,16 +443,10 @@ def format_messages(self, error_info: List[ErrorInfo], if self.pretty: # Add source code fragment and a location marker. if severity == 'error' and source_lines and line > 0: - width = self.fixed_terminal_width or get_terminal_width() - # Let source have some space also on the right side. - width -= DEFAULT_SOURCE_OFFSET - source_line, offset = trim_source_line(source_lines[line - 1], - width, column, MINIMUM_WIDTH) # Note, currently coloring uses the offset to detect source snippets, # so these offsets should not be arbitrary. - a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) - # Also append a marker pointing to the error start location. - a.append(' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^') + a.append(' ' * DEFAULT_SOURCE_OFFSET + source_lines[line - 1]) + a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + '^') return a def file_messages(self, path: str) -> List[str]: diff --git a/mypy/main.py b/mypy/main.py index d5334ada0952b..fa89de73786bf 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -63,11 +63,13 @@ def main(script_path: Optional[str], fscache=fscache) messages = [] - formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes, options.pretty) + 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 + if options.pretty: + formatter.fit_in_terminal(messages) try: for msg in new_messages: if options.color_output: diff --git a/mypy/nodes.py b/mypy/nodes.py index 79343f4ad2123..92d5d5dd7097d 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -895,7 +895,7 @@ class ClassDef(Statement): defs = None # type: Block type_vars = None # type: List[mypy.types.TypeVarDef] # Base class expressions (not semantically analyzed -- can be arbitrary expressions) - base_type_exprs = None # type: List[Expression] + base_type_exprs = None # type: List[Node] # Special base classes like Generic[...] get moved here during semantic analysis removed_base_type_exprs = None # type: List[Expression] info = None # type: TypeInfo # Related TypeInfo diff --git a/mypy/test/testfinegrained.py b/mypy/test/testfinegrained.py index 464f2d837e5c3..c22fb7af29e96 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=9000) out = cast(str, response['out'] or response['err']) return out.splitlines() diff --git a/mypy/util.py b/mypy/util.py index 026c4af747ee6..6268a2c621e27 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -450,10 +450,8 @@ class FancyFormatter: This currently only works on Linux and Mac. """ - def __init__(self, f_out: IO[str], f_err: IO[str], - show_error_codes: bool, pretty: 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 - self.pretty = pretty # Check if we are in a human-facing terminal on a supported platform. if sys.platform not in ('linux', 'darwin'): self.dummy_term = True @@ -511,7 +509,29 @@ def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'non start += self.DIM return start + self.colors[color] + text + self.NORMAL - def colorize(self, error: str, fixed_terminal_width: Optional[int] = None) -> str: + def fit_in_terminal(self, messages: List[str], + fixed_terminal_width: Optional[int] = None) -> None: + """Improve readability by wrapping lines when showing source code.""" + width = fixed_terminal_width or get_terminal_width() + for i, error in enumerate(messages.copy()): + if ': error:' in error: + loc, msg = error.split('error:', maxsplit=1) + msg = soft_wrap(msg, width, first_offset=len(loc) + len('error: ')) + messages[i] = loc + 'error:' + msg + if not error.startswith(' ' * DEFAULT_SOURCE_OFFSET): + # TODO: detecting source code highlights through an indent can be surprising. + continue + if '^' not in error: + # Let source have some space also on the right side. + width -= DEFAULT_SOURCE_OFFSET + column = messages[i+1][:DEFAULT_SOURCE_OFFSET].index('^') + source_line, offset = trim_source_line(error[:DEFAULT_SOURCE_OFFSET], + width, column, MINIMUM_WIDTH) + messages[i] = source_line + # Also adjust the error marker position. + messages[i+1] = messages[i+1][offset:] + + def colorize(self, error: str) -> str: """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() @@ -519,11 +539,6 @@ def colorize(self, error: str, fixed_terminal_width: Optional[int] = None) -> st """ if ': error:' in error: loc, msg = error.split('error:', maxsplit=1) - if self.pretty: - # Improve readability by wrapping lines when showing source code. - max_len = (fixed_terminal_width or get_terminal_width()) - # -1 to compensate for space after 'error:' - msg = soft_wrap(msg, max_len - 1, first_offset=len(loc) + len('error: ')) if not self.show_error_codes: return (loc + self.style('error:', 'red', bold=True) + self.highlight_quote_groups(msg)) @@ -535,7 +550,7 @@ def colorize(self, error: str, fixed_terminal_width: Optional[int] = None) -> st elif ': note:' in error: loc, msg = error.split('note:', maxsplit=1) return loc + self.style('note:', 'blue') + self.underline_link(msg) - elif self.pretty and error.startswith(' ' * DEFAULT_SOURCE_OFFSET): + 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) From 4334320f533f6048b0447401109b637fc9119a7b Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 3 Sep 2019 16:40:21 +0100 Subject: [PATCH 14/18] Don't mutate message lists in-place --- mypy/dmypy_server.py | 7 +++++-- mypy/main.py | 4 ++-- mypy/util.py | 21 +++++++++++---------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/mypy/dmypy_server.py b/mypy/dmypy_server.py index 0d671949e9210..963355dc01e7f 100644 --- a/mypy/dmypy_server.py +++ b/mypy/dmypy_server.py @@ -191,8 +191,7 @@ def __init__(self, options: Options, # Since the object is created in the parent process we can check # the output terminal options here. - self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.show_error_codes, - options.pretty) + self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.show_error_codes) def _response_metadata(self) -> Dict[str, str]: py_version = '{}_{}'.format(self.options.python_version[0], self.options.python_version[1]) @@ -498,6 +497,10 @@ def fine_grained_increment(self, def pretty_messages(self, messages: List[str], n_sources: int, 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/main.py b/mypy/main.py index fa89de73786bf..2dd2a064594f1 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -66,10 +66,10 @@ 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 - if options.pretty: - formatter.fit_in_terminal(messages) try: for msg in new_messages: if options.color_output: diff --git a/mypy/util.py b/mypy/util.py index 6268a2c621e27..c6f32d68cfbc7 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -510,26 +510,27 @@ def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'non return start + self.colors[color] + text + self.NORMAL def fit_in_terminal(self, messages: List[str], - fixed_terminal_width: Optional[int] = None) -> None: - """Improve readability by wrapping lines when showing source code.""" + 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() - for i, error in enumerate(messages.copy()): + 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: ')) - messages[i] = loc + 'error:' + msg + new_messages[i] = loc + 'error:' + msg if not error.startswith(' ' * DEFAULT_SOURCE_OFFSET): # TODO: detecting source code highlights through an indent can be surprising. continue if '^' not in error: + column = messages[i+1].index('^') - DEFAULT_SOURCE_OFFSET # Let source have some space also on the right side. - width -= DEFAULT_SOURCE_OFFSET - column = messages[i+1][:DEFAULT_SOURCE_OFFSET].index('^') - source_line, offset = trim_source_line(error[:DEFAULT_SOURCE_OFFSET], - width, column, MINIMUM_WIDTH) - messages[i] = source_line + source_line, offset = trim_source_line(error, width - DEFAULT_SOURCE_OFFSET, + column, MINIMUM_WIDTH) + new_messages[i] = source_line # Also adjust the error marker position. - messages[i+1] = messages[i+1][offset:] + new_messages[i+1] = messages[i+1][offset:] + return new_messages def colorize(self, error: str) -> str: """Colorize an output line by highlighting the status and error code. From 0c5b0244726a1d0f26bdaa840e5038b2efd43e4e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 3 Sep 2019 16:59:17 +0100 Subject: [PATCH 15/18] Update tests --- mypy/test/testfinegrained.py | 2 +- mypy/util.py | 2 +- test-data/unit/cmdline.test | 53 ++++++----------------- test-data/unit/fine-grained-blockers.test | 10 ++--- test-data/unit/fine-grained.test | 6 +-- 5 files changed, 23 insertions(+), 50 deletions(-) diff --git a/mypy/test/testfinegrained.py b/mypy/test/testfinegrained.py index c22fb7af29e96..736cfe000bb00 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, terminal_width=9000) + 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/util.py b/mypy/util.py index c6f32d68cfbc7..85adac3e0d1c0 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -435,7 +435,7 @@ def soft_wrap(msg: str, max_len: int, first_offset: int, 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: + if len(next_line) + len(next_word) + 1 <= max_line_len: next_line += ' ' + next_word else: lines.append(next_line) diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 184a9d302267e..70806253d8704 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1388,33 +1388,8 @@ Found 2 errors in 2 files (checked 2 source files) mypy: can't read file 'missing.py': No such file or directory == Return code: 2 -[case testShowSourceCodeSnippetsDefaultFormatting] -# cmd: mypy --show-source-code --no-color-output --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' - ^~~~~~~~ [operator] -some_file.py:11: error: Incompatible types in assignment (expression has type "AnotherCustomClassDefinedBelow", variable has type "OneCustomClassName") - self.very_important_attribute_with_long_name: OneCustomClassName = OneCu... - ^~~~~~~~ [assignment] -some_file.py:11: error: Argument 1 to "some_interesting_method" of "OneCustomClassName" has incompatible type "Union[int, str, float]"; expected "AnotherCustomClassDefinedBelow" - ...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassName()... - ^~~~~~~~ [arg-type] - [case testShowSourceCodeSnippetsWrappedFormatting] -# cmd: mypy --show-source-code --python-version=3.6 some_file.py +# cmd: mypy --pretty --python-version=3.6 some_file.py [file some_file.py] from typing import Union @@ -1430,25 +1405,23 @@ class AnotherCustomClassDefinedBelow: [out] some_file.py:3: error: Unsupported operand types for + ("int" and "str") 42 + 'no way' - ^~~~~~~~ [operator] + ^ some_file.py:11: error: Incompatible types in assignment (expression has type - "AnotherCustomClassDefinedBelow", variable has type - "OneCustomClassName") - self.very_important_attribute_with_long_name: OneCustomClassName = OneCu... - ^~~~~~~~ [assignment] +"AnotherCustomClassDefinedBelow", variable has type "OneCustomClassName") + self.very_important_attribute_with_long_name: OneCustomClassName... + ^ some_file.py:11: error: Argument 1 to "some_interesting_method" of - "OneCustomClassName" has incompatible type - "Union[int, str, float]"; expected - "AnotherCustomClassDefinedBelow" - ...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassName()... - ^~~~~~~~ [arg-type] +"OneCustomClassName" has incompatible type "Union[int, str, float]"; expected +"AnotherCustomClassDefinedBelow" +...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassNa... + ^ [case testShowSourceCodeSnippetsBlockingError] -# cmd: mypy --show-source-code some_file.py +# 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 - it_looks_like_we_started_typing_something_but_then. = did_not_notice(an_extra_do... - ^~~~~~~~ [syntax] +some_file.py:1: error: invalid syntax [syntax] + it_looks_like_we_started_typing_something_but_then. = did_not_notice(an_... + ^ == Return code: 2 diff --git a/test-data/unit/fine-grained-blockers.test b/test-data/unit/fine-grained-blockers.test index 90b1211c705a2..25587dd5671c1 100644 --- a/test-data/unit/fine-grained-blockers.test +++ b/test-data/unit/fine-grained-blockers.test @@ -25,7 +25,7 @@ main:2: error: Too few arguments for "f" == [case testParseErrorShowSource] -# flags: --show-source-code +# flags: --pretty --show-error-codes import a a.f() [file a.py] @@ -38,13 +38,13 @@ def f(x: int) -> None: pass def f() -> None: pass [out] == -a.py:1: error: invalid syntax +a.py:1: error: invalid syntax [syntax] def f(x: int) -> - ^~~~~~~~ [syntax] + ^ == -main:3: error: Too few arguments for "f" +main:3: error: Too few arguments for "f" [call-arg] a.f() - ^~~~~~~~ [call-arg] + ^ == [case testParseErrorMultipleTimes] diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index a25b5a373bf60..ad2cba3dbf153 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -82,7 +82,7 @@ class A: main:4: error: Too few arguments for "g" of "A" [case testReprocessMethodShowSource] -# flags: --show-source-code +# flags: --pretty --show-error-codes import m class B: def f(self, a: m.A) -> None: @@ -95,9 +95,9 @@ class A: def g(self, a: A) -> None: pass [out] == -main:5: error: Too few arguments for "g" of "A" +main:5: error: Too few arguments for "g" of "A" [call-arg] a.g() # E - ^~~~~~~~ [call-arg] + ^ [case testFunctionMissingModuleAttribute] import m From d898e8b62ed603d333569d484a5b3e6598eb2e2f Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 3 Sep 2019 19:28:53 +0100 Subject: [PATCH 16/18] Fix couple of-by-one errors; add tests and comments --- mypy/nodes.py | 2 +- mypy/test/testformatter.py | 51 +++++++++++++++++++++++++++++++++++++ mypy/util.py | 22 +++++++++++++--- test-data/unit/cmdline.test | 4 +-- 4 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 mypy/test/testformatter.py diff --git a/mypy/nodes.py b/mypy/nodes.py index 92d5d5dd7097d..79343f4ad2123 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -895,7 +895,7 @@ class ClassDef(Statement): defs = None # type: Block type_vars = None # type: List[mypy.types.TypeVarDef] # Base class expressions (not semantically analyzed -- can be arbitrary expressions) - base_type_exprs = None # type: List[Node] + base_type_exprs = None # type: List[Expression] # Special base classes like Generic[...] get moved here during semantic analysis removed_base_type_exprs = None # type: List[Expression] info = None # type: TypeInfo # Related TypeInfo diff --git a/mypy/test/testformatter.py b/mypy/test/testformatter.py new file mode 100644 index 0000000000000..623c7a62753fd --- /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 85adac3e0d1c0..c5a43aa6c9880 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -155,12 +155,26 @@ def trim_source_line(line: str, max_len: int, col: int, min_width: int) -> Tuple Return the trimmed string and the column offset to to adjust error location. """ - if len(line) < max_len: + 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 col < max_len - min_width: + + # 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 - offset = col - max_len + min_width - return '...' + line[offset:col + min_width] + '...', offset - 3 + + # 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]]: diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 70806253d8704..3e93c1d457ba9 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1413,8 +1413,8 @@ some_file.py:11: error: Incompatible types in assignment (expression has type some_file.py:11: error: Argument 1 to "some_interesting_method" of "OneCustomClassName" has incompatible type "Union[int, str, float]"; expected "AnotherCustomClassDefinedBelow" -...ry_important_attribute_with_long_name: OneCustomClassName = OneCustomClassNa... - ^ +...y_important_attribute_with_long_name: OneCustomClassName = OneCustomClassNam... + ^ [case testShowSourceCodeSnippetsBlockingError] # cmd: mypy --pretty --show-error-codes some_file.py From ade9a38d8b2780514bff7e7e5e4b992427b3b74a Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 3 Sep 2019 21:25:01 +0100 Subject: [PATCH 17/18] Small tweaks --- mypy/dmypy_server.py | 2 +- mypy/errors.py | 11 +++++++---- mypy/util.py | 21 ++++++++++++--------- test-data/unit/cmdline.test | 10 +++++----- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/mypy/dmypy_server.py b/mypy/dmypy_server.py index 963355dc01e7f..3a3f43ec137ea 100644 --- a/mypy/dmypy_server.py +++ b/mypy/dmypy_server.py @@ -375,7 +375,7 @@ def check(self, sources: List[BuildSource], """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). Also path the terminal_width to formatter. + (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, terminal_width) diff --git a/mypy/errors.py b/mypy/errors.py index c3ff5b34ccc4c..77cf3fabbc424 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -416,7 +416,7 @@ 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. If add_snippets + 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'). """ @@ -443,17 +443,20 @@ def format_messages(self, error_info: List[ErrorInfo], 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_lines[line - 1]) + a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line) a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + '^') return a def file_messages(self, path: str) -> List[str]: """Return a string list of new error messages from a given file. - Use a form suitable for displaying to the user. If add_snippets is True, - then one must also pass the source code and Python version. + Use a form suitable for displaying to the user. """ if path not in self.error_info_map: return [] diff --git a/mypy/util.py b/mypy/util.py index c5a43aa6c9880..14306a39d369d 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -148,7 +148,7 @@ def read_py_file(path: str, read: Callable[[str], bytes], 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' of characters around 'col' (an error location). If either + 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... @@ -533,17 +533,20 @@ def fit_in_terminal(self, messages: List[str], 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 not error.startswith(' ' * DEFAULT_SOURCE_OFFSET): + if error.startswith(' ' * DEFAULT_SOURCE_OFFSET) and '^' not in error: # TODO: detecting source code highlights through an indent can be surprising. - continue - if '^' not in error: + # 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. - source_line, offset = trim_source_line(error, width - DEFAULT_SOURCE_OFFSET, - column, MINIMUM_WIDTH) - new_messages[i] = source_line + + # 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] = messages[i+1][offset:] + new_messages[i+1] = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^' return new_messages def colorize(self, error: str) -> str: diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 3e93c1d457ba9..e303f992344a0 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1408,13 +1408,13 @@ some_file.py:3: error: Unsupported operand types for + ("int" and "str") ^ some_file.py:11: error: Incompatible types in assignment (expression has type "AnotherCustomClassDefinedBelow", variable has type "OneCustomClassName") - self.very_important_attribute_with_long_name: 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" -...y_important_attribute_with_long_name: OneCustomClassName = OneCustomClassNam... - ^ + ...t_attribute_with_long_name: OneCustomClassName = OneCustomClassName().... + ^ [case testShowSourceCodeSnippetsBlockingError] # cmd: mypy --pretty --show-error-codes some_file.py @@ -1422,6 +1422,6 @@ some_file.py:11: error: Argument 1 to "some_interesting_method" of it_looks_like_we_started_typing_something_but_then. = did_not_notice(an_extra_dot) [out] some_file.py:1: error: invalid syntax [syntax] - it_looks_like_we_started_typing_something_but_then. = did_not_notice(an_... - ^ + ...ooks_like_we_started_typing_something_but_then. = did_not_notice(an_ex... + ^ == Return code: 2 From 89e24dc4e4b065b3bada88b15a712314e8f853cd Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 4 Sep 2019 15:46:40 +0100 Subject: [PATCH 18/18] Add an end-to-end daemon test for --pretty --- test-data/unit/daemon.test | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test-data/unit/daemon.test b/test-data/unit/daemon.test index 70ce344bc175a..d993901d5828c 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