Skip to content

Commit 7b83c35

Browse files
authored
Colorize daemon output and add summary line (#7441)
Fixes #7438 This PR makes the daemon behave the same way as the normal run (i.e. use colorized output and write a summary line). I also fix a minor bug that the summary line was colorized even if `--no-color-output` was passed. The idea is quite simple, client passes to the server info about whether we are in tty and if yes server returns nicely formatted output. I tried running various commands on Linux and piping it through `grep`.
1 parent de6b291 commit 7b83c35

File tree

6 files changed

+85
-27
lines changed

6 files changed

+85
-27
lines changed

mypy/dmypy/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,9 @@ def request(status_file: str, command: str, *, timeout: Optional[int] = None,
466466
response = {} # type: Dict[str, str]
467467
args = dict(kwds)
468468
args['command'] = command
469+
# Tell the server whether this request was initiated from a human-facing terminal,
470+
# so that it can format the type checking output accordingly.
471+
args['is_tty'] = sys.stdout.isatty()
469472
bdata = json.dumps(args).encode('utf8')
470473
_, name = get_status(status_file)
471474
try:

mypy/dmypy_server.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import traceback
1717
from contextlib import redirect_stderr, redirect_stdout
1818

19-
from typing import AbstractSet, Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
19+
from typing import AbstractSet, Any, Callable, Dict, List, Optional, Sequence, Tuple
2020
from typing_extensions import Final
2121

2222
import mypy.build
@@ -33,6 +33,7 @@
3333
from mypy.suggestions import SuggestionFailure, SuggestionEngine
3434
from mypy.typestate import reset_global_state
3535
from mypy.version import __version__
36+
from mypy.util import FancyFormatter, count_stats
3637

3738
MEM_PROFILE = False # type: Final # If True, dump memory profile after initialization
3839

@@ -188,6 +189,10 @@ def __init__(self, options: Options,
188189
options.local_partial_types = True
189190
self.status_file = status_file
190191

192+
# Since the object is created in the parent process we can check
193+
# the output terminal options here.
194+
self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.show_error_codes)
195+
191196
def _response_metadata(self) -> Dict[str, str]:
192197
py_version = '{}_{}'.format(self.options.python_version[0], self.options.python_version[1])
193198
return {
@@ -248,13 +253,18 @@ def serve(self) -> None:
248253
if exc_info[0] and exc_info[0] is not SystemExit:
249254
traceback.print_exception(*exc_info)
250255

251-
def run_command(self, command: str, data: Mapping[str, object]) -> Dict[str, object]:
256+
def run_command(self, command: str, data: Dict[str, object]) -> Dict[str, object]:
252257
"""Run a specific command from the registry."""
253258
key = 'cmd_' + command
254259
method = getattr(self.__class__, key, None)
255260
if method is None:
256261
return {'error': "Unrecognized command '%s'" % command}
257262
else:
263+
if command not in {'check', 'recheck', 'run'}:
264+
# Only the above commands use some error formatting.
265+
del data['is_tty']
266+
elif int(os.getenv('MYPY_FORCE_COLOR', '0')):
267+
data['is_tty'] = True
258268
return method(self, **data)
259269

260270
# Command functions (run in the server via RPC).
@@ -280,7 +290,7 @@ def cmd_stop(self) -> Dict[str, object]:
280290
os.unlink(self.status_file)
281291
return {}
282292

283-
def cmd_run(self, version: str, args: Sequence[str]) -> Dict[str, object]:
293+
def cmd_run(self, version: str, args: Sequence[str], is_tty: bool) -> Dict[str, object]:
284294
"""Check a list of files, triggering a restart if needed."""
285295
try:
286296
# Process options can exit on improper arguments, so we need to catch that and
@@ -313,17 +323,18 @@ def cmd_run(self, version: str, args: Sequence[str]) -> Dict[str, object]:
313323
return {'out': '', 'err': str(err), 'status': 2}
314324
except SystemExit as e:
315325
return {'out': stdout.getvalue(), 'err': stderr.getvalue(), 'status': e.code}
316-
return self.check(sources)
326+
return self.check(sources, is_tty)
317327

318-
def cmd_check(self, files: Sequence[str]) -> Dict[str, object]:
328+
def cmd_check(self, files: Sequence[str], is_tty: bool) -> Dict[str, object]:
319329
"""Check a list of files."""
320330
try:
321331
sources = create_source_list(files, self.options, self.fscache)
322332
except InvalidSourceList as err:
323333
return {'out': '', 'err': str(err), 'status': 2}
324-
return self.check(sources)
334+
return self.check(sources, is_tty)
325335

326336
def cmd_recheck(self,
337+
is_tty: bool,
327338
remove: Optional[List[str]] = None,
328339
update: Optional[List[str]] = None) -> Dict[str, object]:
329340
"""Check the same list of files we checked most recently.
@@ -349,17 +360,21 @@ def cmd_recheck(self,
349360
t1 = time.time()
350361
manager = self.fine_grained_manager.manager
351362
manager.log("fine-grained increment: cmd_recheck: {:.3f}s".format(t1 - t0))
352-
res = self.fine_grained_increment(sources, remove, update)
363+
res = self.fine_grained_increment(sources, is_tty, remove, update)
353364
self.fscache.flush()
354365
self.update_stats(res)
355366
return res
356367

357-
def check(self, sources: List[BuildSource]) -> Dict[str, Any]:
358-
"""Check using fine-grained incremental mode."""
368+
def check(self, sources: List[BuildSource], is_tty: bool) -> Dict[str, Any]:
369+
"""Check using fine-grained incremental mode.
370+
371+
If is_tty is True format the output nicely with colors and summary line
372+
(unless disabled in self.options).
373+
"""
359374
if not self.fine_grained_manager:
360-
res = self.initialize_fine_grained(sources)
375+
res = self.initialize_fine_grained(sources, is_tty)
361376
else:
362-
res = self.fine_grained_increment(sources)
377+
res = self.fine_grained_increment(sources, is_tty)
363378
self.fscache.flush()
364379
self.update_stats(res)
365380
return res
@@ -371,7 +386,8 @@ def update_stats(self, res: Dict[str, Any]) -> None:
371386
res['stats'] = manager.stats
372387
manager.stats = {}
373388

374-
def initialize_fine_grained(self, sources: List[BuildSource]) -> Dict[str, Any]:
389+
def initialize_fine_grained(self, sources: List[BuildSource],
390+
is_tty: bool) -> Dict[str, Any]:
375391
self.fswatcher = FileSystemWatcher(self.fscache)
376392
t0 = time.time()
377393
self.update_sources(sources)
@@ -433,10 +449,12 @@ def initialize_fine_grained(self, sources: List[BuildSource]) -> Dict[str, Any]:
433449
print_memory_profile(run_gc=False)
434450

435451
status = 1 if messages else 0
452+
messages = self.pretty_messages(messages, len(sources), is_tty)
436453
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
437454

438455
def fine_grained_increment(self,
439456
sources: List[BuildSource],
457+
is_tty: bool,
440458
remove: Optional[List[str]] = None,
441459
update: Optional[List[str]] = None,
442460
) -> Dict[str, Any]:
@@ -466,8 +484,28 @@ def fine_grained_increment(self,
466484

467485
status = 1 if messages else 0
468486
self.previous_sources = sources
487+
messages = self.pretty_messages(messages, len(sources), is_tty)
469488
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
470489

490+
def pretty_messages(self, messages: List[str], n_sources: int,
491+
is_tty: bool = False) -> List[str]:
492+
use_color = self.options.color_output and is_tty
493+
if self.options.error_summary:
494+
summary = None # type: Optional[str]
495+
if messages:
496+
n_errors, n_files = count_stats(messages)
497+
if n_errors:
498+
summary = self.formatter.format_error(n_errors, n_files, n_sources,
499+
use_color)
500+
else:
501+
summary = self.formatter.format_success(n_sources, use_color)
502+
if summary:
503+
# Create new list to avoid appending multiple summaries on successive runs.
504+
messages = messages + [summary]
505+
if use_color:
506+
messages = [self.formatter.colorize(m) for m in messages]
507+
return messages
508+
471509
def update_sources(self, sources: List[BuildSource]) -> None:
472510
paths = [source.path for source in sources if source.path is not None]
473511
self.fswatcher.add_watched_paths(paths)

mypy/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ def flush_errors(new_messages: List[str], serious: bool) -> None:
112112
if messages:
113113
n_errors, n_files = util.count_stats(messages)
114114
if n_errors:
115-
stdout.write(formatter.format_error(n_errors, n_files, len(sources)) + '\n')
115+
stdout.write(formatter.format_error(n_errors, n_files, len(sources),
116+
options.color_output) + '\n')
116117
else:
117-
stdout.write(formatter.format_success(len(sources)) + '\n')
118+
stdout.write(formatter.format_success(len(sources),
119+
options.color_output) + '\n')
118120
stdout.flush()
119121
if options.fast_exit:
120122
# Exit without freeing objects -- it's faster.

mypy/test/testfinegrained.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ def get_options(self,
181181
options.incremental = True
182182
options.use_builtins_fixtures = True
183183
options.show_traceback = True
184+
options.error_summary = False
184185
options.fine_grained_incremental = not build_cache
185186
options.use_fine_grained_cache = self.use_cache and not build_cache
186187
options.cache_fine_grained = self.use_cache
@@ -196,7 +197,7 @@ def get_options(self,
196197
return options
197198

198199
def run_check(self, server: Server, sources: List[BuildSource]) -> List[str]:
199-
response = server.check(sources)
200+
response = server.check(sources, is_tty=False)
200201
out = cast(str, response['out'] or response['err'])
201202
return out.splitlines()
202203

mypy/util.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,19 @@ def underline_link(self, note: str) -> str:
427427
self.style(note[start:end], 'none', underline=True) +
428428
note[end:])
429429

430-
def format_success(self, n_sources: int) -> str:
431-
return self.style('Success: no issues found in {}'
432-
' source file{}'.format(n_sources, 's' if n_sources != 1 else ''),
433-
'green', bold=True)
430+
def format_success(self, n_sources: int, use_color: bool = True) -> str:
431+
msg = 'Success: no issues found in {}' \
432+
' source file{}'.format(n_sources, 's' if n_sources != 1 else '')
433+
if not use_color:
434+
return msg
435+
return self.style(msg, 'green', bold=True)
434436

435-
def format_error(self, n_errors: int, n_files: int, n_sources: int) -> str:
437+
def format_error(self, n_errors: int, n_files: int, n_sources: int,
438+
use_color: bool = True) -> str:
436439
msg = 'Found {} error{} in {} file{}' \
437440
' (checked {} source file{})'.format(n_errors, 's' if n_errors != 1 else '',
438441
n_files, 's' if n_files != 1 else '',
439442
n_sources, 's' if n_sources != 1 else '')
443+
if not use_color:
444+
return msg
440445
return self.style(msg, 'red', bold=True)

test-data/unit/daemon.test

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ Daemon stopped
1111
$ dmypy start -- --follow-imports=error
1212
Daemon started
1313
$ dmypy check -- foo.py
14+
Success: no issues found in 1 source file
1415
$ dmypy recheck
16+
Success: no issues found in 1 source file
1517
$ dmypy stop
1618
Daemon stopped
1719
[file foo.py]
@@ -20,6 +22,7 @@ def f(): pass
2022
[case testDaemonRun]
2123
$ dmypy run -- foo.py --follow-imports=error
2224
Daemon started
25+
Success: no issues found in 1 source file
2326
$ dmypy stop
2427
Daemon stopped
2528
[file foo.py]
@@ -28,7 +31,9 @@ def f(): pass
2831
[case testDaemonRunRestart]
2932
$ dmypy run -- foo.py --follow-imports=error
3033
Daemon started
34+
Success: no issues found in 1 source file
3135
$ dmypy run -- foo.py --follow-imports=error
36+
Success: no issues found in 1 source file
3237
$ {python} -c "print('[mypy]')" >mypy.ini
3338
$ {python} -c "print('disallow_untyped_defs = True')" >>mypy.ini
3439
$ dmypy run -- foo.py --follow-imports=error
@@ -37,19 +42,21 @@ Daemon stopped
3742
Daemon started
3843
foo.py:1: error: Function is missing a return type annotation
3944
foo.py:1: note: Use "-> None" if function does not return a value
45+
Found 1 error in 1 file (checked 1 source file)
4046
== Return code: 1
4147
$ {python} -c "print('def f() -> None: pass')" >foo.py
4248
$ dmypy run -- foo.py --follow-imports=error
49+
Success: no issues found in 1 source file
4350
$ dmypy stop
4451
Daemon stopped
4552
[file foo.py]
4653
def f(): pass
4754

4855
[case testDaemonRunRestartPluginVersion]
49-
$ dmypy run -- foo.py
56+
$ dmypy run -- foo.py --no-error-summary
5057
Daemon started
5158
$ {python} -c "print(' ')" >> plug.py
52-
$ dmypy run -- foo.py
59+
$ dmypy run -- foo.py --no-error-summary
5360
Restarting: plugins changed
5461
Daemon stopped
5562
Daemon started
@@ -79,22 +86,22 @@ No status file found
7986
$ dmypy recheck
8087
No status file found
8188
== Return code: 2
82-
$ dmypy start -- --follow-imports=error
89+
$ dmypy start -- --follow-imports=error --no-error-summary
8390
Daemon started
8491
$ dmypy status
8592
Daemon is up and running
8693
$ dmypy start
8794
Daemon is still alive
8895
== Return code: 2
89-
$ dmypy restart -- --follow-imports=error
96+
$ dmypy restart -- --follow-imports=error --no-error-summary
9097
Daemon stopped
9198
Daemon started
9299
$ dmypy stop
93100
Daemon stopped
94101
$ dmypy status
95102
No status file found
96103
== Return code: 2
97-
$ dmypy restart -- --follow-imports=error
104+
$ dmypy restart -- --follow-imports=error --no-error-summary
98105
Daemon started
99106
$ dmypy recheck
100107
Command 'recheck' is only valid after a 'check' command
@@ -106,7 +113,7 @@ Daemon has died
106113
== Return code: 2
107114

108115
[case testDaemonRecheck]
109-
$ dmypy start -- --follow-imports=error
116+
$ dmypy start -- --follow-imports=error --no-error-summary
110117
Daemon started
111118
$ dmypy check foo.py bar.py
112119
$ dmypy recheck
@@ -153,6 +160,7 @@ $ {python} -c "import time;time.sleep(1)"
153160
$ {python} -c "print('x=1')" >bar.py
154161
$ dmypy run -- foo.py bar.py --follow-imports=error --use-fine-grained-cache --no-sqlite-cache --python-version=3.6
155162
Daemon started
163+
Success: no issues found in 2 source files
156164
$ dmypy status --fswatcher-dump-file test.json
157165
Daemon is up and running
158166
$ dmypy stop
@@ -165,6 +173,7 @@ $ {python} -c "print('lol')" >foo.py
165173
$ dmypy run --log-file=log -- foo.py bar.py --follow-imports=error --use-fine-grained-cache --no-sqlite-cache --python-version=3.6 --quickstart-file test.json
166174
Daemon started
167175
foo.py:1: error: Name 'lol' is not defined
176+
Found 1 error in 1 file (checked 2 source files)
168177
== Return code: 1
169178
-- make sure no errors made it to the log file
170179
$ {python} -c "import sys; sys.stdout.write(open('log').read())"
@@ -173,7 +182,7 @@ $ {python} -c "import sys; sys.stdout.write(open('log').read())"
173182
$ {python} -c "x = open('.mypy_cache/3.6/bar.meta.json').read(); y = open('asdf.json').read(); assert x == y"
174183

175184
[case testDaemonSuggest]
176-
$ dmypy start --log-file log.txt -- --follow-imports=error
185+
$ dmypy start --log-file log.txt -- --follow-imports=error --no-error-summary
177186
Daemon started
178187
$ dmypy suggest foo:foo
179188
Command 'suggest' is only valid after a 'check' command

0 commit comments

Comments
 (0)