Hide keyboard shortcuts

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~~~~~~~~~~~~ 

5 

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). 

8 

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 

18 

19try: 

20 from configparser import ConfigParser 

21except ImportError: 

22 from ConfigParser import ConfigParser 

23 

24from termcolor import colored 

25 

26import py 

27import pytest 

28from _pytest.terminal import TerminalReporter 

29 

30 

31__version__ = '0.9.2' 

32 

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] 

65 

66 

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 

74 

75 

76def pytest_runtestloop(session): 

77 reporter = session.config.pluginmanager.getplugin('terminalreporter') 

78 if reporter: 

79 reporter.tests_count = len(session.items) 

80 

81 

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) 

89 

90 

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) 

99 

100 

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 ) 

117 

118 

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 ]) 

126 

127 for key in THEME: 

128 if not config.has_option('theme', key): 

129 continue 

130 

131 value = config.get("theme", key) 

132 value = value.lower() 

133 if value in ('', 'none'): 

134 value = None 

135 

136 THEME[key] = value 

137 

138 if config.has_option('sugar', 'progressbar_length'): 

139 LEN_PROGRESS_BAR_SETTING = config.get('sugar', 'progressbar_length') 

140 

141 

142def strip_colors(text): 

143 ansi_escape = re.compile(r'\x1b[^m]*m') 

144 stripped = ansi_escape.sub('', text) 

145 return stripped 

146 

147 

148def real_string_length(string): 

149 return len(strip_colors(string)) 

150 

151 

152IS_SUGAR_ENABLED = False 

153 

154 

155@pytest.mark.trylast 

156def pytest_configure(config): 

157 global IS_SUGAR_ENABLED 

158 

159 if sys.stdout.isatty() or config.getvalue('force_sugar'): 

160 IS_SUGAR_ENABLED = True 

161 

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()) 

172 

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') 

179 

180 

181def pytest_report_teststatus(report): 

182 if not IS_SUGAR_ENABLED: 

183 return 

184 

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']) 

197 

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" 

207 

208 return report.outcome, letter, report.outcome.upper() 

209 

210 

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() 

222 

223 def reset_tracked_lines(self): 

224 self.current_lines = {} 

225 self.current_line_nums = {} 

226 self.current_line_num = 0 

227 

228 def report_collect(self, final=False): 

229 pass 

230 

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) 

240 

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) 

255 

256 def write_fspath_result(self, fspath, res): 

257 return 

258 

259 def insert_progress(self, report): 

260 def get_progress_bar(): 

261 length = LEN_PROGRESS_BAR 

262 if not length: 

263 return '' 

264 

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% " 

277 

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']) 

284 

285 bar = PROGRESS_BAR_BLOCKS[-1] * floored 

286 if rem > 0: 

287 bar += PROGRESS_BAR_BLOCKS[rem] 

288 bar += ' ' * (LEN_PROGRESS_BAR - len(bar)) 

289 

290 last = 0 

291 last_theme = None 

292 

293 progressbar_background = THEME['progressbar_background'] 

294 if progressbar_background is None: 

295 on_color = None 

296 else: 

297 on_color = 'on_' + progressbar_background 

298 

299 for block, success in self.progress_blocks: 

300 if success: 

301 theme = THEME['progressbar'] 

302 else: 

303 theme = THEME['progressbar_fail'] 

304 

305 if last < block: 

306 progressbar += colored(bar[last:block], 

307 last_theme, 

308 on_color) 

309 

310 progressbar += colored(bar[block], 

311 theme, 

312 on_color) 

313 last = block + 1 

314 last_theme = theme 

315 

316 if last < len(bar): 

317 progressbar += colored(bar[last:len(bar)], 

318 last_theme, 

319 on_color) 

320 

321 return progressbar 

322 

323 append_string = get_progress_bar() 

324 

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) 

328 

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 

336 

337 self.overwrite(full_line, self.current_line_num - line_num) 

338 

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) 

343 

344 # Overwrite the line 

345 self.writer.write("\r%s" % line) 

346 

347 # Return cursor to original line 

348 if rel_line_num > 0: 

349 self.writer.write("\033[%dB" % rel_line_num) 

350 

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 ) 

358 

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") 

393 

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() 

398 

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 

403 

404 def pytest_runtest_logfinish(self): 

405 # prevent the default implementation to try to show 

406 # pytest's default progress 

407 pass 

408 

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 

412 

413 def pytest_runtest_logreport(self, report): 

414 global LEN_PROGRESS_BAR_SETTING, LEN_PROGRESS_BAR 

415 

416 res = pytest_report_teststatus(report=report) 

417 cat, letter, word = res 

418 self.stats.setdefault(cat, []).append(report) 

419 

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) 

428 

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]) 

438 

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) 

449 

450 self.current_lines[path] = self.current_lines[path] + letter 

451 

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]) 

473 

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 

495 

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 

504 

505 def summary_stats(self): 

506 session_duration = py.std.time.time() - self._sessionstarttime 

507 

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 )) 

514 

515 if self.count('xpassed') > 0: 

516 self.write_line(colored( 

517 " % 5d xpassed" % self.count('xpassed'), 

518 THEME['xpassed'] 

519 )) 

520 

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) 

550 

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 )) 

558 

559 if self.count('xfailed') > 0: 

560 self.write_line(colored( 

561 " % 5d xfailed" % self.count('xfailed'), 

562 THEME['xfailed'] 

563 )) 

564 

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 )) 

572 

573 if self.count('rerun') > 0: 

574 self.write_line(colored( 

575 " % 5d rerun" % self.count('rerun'), 

576 THEME['rerun'] 

577 )) 

578 

579 if self.count('deselected') > 0: 

580 self.write_line(colored( 

581 " % 5d deselected" % self.count('deselected'), 

582 THEME['warning'] 

583 )) 

584 

585 def _get_decoded_crashline(self, report): 

586 crashline = self._getcrashline(report) 

587 

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') 

595 

596 return crashline 

597 

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 

602 

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 

607 

608 def print_failure(self, report): 

609 # https://github.com/Frozenball/pytest-sugar/issues/34 

610 if hasattr(report, 'wasxfail'): 

611 return 

612 

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() 

629 

630 

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