Coverage for /usr/local/lib/python3.7/site-packages/pytest_sugar.py : 4%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# -*- coding: utf-8 -*-
2"""
3pytest_sugar
4~~~~~~~~~~~~
6py.test is a plugin for py.test that changes the default look
7and feel of py.test (e.g. progressbar, show tests that fail instantly).
9:copyright: see LICENSE for details
10:license: BSD, see LICENSE for more details.
11"""
12from __future__ import unicode_literals
13import locale
14import os
15import re
16import sys
17from packaging.version import parse
19try:
20 from configparser import ConfigParser
21except ImportError:
22 from ConfigParser import ConfigParser
24from termcolor import colored
26import py
27import pytest
28from _pytest.terminal import TerminalReporter
31__version__ = '0.9.2'
33LEN_RIGHT_MARGIN = 0
34LEN_PROGRESS_PERCENTAGE = 5
35LEN_PROGRESS_BAR_SETTING = '10'
36LEN_PROGRESS_BAR = None
37THEME = {
38 'header': 'magenta',
39 'skipped': 'blue',
40 'success': 'green',
41 'warning': 'yellow',
42 'fail': 'red',
43 'error': 'red',
44 'xfailed': 'green',
45 'xpassed': 'red',
46 'progressbar': 'green',
47 'progressbar_fail': 'red',
48 'progressbar_background': 'grey',
49 'path': 'cyan',
50 'name': None,
51 'symbol_passed': '✓',
52 'symbol_skipped': 's',
53 'symbol_failed': '⨯',
54 'symbol_failed_not_call': 'ₓ',
55 'symbol_xfailed_skipped': 'x',
56 'symbol_xfailed_failed': 'X',
57 'symbol_unknown': '?',
58 'unknown': 'blue',
59 'symbol_rerun': 'R',
60 'rerun': 'blue',
61}
62PROGRESS_BAR_BLOCKS = [
63 ' ', '▏', '▎', '▎', '▍', '▍', '▌', '▌', '▋', '▋', '▊', '▊', '▉', '▉', '█',
64]
67def flatten(l):
68 for x in l:
69 if isinstance(x, (list, tuple)):
70 for y in flatten(x):
71 yield y
72 else:
73 yield x
76def pytest_runtestloop(session):
77 reporter = session.config.pluginmanager.getplugin('terminalreporter')
78 if reporter:
79 reporter.tests_count = len(session.items)
82class DeferredXdistPlugin(object):
83 def pytest_xdist_node_collection_finished(self, node, ids):
84 terminal_reporter = node.config.pluginmanager.getplugin(
85 'terminalreporter'
86 )
87 if terminal_reporter:
88 terminal_reporter.tests_count = len(ids)
91def pytest_deselected(items):
92 """ Update tests_count to not include deselected tests """
93 if len(items) > 0:
94 pluginmanager = items[0].config.pluginmanager
95 terminal_reporter = pluginmanager.getplugin('terminalreporter')
96 if (hasattr(terminal_reporter, 'tests_count')
97 and terminal_reporter.tests_count > 0):
98 terminal_reporter.tests_count -= len(items)
101def pytest_addoption(parser):
102 group = parser.getgroup("terminal reporting", "reporting", after="general")
103 group._addoption(
104 '--old-summary', action="store_true",
105 dest="tb_summary", default=False,
106 help=(
107 "Show tests that failed instead of one-line tracebacks"
108 )
109 )
110 group._addoption(
111 '--force-sugar', action="store_true",
112 dest="force_sugar", default=False,
113 help=(
114 "Force pytest-sugar output even when not in real terminal"
115 )
116 )
119def pytest_sessionstart(session):
120 global LEN_PROGRESS_BAR_SETTING
121 config = ConfigParser()
122 config.read([
123 'pytest-sugar.conf',
124 os.path.expanduser('~/.pytest-sugar.conf')
125 ])
127 for key in THEME:
128 if not config.has_option('theme', key):
129 continue
131 value = config.get("theme", key)
132 value = value.lower()
133 if value in ('', 'none'):
134 value = None
136 THEME[key] = value
138 if config.has_option('sugar', 'progressbar_length'):
139 LEN_PROGRESS_BAR_SETTING = config.get('sugar', 'progressbar_length')
142def strip_colors(text):
143 ansi_escape = re.compile(r'\x1b[^m]*m')
144 stripped = ansi_escape.sub('', text)
145 return stripped
148def real_string_length(string):
149 return len(strip_colors(string))
152IS_SUGAR_ENABLED = False
155@pytest.mark.trylast
156def pytest_configure(config):
157 global IS_SUGAR_ENABLED
159 if sys.stdout.isatty() or config.getvalue('force_sugar'):
160 IS_SUGAR_ENABLED = True
162 if config.pluginmanager.hasplugin('xdist'):
163 try:
164 import xdist
165 except ImportError:
166 pass
167 else:
168 from distutils.version import LooseVersion
169 xdist_version = LooseVersion(xdist.__version__)
170 if xdist_version >= LooseVersion('1.14'):
171 config.pluginmanager.register(DeferredXdistPlugin())
173 if IS_SUGAR_ENABLED and not getattr(config, 'slaveinput', None):
174 # Get the standard terminal reporter plugin and replace it with our
175 standard_reporter = config.pluginmanager.getplugin('terminalreporter')
176 sugar_reporter = SugarTerminalReporter(standard_reporter)
177 config.pluginmanager.unregister(standard_reporter)
178 config.pluginmanager.register(sugar_reporter, 'terminalreporter')
181def pytest_report_teststatus(report):
182 if not IS_SUGAR_ENABLED:
183 return
185 if report.passed:
186 letter = colored(THEME['symbol_passed'], THEME['success'])
187 elif report.skipped:
188 letter = colored(THEME['symbol_skipped'], THEME['skipped'])
189 elif report.failed:
190 letter = colored(THEME['symbol_failed'], THEME['fail'])
191 if report.when != "call":
192 letter = colored(THEME['symbol_failed_not_call'], THEME['fail'])
193 elif report.outcome == 'rerun':
194 letter = colored(THEME['symbol_rerun'], THEME['rerun'])
195 else:
196 letter = colored(THEME['symbol_unknown'], THEME['unknown'])
198 if hasattr(report, "wasxfail"):
199 if report.skipped:
200 return "xfailed", colored(
201 THEME['symbol_xfailed_skipped'], THEME['xfailed']
202 ), "xfail"
203 elif report.passed:
204 return "xpassed", colored(
205 THEME['symbol_xfailed_failed'], THEME['xpassed']
206 ), "XPASS"
208 return report.outcome, letter, report.outcome.upper()
211class SugarTerminalReporter(TerminalReporter):
212 def __init__(self, reporter):
213 TerminalReporter.__init__(self, reporter.config)
214 self.writer = self._tw
215 self.paths_left = []
216 self.tests_count = 0
217 self.tests_taken = 0
218 self.reports = []
219 self.unreported_errors = []
220 self.progress_blocks = []
221 self.reset_tracked_lines()
223 def reset_tracked_lines(self):
224 self.current_lines = {}
225 self.current_line_nums = {}
226 self.current_line_num = 0
228 def report_collect(self, final=False):
229 pass
231 def pytest_collectreport(self, report):
232 TerminalReporter.pytest_collectreport(self, report)
233 if report.location[0]:
234 self.paths_left.append(
235 os.path.join(os.getcwd(), report.location[0])
236 )
237 if report.failed:
238 self.rewrite("")
239 self.print_failure(report)
241 def pytest_sessionstart(self, session):
242 self._sessionstarttime = py.std.time.time()
243 verinfo = ".".join(map(str, sys.version_info[:3]))
244 self.write_line(
245 "Test session starts "
246 "(platform: %s, Python %s, pytest %s, pytest-sugar %s)" % (
247 sys.platform, verinfo, pytest.__version__, __version__,
248 ), bold=True
249 )
250 lines = self.config.hook.pytest_report_header(
251 config=self.config, startdir=self.startdir)
252 lines.reverse()
253 for line in flatten(lines):
254 self.write_line(line)
256 def write_fspath_result(self, fspath, res):
257 return
259 def insert_progress(self, report):
260 def get_progress_bar():
261 length = LEN_PROGRESS_BAR
262 if not length:
263 return ''
265 p = (
266 float(self.tests_taken) / self.tests_count
267 if self.tests_count else 0
268 )
269 floored = int(p * length)
270 rem = int(round(
271 (p * length - floored) * (len(PROGRESS_BAR_BLOCKS) - 1)
272 ))
273 progressbar = "%i%% " % round(p * 100)
274 # make sure we only report 100% at the last test
275 if progressbar == "100% " and self.tests_taken < self.tests_count:
276 progressbar = "99% "
278 # if at least one block indicates failure,
279 # then the percentage should reflect that
280 if [1 for block, success in self.progress_blocks if not success]:
281 progressbar = colored(progressbar, THEME['fail'])
282 else:
283 progressbar = colored(progressbar, THEME['success'])
285 bar = PROGRESS_BAR_BLOCKS[-1] * floored
286 if rem > 0:
287 bar += PROGRESS_BAR_BLOCKS[rem]
288 bar += ' ' * (LEN_PROGRESS_BAR - len(bar))
290 last = 0
291 last_theme = None
293 progressbar_background = THEME['progressbar_background']
294 if progressbar_background is None:
295 on_color = None
296 else:
297 on_color = 'on_' + progressbar_background
299 for block, success in self.progress_blocks:
300 if success:
301 theme = THEME['progressbar']
302 else:
303 theme = THEME['progressbar_fail']
305 if last < block:
306 progressbar += colored(bar[last:block],
307 last_theme,
308 on_color)
310 progressbar += colored(bar[block],
311 theme,
312 on_color)
313 last = block + 1
314 last_theme = theme
316 if last < len(bar):
317 progressbar += colored(bar[last:len(bar)],
318 last_theme,
319 on_color)
321 return progressbar
323 append_string = get_progress_bar()
325 path = self.report_key(report)
326 current_line = self.current_lines.get(path, "")
327 line_num = self.current_line_nums.get(path, self.current_line_num)
329 console_width = self._tw.fullwidth
330 num_spaces = (
331 console_width - real_string_length(current_line) -
332 real_string_length(append_string) - LEN_RIGHT_MARGIN
333 )
334 full_line = current_line + " " * num_spaces
335 full_line += append_string
337 self.overwrite(full_line, self.current_line_num - line_num)
339 def overwrite(self, line, rel_line_num):
340 # Move cursor up rel_line_num lines
341 if rel_line_num > 0:
342 self.writer.write("\033[%dA" % rel_line_num)
344 # Overwrite the line
345 self.writer.write("\r%s" % line)
347 # Return cursor to original line
348 if rel_line_num > 0:
349 self.writer.write("\033[%dB" % rel_line_num)
351 def get_max_column_for_test_status(self):
352 return (
353 self._tw.fullwidth
354 - LEN_PROGRESS_PERCENTAGE
355 - LEN_PROGRESS_BAR
356 - LEN_RIGHT_MARGIN
357 )
359 def begin_new_line(self, report, print_filename):
360 path = self.report_key(report)
361 self.current_line_num += 1
362 if len(report.fspath) > self.get_max_column_for_test_status() - 5:
363 fspath = '...' + report.fspath[
364 -(self.get_max_column_for_test_status() - 5 - 5):
365 ]
366 else:
367 fspath = report.fspath
368 basename = os.path.basename(fspath)
369 if print_filename:
370 if self.showlongtestinfo:
371 test_location = report.location[0]
372 test_name = report.location[2]
373 else:
374 test_location = fspath[0:-len(basename)]
375 test_name = fspath[-len(basename):]
376 if test_location:
377 pass
378 # only replace if test_location is not empty, if it is,
379 # test_name contains the filename
380 # FIXME: This doesn't work.
381 # test_name = test_name.replace('.', '::')
382 self.current_lines[path] = (
383 " " +
384 colored(test_location, THEME['path']) +
385 ("::" if self.verbosity > 0 else "") +
386 colored(test_name, THEME['name']) +
387 " "
388 )
389 else:
390 self.current_lines[path] = " " * (2 + len(fspath))
391 self.current_line_nums[path] = self.current_line_num
392 self.writer.write("\r\n")
394 def reached_last_column_for_test_status(self, report):
395 len_line = real_string_length(
396 self.current_lines[self.report_key(report)])
397 return len_line >= self.get_max_column_for_test_status()
399 def pytest_runtest_logstart(self, nodeid, location):
400 # Prevent locationline from being printed since we already
401 # show the module_name & in verbose mode the test name.
402 pass
404 def pytest_runtest_logfinish(self):
405 # prevent the default implementation to try to show
406 # pytest's default progress
407 pass
409 def report_key(self, report):
410 """Returns a key to identify which line the report should write to."""
411 return report.location if self.showlongtestinfo else report.fspath
413 def pytest_runtest_logreport(self, report):
414 global LEN_PROGRESS_BAR_SETTING, LEN_PROGRESS_BAR
416 res = pytest_report_teststatus(report=report)
417 cat, letter, word = res
418 self.stats.setdefault(cat, []).append(report)
420 if not LEN_PROGRESS_BAR:
421 if LEN_PROGRESS_BAR_SETTING.endswith('%'):
422 LEN_PROGRESS_BAR = (
423 self._tw.fullwidth *
424 int(LEN_PROGRESS_BAR_SETTING[:-1]) // 100
425 )
426 else:
427 LEN_PROGRESS_BAR = int(LEN_PROGRESS_BAR_SETTING)
429 self.reports.append(report)
430 if report.outcome == 'failed':
431 print("")
432 self.print_failure(report)
433 # Ignore other reports or it will cause duplicated letters
434 if report.when == 'teardown':
435 self.tests_taken += 1
436 self.insert_progress(report)
437 path = os.path.join(os.getcwd(), report.location[0])
439 if report.when == 'call' or report.skipped:
440 path = self.report_key(report)
441 if path not in self.current_line_nums:
442 self.begin_new_line(report, print_filename=True)
443 elif self.reached_last_column_for_test_status(report):
444 # Print filename if another line was inserted in-between
445 print_filename = (
446 self.current_line_nums[self.report_key(report)] !=
447 self.current_line_num)
448 self.begin_new_line(report, print_filename)
450 self.current_lines[path] = self.current_lines[path] + letter
452 block = int(
453 float(self.tests_taken) * LEN_PROGRESS_BAR / self.tests_count
454 if self.tests_count else 0
455 )
456 if report.failed:
457 if (
458 not self.progress_blocks or
459 self.progress_blocks[-1][0] != block
460 ):
461 self.progress_blocks.append([block, False])
462 elif (
463 self.progress_blocks and
464 self.progress_blocks[-1][0] == block
465 ):
466 self.progress_blocks[-1][1] = False
467 else:
468 if (
469 not self.progress_blocks or
470 self.progress_blocks[-1][0] != block
471 ):
472 self.progress_blocks.append([block, True])
474 if not letter and not word:
475 return
476 if self.verbosity > 0:
477 if isinstance(word, tuple):
478 word, markup = word
479 else:
480 if report.passed:
481 markup = {'green': True}
482 elif report.failed:
483 markup = {'red': True}
484 elif report.skipped:
485 markup = {'yellow': True}
486 line = self._locationline(str(report.fspath), *report.location)
487 if hasattr(report, 'node'):
488 self._tw.write("\r\n")
489 self.current_line_num += 1
490 if hasattr(report, 'node'):
491 self._tw.write("[%s] " % report.node.gateway.id)
492 self._tw.write(word, **markup)
493 self._tw.write(" " + line)
494 self.currentfspath = -2
496 def count(self, key, when=('call',)):
497 if self.stats.get(key):
498 return len([
499 x for x in self.stats.get(key)
500 if not hasattr(x, 'when') or x.when in when
501 ])
502 else:
503 return 0
505 def summary_stats(self):
506 session_duration = py.std.time.time() - self._sessionstarttime
508 print("\nResults (%.2fs):" % round(session_duration, 2))
509 if self.count('passed') > 0:
510 self.write_line(colored(
511 " % 5d passed" % self.count('passed'),
512 THEME['success']
513 ))
515 if self.count('xpassed') > 0:
516 self.write_line(colored(
517 " % 5d xpassed" % self.count('xpassed'),
518 THEME['xpassed']
519 ))
521 if self.count('failed', when=['call']) > 0:
522 self.write_line(colored(
523 " % 5d failed" % self.count('failed', when=['call']),
524 THEME['fail']
525 ))
526 for report in self.stats['failed']:
527 if report.when != 'call':
528 continue
529 if self.config.option.tb_summary:
530 crashline = self._get_decoded_crashline(report)
531 else:
532 path = os.path.dirname(report.location[0])
533 name = os.path.basename(report.location[0])
534 # Doctest failure reports have lineno=None at least up to
535 # pytest==3.0.7, but it is available via longrepr object.
536 try:
537 lineno = report.longrepr.reprlocation.lineno
538 except AttributeError:
539 lineno = report.location[1]
540 if lineno is not None:
541 lineno += 1
542 crashline = '%s%s%s:%s %s' % (
543 colored(path, THEME['path']),
544 '/' if path else '',
545 colored(name, THEME['name']),
546 lineno if lineno else '?',
547 colored(report.location[2], THEME['fail'])
548 )
549 self.write_line(" - %s" % crashline)
551 if self.count('failed', when=['setup', 'teardown']) > 0:
552 self.write_line(colored(
553 " % 5d error" % (
554 self.count('failed', when=['setup', 'teardown'])
555 ),
556 THEME['error']
557 ))
559 if self.count('xfailed') > 0:
560 self.write_line(colored(
561 " % 5d xfailed" % self.count('xfailed'),
562 THEME['xfailed']
563 ))
565 if self.count('skipped', when=['call', 'setup', 'teardown']) > 0:
566 self.write_line(colored(
567 " % 5d skipped" % (
568 self.count('skipped', when=['call', 'setup', 'teardown'])
569 ),
570 THEME['skipped']
571 ))
573 if self.count('rerun') > 0:
574 self.write_line(colored(
575 " % 5d rerun" % self.count('rerun'),
576 THEME['rerun']
577 ))
579 if self.count('deselected') > 0:
580 self.write_line(colored(
581 " % 5d deselected" % self.count('deselected'),
582 THEME['warning']
583 ))
585 def _get_decoded_crashline(self, report):
586 crashline = self._getcrashline(report)
588 if hasattr(crashline, 'decode'):
589 encoding = locale.getpreferredencoding()
590 try:
591 crashline = crashline.decode(encoding)
592 except UnicodeDecodeError:
593 encoding = 'utf-8'
594 crashline = crashline.decode(encoding, errors='replace')
596 return crashline
598 def summary_failures(self):
599 # Prevent failure summary from being shown since we already
600 # show the failure instantly after failure has occurred.
601 pass
603 def summary_errors(self):
604 # Prevent error summary from being shown since we already
605 # show the error instantly after error has occurred.
606 pass
608 def print_failure(self, report):
609 # https://github.com/Frozenball/pytest-sugar/issues/34
610 if hasattr(report, 'wasxfail'):
611 return
613 if self.config.option.tbstyle != "no":
614 if self.config.option.tbstyle == "line":
615 line = self._getcrashline(report)
616 self.write_line(line)
617 else:
618 msg = self._getfailureheadline(report)
619 if not hasattr(report, 'when'):
620 msg = "ERROR collecting " + msg
621 elif report.when == "setup":
622 msg = "ERROR at setup of " + msg
623 elif report.when == "teardown":
624 msg = "ERROR at teardown of " + msg
625 self.write_line('')
626 self.write_sep("―", msg)
627 self._outrep_summary(report)
628 self.reset_tracked_lines()
631# On older version of Pytest, allow default progress
632if parse(pytest.__version__) <= parse('3.4'): # pragma: no cover
633 del SugarTerminalReporter.pytest_runtest_logfinish