Skip to content

Commit 91adf61

Browse files
authored
Show code snippets on demand (#7440)
* Start working on source code snippets * Make some cleanup * Add couple tests * Undo the dedicated error; update self-check to see these columns * Only do wrapping when showing source * Minor fixes * Fix tests on Python 3.5 * Support also blocking errors and daemon * Add couple tests * Fix bug * Address some CR * More CR * Separate fit in terminal from colorizing * Don't mutate message lists in-place * Update tests * Fix couple of-by-one errors; add tests and comments * Small tweaks * Add an end-to-end daemon test for --pretty
1 parent bb7b5cd commit 91adf61

14 files changed

+427
-31
lines changed

mypy/build.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
from mypy.indirection import TypeIndirectionVisitor
3737
from mypy.errors import Errors, CompileError, ErrorInfo, report_internal_error
3838
from mypy.util import (
39-
DecodeError, decode_python_encoding, is_sub_path, get_mypy_comments, module_prefix
39+
DecodeError, decode_python_encoding, is_sub_path, get_mypy_comments, module_prefix,
40+
read_py_file
4041
)
4142
if TYPE_CHECKING:
4243
from mypy.report import Reports # Avoid unconditional slow import
@@ -197,9 +198,12 @@ def _build(sources: List[BuildSource],
197198
reports = Reports(data_dir, options.report_dirs)
198199

199200
source_set = BuildSourceSet(sources)
201+
cached_read = fscache.read
200202
errors = Errors(options.show_error_context,
201203
options.show_column_numbers,
202-
options.show_error_codes)
204+
options.show_error_codes,
205+
options.pretty,
206+
lambda path: read_py_file(path, cached_read, options.python_version))
203207
plugin, snapshot = load_plugins(options, errors, stdout)
204208

205209
# Construct a build manager object to hold state during the build.

mypy/dmypy/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from mypy.dmypy_util import DEFAULT_STATUS_FILE, receive
1919
from mypy.ipc import IPCClient, IPCException
2020
from mypy.dmypy_os import alive, kill
21-
from mypy.util import check_python_version
21+
from mypy.util import check_python_version, get_terminal_width
2222

2323
from mypy.version import __version__
2424

@@ -469,6 +469,7 @@ def request(status_file: str, command: str, *, timeout: Optional[int] = None,
469469
# Tell the server whether this request was initiated from a human-facing terminal,
470470
# so that it can format the type checking output accordingly.
471471
args['is_tty'] = sys.stdout.isatty()
472+
args['terminal_width'] = get_terminal_width()
472473
bdata = json.dumps(args).encode('utf8')
473474
_, name = get_status(status_file)
474475
try:

mypy/dmypy_server.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ def run_command(self, command: str, data: Dict[str, object]) -> Dict[str, object
263263
if command not in {'check', 'recheck', 'run'}:
264264
# Only the above commands use some error formatting.
265265
del data['is_tty']
266+
del data['terminal_width']
266267
elif int(os.getenv('MYPY_FORCE_COLOR', '0')):
267268
data['is_tty'] = True
268269
return method(self, **data)
@@ -290,7 +291,8 @@ def cmd_stop(self) -> Dict[str, object]:
290291
os.unlink(self.status_file)
291292
return {}
292293

293-
def cmd_run(self, version: str, args: Sequence[str], is_tty: bool) -> Dict[str, object]:
294+
def cmd_run(self, version: str, args: Sequence[str],
295+
is_tty: bool, terminal_width: int) -> Dict[str, object]:
294296
"""Check a list of files, triggering a restart if needed."""
295297
try:
296298
# Process options can exit on improper arguments, so we need to catch that and
@@ -323,18 +325,20 @@ def cmd_run(self, version: str, args: Sequence[str], is_tty: bool) -> Dict[str,
323325
return {'out': '', 'err': str(err), 'status': 2}
324326
except SystemExit as e:
325327
return {'out': stdout.getvalue(), 'err': stderr.getvalue(), 'status': e.code}
326-
return self.check(sources, is_tty)
328+
return self.check(sources, is_tty, terminal_width)
327329

328-
def cmd_check(self, files: Sequence[str], is_tty: bool) -> Dict[str, object]:
330+
def cmd_check(self, files: Sequence[str],
331+
is_tty: bool, terminal_width: int) -> Dict[str, object]:
329332
"""Check a list of files."""
330333
try:
331334
sources = create_source_list(files, self.options, self.fscache)
332335
except InvalidSourceList as err:
333336
return {'out': '', 'err': str(err), 'status': 2}
334-
return self.check(sources, is_tty)
337+
return self.check(sources, is_tty, terminal_width)
335338

336339
def cmd_recheck(self,
337340
is_tty: bool,
341+
terminal_width: int,
338342
remove: Optional[List[str]] = None,
339343
update: Optional[List[str]] = None) -> Dict[str, object]:
340344
"""Check the same list of files we checked most recently.
@@ -360,21 +364,23 @@ def cmd_recheck(self,
360364
t1 = time.time()
361365
manager = self.fine_grained_manager.manager
362366
manager.log("fine-grained increment: cmd_recheck: {:.3f}s".format(t1 - t0))
363-
res = self.fine_grained_increment(sources, is_tty, remove, update)
367+
res = self.fine_grained_increment(sources, is_tty, terminal_width,
368+
remove, update)
364369
self.fscache.flush()
365370
self.update_stats(res)
366371
return res
367372

368-
def check(self, sources: List[BuildSource], is_tty: bool) -> Dict[str, Any]:
373+
def check(self, sources: List[BuildSource],
374+
is_tty: bool, terminal_width: int) -> Dict[str, Any]:
369375
"""Check using fine-grained incremental mode.
370376
371377
If is_tty is True format the output nicely with colors and summary line
372-
(unless disabled in self.options).
378+
(unless disabled in self.options). Also pass the terminal_width to formatter.
373379
"""
374380
if not self.fine_grained_manager:
375-
res = self.initialize_fine_grained(sources, is_tty)
381+
res = self.initialize_fine_grained(sources, is_tty, terminal_width)
376382
else:
377-
res = self.fine_grained_increment(sources, is_tty)
383+
res = self.fine_grained_increment(sources, is_tty, terminal_width)
378384
self.fscache.flush()
379385
self.update_stats(res)
380386
return res
@@ -387,7 +393,7 @@ def update_stats(self, res: Dict[str, Any]) -> None:
387393
manager.stats = {}
388394

389395
def initialize_fine_grained(self, sources: List[BuildSource],
390-
is_tty: bool) -> Dict[str, Any]:
396+
is_tty: bool, terminal_width: int) -> Dict[str, Any]:
391397
self.fswatcher = FileSystemWatcher(self.fscache)
392398
t0 = time.time()
393399
self.update_sources(sources)
@@ -449,12 +455,13 @@ def initialize_fine_grained(self, sources: List[BuildSource],
449455
print_memory_profile(run_gc=False)
450456

451457
status = 1 if messages else 0
452-
messages = self.pretty_messages(messages, len(sources), is_tty)
458+
messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width)
453459
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
454460

455461
def fine_grained_increment(self,
456462
sources: List[BuildSource],
457463
is_tty: bool,
464+
terminal_width: int,
458465
remove: Optional[List[str]] = None,
459466
update: Optional[List[str]] = None,
460467
) -> Dict[str, Any]:
@@ -484,12 +491,16 @@ def fine_grained_increment(self,
484491

485492
status = 1 if messages else 0
486493
self.previous_sources = sources
487-
messages = self.pretty_messages(messages, len(sources), is_tty)
494+
messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width)
488495
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
489496

490497
def pretty_messages(self, messages: List[str], n_sources: int,
491-
is_tty: bool = False) -> List[str]:
498+
is_tty: bool = False, terminal_width: Optional[int] = None) -> List[str]:
492499
use_color = self.options.color_output and is_tty
500+
fit_width = self.options.pretty and is_tty
501+
if fit_width:
502+
messages = self.formatter.fit_in_terminal(messages,
503+
fixed_terminal_width=terminal_width)
493504
if self.options.error_summary:
494505
summary = None # type: Optional[str]
495506
if messages:

mypy/errors.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import traceback
44
from collections import OrderedDict, defaultdict
55

6-
from typing import Tuple, List, TypeVar, Set, Dict, Optional, TextIO
6+
from typing import Tuple, List, TypeVar, Set, Dict, Optional, TextIO, Callable
77
from typing_extensions import Final
88

99
from mypy.scope import Scope
1010
from mypy.options import Options
1111
from mypy.version import __version__ as mypy_version
1212
from mypy.errorcodes import ErrorCode
1313
from mypy import errorcodes as codes
14+
from mypy.util import DEFAULT_SOURCE_OFFSET
1415

1516
T = TypeVar('T')
1617
allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final
@@ -156,10 +157,15 @@ class Errors:
156157
def __init__(self,
157158
show_error_context: bool = False,
158159
show_column_numbers: bool = False,
159-
show_error_codes: bool = False) -> None:
160+
show_error_codes: bool = False,
161+
pretty: bool = False,
162+
read_source: Optional[Callable[[str], Optional[List[str]]]] = None) -> None:
160163
self.show_error_context = show_error_context
161164
self.show_column_numbers = show_column_numbers
162165
self.show_error_codes = show_error_codes
166+
self.pretty = pretty
167+
# We use fscache to read source code when showing snippets.
168+
self.read_source = read_source
163169
self.initialize()
164170

165171
def initialize(self) -> None:
@@ -179,7 +185,11 @@ def reset(self) -> None:
179185
self.initialize()
180186

181187
def copy(self) -> 'Errors':
182-
new = Errors(self.show_error_context, self.show_column_numbers)
188+
new = Errors(self.show_error_context,
189+
self.show_column_numbers,
190+
self.show_error_codes,
191+
self.pretty,
192+
self.read_source)
183193
new.file = self.file
184194
new.import_ctx = self.import_ctx[:]
185195
new.type_name = self.type_name[:]
@@ -402,10 +412,13 @@ def raise_error(self) -> None:
402412
use_stdout=True,
403413
module_with_blocker=self.blocker_module())
404414

405-
def format_messages(self, error_info: List[ErrorInfo]) -> List[str]:
415+
def format_messages(self, error_info: List[ErrorInfo],
416+
source_lines: Optional[List[str]]) -> List[str]:
406417
"""Return a string list that represents the error messages.
407418
408-
Use a form suitable for displaying to the user.
419+
Use a form suitable for displaying to the user. If self.pretty
420+
is True also append a relevant trimmed source code line (only for
421+
severity 'error').
409422
"""
410423
a = [] # type: List[str]
411424
errors = self.render_messages(self.sort_messages(error_info))
@@ -427,6 +440,17 @@ def format_messages(self, error_info: List[ErrorInfo]) -> List[str]:
427440
# displaying duplicate error codes.
428441
s = '{} [{}]'.format(s, code.code)
429442
a.append(s)
443+
if self.pretty:
444+
# Add source code fragment and a location marker.
445+
if severity == 'error' and source_lines and line > 0:
446+
source_line = source_lines[line - 1]
447+
if column < 0:
448+
# Something went wrong, take first non-empty column.
449+
column = len(source_line) - len(source_line.lstrip())
450+
# Note, currently coloring uses the offset to detect source snippets,
451+
# so these offsets should not be arbitrary.
452+
a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line)
453+
a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + '^')
430454
return a
431455

432456
def file_messages(self, path: str) -> List[str]:
@@ -437,7 +461,11 @@ def file_messages(self, path: str) -> List[str]:
437461
if path not in self.error_info_map:
438462
return []
439463
self.flushed_files.add(path)
440-
return self.format_messages(self.error_info_map[path])
464+
source_lines = None
465+
if self.pretty:
466+
assert self.read_source
467+
source_lines = self.read_source(path)
468+
return self.format_messages(self.error_info_map[path], source_lines)
441469

442470
def new_messages(self) -> List[str]:
443471
"""Return a string list of new error messages.

mypy/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ def main(script_path: Optional[str],
6666
formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes)
6767

6868
def flush_errors(new_messages: List[str], serious: bool) -> None:
69+
if options.pretty:
70+
new_messages = formatter.fit_in_terminal(new_messages)
6971
messages.extend(new_messages)
7072
f = stderr if serious else stdout
7173
try:
@@ -582,6 +584,11 @@ def add_invertible_flag(flag: str,
582584
add_invertible_flag('--show-error-codes', default=False,
583585
help="Show error codes in error messages",
584586
group=error_group)
587+
add_invertible_flag('--pretty', default=False,
588+
help="Use visually nicer output in error messages:"
589+
" Use soft word wrap, show source code snippets,"
590+
" and error location markers",
591+
group=error_group)
585592
add_invertible_flag('--no-color-output', dest='color_output', default=True,
586593
help="Do not colorize error messages",
587594
group=error_group)

mypy/options.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ def __init__(self) -> None:
244244
self.shadow_file = None # type: Optional[List[List[str]]]
245245
self.show_column_numbers = False # type: bool
246246
self.show_error_codes = False
247+
# Use soft word wrap and show trimmed source snippets with error location markers.
248+
self.pretty = False
247249
self.dump_graph = False
248250
self.dump_deps = False
249251
self.logical_deps = False

mypy/test/testfinegrained.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def get_options(self,
197197
return options
198198

199199
def run_check(self, server: Server, sources: List[BuildSource]) -> List[str]:
200-
response = server.check(sources, is_tty=False)
200+
response = server.check(sources, is_tty=False, terminal_width=-1)
201201
out = cast(str, response['out'] or response['err'])
202202
return out.splitlines()
203203

mypy/test/testformatter.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from unittest import TestCase, main
2+
3+
from mypy.util import trim_source_line, split_words
4+
5+
6+
class FancyErrorFormattingTestCases(TestCase):
7+
def test_trim_source(self) -> None:
8+
assert trim_source_line('0123456789abcdef',
9+
max_len=16, col=5, min_width=2) == ('0123456789abcdef', 0)
10+
11+
# Locations near start.
12+
assert trim_source_line('0123456789abcdef',
13+
max_len=7, col=0, min_width=2) == ('0123456...', 0)
14+
assert trim_source_line('0123456789abcdef',
15+
max_len=7, col=4, min_width=2) == ('0123456...', 0)
16+
17+
# Middle locations.
18+
assert trim_source_line('0123456789abcdef',
19+
max_len=7, col=5, min_width=2) == ('...1234567...', -2)
20+
assert trim_source_line('0123456789abcdef',
21+
max_len=7, col=6, min_width=2) == ('...2345678...', -1)
22+
assert trim_source_line('0123456789abcdef',
23+
max_len=7, col=8, min_width=2) == ('...456789a...', 1)
24+
25+
# Locations near the end.
26+
assert trim_source_line('0123456789abcdef',
27+
max_len=7, col=11, min_width=2) == ('...789abcd...', 4)
28+
assert trim_source_line('0123456789abcdef',
29+
max_len=7, col=13, min_width=2) == ('...9abcdef', 6)
30+
assert trim_source_line('0123456789abcdef',
31+
max_len=7, col=15, min_width=2) == ('...9abcdef', 6)
32+
33+
def test_split_words(self) -> None:
34+
assert split_words('Simple message') == ['Simple', 'message']
35+
assert split_words('Message with "Some[Long, Types]"'
36+
' in it') == ['Message', 'with',
37+
'"Some[Long, Types]"', 'in', 'it']
38+
assert split_words('Message with "Some[Long, Types]"'
39+
' and [error-code]') == ['Message', 'with', '"Some[Long, Types]"',
40+
'and', '[error-code]']
41+
assert split_words('"Type[Stands, First]" then words') == ['"Type[Stands, First]"',
42+
'then', 'words']
43+
assert split_words('First words "Then[Stands, Type]"') == ['First', 'words',
44+
'"Then[Stands, Type]"']
45+
assert split_words('"Type[Only, Here]"') == ['"Type[Only, Here]"']
46+
assert split_words('OneWord') == ['OneWord']
47+
assert split_words(' ') == ['', '']
48+
49+
50+
if __name__ == '__main__':
51+
main()

0 commit comments

Comments
 (0)