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""" terminal reporting of the full testing process. 

2 

3This is a good source for looking at the various reporting hooks. 

4""" 

5import argparse 

6import collections 

7import datetime 

8import platform 

9import sys 

10import time 

11from functools import partial 

12from typing import Any 

13from typing import Callable 

14from typing import Dict 

15from typing import List 

16from typing import Mapping 

17from typing import Optional 

18from typing import Set 

19from typing import Tuple 

20 

21import attr 

22import pluggy 

23import py 

24from more_itertools import collapse 

25 

26import pytest 

27from _pytest import nodes 

28from _pytest.config import Config 

29from _pytest.main import ExitCode 

30from _pytest.main import Session 

31from _pytest.reports import CollectReport 

32from _pytest.reports import TestReport 

33 

34REPORT_COLLECTING_RESOLUTION = 0.5 

35 

36 

37class MoreQuietAction(argparse.Action): 

38 """ 

39 a modified copy of the argparse count action which counts down and updates 

40 the legacy quiet attribute at the same time 

41 

42 used to unify verbosity handling 

43 """ 

44 

45 def __init__(self, option_strings, dest, default=None, required=False, help=None): 

46 super().__init__( 

47 option_strings=option_strings, 

48 dest=dest, 

49 nargs=0, 

50 default=default, 

51 required=required, 

52 help=help, 

53 ) 

54 

55 def __call__(self, parser, namespace, values, option_string=None): 

56 new_count = getattr(namespace, self.dest, 0) - 1 

57 setattr(namespace, self.dest, new_count) 

58 # todo Deprecate config.quiet 

59 namespace.quiet = getattr(namespace, "quiet", 0) + 1 

60 

61 

62def pytest_addoption(parser): 

63 group = parser.getgroup("terminal reporting", "reporting", after="general") 

64 group._addoption( 

65 "-v", 

66 "--verbose", 

67 action="count", 

68 default=0, 

69 dest="verbose", 

70 help="increase verbosity.", 

71 ), 

72 group._addoption( 

73 "-q", 

74 "--quiet", 

75 action=MoreQuietAction, 

76 default=0, 

77 dest="verbose", 

78 help="decrease verbosity.", 

79 ), 

80 group._addoption( 

81 "--verbosity", 

82 dest="verbose", 

83 type=int, 

84 default=0, 

85 help="set verbosity. Default is 0.", 

86 ) 

87 group._addoption( 

88 "-r", 

89 action="store", 

90 dest="reportchars", 

91 default="", 

92 metavar="chars", 

93 help="show extra test summary info as specified by chars: (f)ailed, " 

94 "(E)rror, (s)kipped, (x)failed, (X)passed, " 

95 "(p)assed, (P)assed with output, (a)ll except passed (p/P), or (A)ll. " 

96 "(w)arnings are enabled by default (see --disable-warnings).", 

97 ) 

98 group._addoption( 

99 "--disable-warnings", 

100 "--disable-pytest-warnings", 

101 default=False, 

102 dest="disable_warnings", 

103 action="store_true", 

104 help="disable warnings summary", 

105 ) 

106 group._addoption( 

107 "-l", 

108 "--showlocals", 

109 action="store_true", 

110 dest="showlocals", 

111 default=False, 

112 help="show locals in tracebacks (disabled by default).", 

113 ) 

114 group._addoption( 

115 "--tb", 

116 metavar="style", 

117 action="store", 

118 dest="tbstyle", 

119 default="auto", 

120 choices=["auto", "long", "short", "no", "line", "native"], 

121 help="traceback print mode (auto/long/short/line/native/no).", 

122 ) 

123 group._addoption( 

124 "--show-capture", 

125 action="store", 

126 dest="showcapture", 

127 choices=["no", "stdout", "stderr", "log", "all"], 

128 default="all", 

129 help="Controls how captured stdout/stderr/log is shown on failed tests. " 

130 "Default is 'all'.", 

131 ) 

132 group._addoption( 

133 "--fulltrace", 

134 "--full-trace", 

135 action="store_true", 

136 default=False, 

137 help="don't cut any tracebacks (default is to cut).", 

138 ) 

139 group._addoption( 

140 "--color", 

141 metavar="color", 

142 action="store", 

143 dest="color", 

144 default="auto", 

145 choices=["yes", "no", "auto"], 

146 help="color terminal output (yes/no/auto).", 

147 ) 

148 

149 parser.addini( 

150 "console_output_style", 

151 help='console output: "classic", or with additional progress information ("progress" (percentage) | "count").', 

152 default="progress", 

153 ) 

154 

155 

156def pytest_configure(config: Config) -> None: 

157 reporter = TerminalReporter(config, sys.stdout) 

158 config.pluginmanager.register(reporter, "terminalreporter") 

159 if config.option.debug or config.option.traceconfig: 

160 

161 def mywriter(tags, args): 

162 msg = " ".join(map(str, args)) 

163 reporter.write_line("[traceconfig] " + msg) 

164 

165 config.trace.root.setprocessor("pytest:config", mywriter) 

166 

167 

168def getreportopt(config: Config) -> str: 

169 reportopts = "" 

170 reportchars = config.option.reportchars 

171 if not config.option.disable_warnings and "w" not in reportchars: 

172 reportchars += "w" 

173 elif config.option.disable_warnings and "w" in reportchars: 

174 reportchars = reportchars.replace("w", "") 

175 for char in reportchars: 

176 if char == "a": 

177 reportopts = "sxXwEf" 

178 elif char == "A": 

179 reportopts = "PpsxXwEf" 

180 break 

181 elif char not in reportopts: 

182 reportopts += char 

183 return reportopts 

184 

185 

186@pytest.hookimpl(trylast=True) # after _pytest.runner 

187def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]: 

188 if report.passed: 

189 letter = "." 

190 elif report.skipped: 

191 letter = "s" 

192 elif report.failed: 

193 letter = "F" 

194 if report.when != "call": 

195 letter = "f" 

196 

197 # Report failed CollectReports as "error" (in line with pytest_collectreport). 

198 outcome = report.outcome 

199 if report.when == "collect" and outcome == "failed": 

200 outcome = "error" 

201 

202 return outcome, letter, outcome.upper() 

203 

204 

205@attr.s 

206class WarningReport: 

207 """ 

208 Simple structure to hold warnings information captured by ``pytest_warning_captured``. 

209 

210 :ivar str message: user friendly message about the warning 

211 :ivar str|None nodeid: node id that generated the warning (see ``get_location``). 

212 :ivar tuple|py.path.local fslocation: 

213 file system location of the source of the warning (see ``get_location``). 

214 """ 

215 

216 message = attr.ib(type=str) 

217 nodeid = attr.ib(type=Optional[str], default=None) 

218 fslocation = attr.ib(default=None) 

219 count_towards_summary = True 

220 

221 def get_location(self, config): 

222 """ 

223 Returns the more user-friendly information about the location 

224 of a warning, or None. 

225 """ 

226 if self.nodeid: 

227 return self.nodeid 

228 if self.fslocation: 

229 if isinstance(self.fslocation, tuple) and len(self.fslocation) >= 2: 

230 filename, linenum = self.fslocation[:2] 

231 relpath = py.path.local(filename).relto(config.invocation_dir) 

232 if not relpath: 

233 relpath = str(filename) 

234 return "{}:{}".format(relpath, linenum) 

235 else: 

236 return str(self.fslocation) 

237 return None 

238 

239 

240class TerminalReporter: 

241 def __init__(self, config: Config, file=None) -> None: 

242 import _pytest.config 

243 

244 self.config = config 

245 self._numcollected = 0 

246 self._session = None # type: Optional[Session] 

247 self._showfspath = None 

248 

249 self.stats = {} # type: Dict[str, List[Any]] 

250 self.startdir = config.invocation_dir 

251 if file is None: 

252 file = sys.stdout 

253 self._tw = _pytest.config.create_terminal_writer(config, file) 

254 # self.writer will be deprecated in pytest-3.4 

255 self.writer = self._tw 

256 self._screen_width = self._tw.fullwidth 

257 self.currentfspath = None # type: Any 

258 self.reportchars = getreportopt(config) 

259 self.hasmarkup = self._tw.hasmarkup 

260 self.isatty = file.isatty() 

261 self._progress_nodeids_reported = set() # type: Set[str] 

262 self._show_progress_info = self._determine_show_progress_info() 

263 self._collect_report_last_write = None # type: Optional[float] 

264 

265 def _determine_show_progress_info(self): 

266 """Return True if we should display progress information based on the current config""" 

267 # do not show progress if we are not capturing output (#3038) 

268 if self.config.getoption("capture", "no") == "no": 

269 return False 

270 # do not show progress if we are showing fixture setup/teardown 

271 if self.config.getoption("setupshow", False): 

272 return False 

273 cfg = self.config.getini("console_output_style") 

274 if cfg in ("progress", "count"): 

275 return cfg 

276 return False 

277 

278 @property 

279 def verbosity(self): 

280 return self.config.option.verbose 

281 

282 @property 

283 def showheader(self): 

284 return self.verbosity >= 0 

285 

286 @property 

287 def showfspath(self): 

288 if self._showfspath is None: 

289 return self.verbosity >= 0 

290 return self._showfspath 

291 

292 @showfspath.setter 

293 def showfspath(self, value): 

294 self._showfspath = value 

295 

296 @property 

297 def showlongtestinfo(self): 

298 return self.verbosity > 0 

299 

300 def hasopt(self, char): 

301 char = {"xfailed": "x", "skipped": "s"}.get(char, char) 

302 return char in self.reportchars 

303 

304 def write_fspath_result(self, nodeid, res, **markup): 

305 fspath = self.config.rootdir.join(nodeid.split("::")[0]) 

306 # NOTE: explicitly check for None to work around py bug, and for less 

307 # overhead in general (https://github.com/pytest-dev/py/pull/207). 

308 if self.currentfspath is None or fspath != self.currentfspath: 

309 if self.currentfspath is not None and self._show_progress_info: 

310 self._write_progress_information_filling_space() 

311 self.currentfspath = fspath 

312 fspath = self.startdir.bestrelpath(fspath) 

313 self._tw.line() 

314 self._tw.write(fspath + " ") 

315 self._tw.write(res, **markup) 

316 

317 def write_ensure_prefix(self, prefix, extra="", **kwargs): 

318 if self.currentfspath != prefix: 

319 self._tw.line() 

320 self.currentfspath = prefix 

321 self._tw.write(prefix) 

322 if extra: 

323 self._tw.write(extra, **kwargs) 

324 self.currentfspath = -2 

325 

326 def ensure_newline(self): 

327 if self.currentfspath: 

328 self._tw.line() 

329 self.currentfspath = None 

330 

331 def write(self, content, **markup): 

332 self._tw.write(content, **markup) 

333 

334 def write_line(self, line, **markup): 

335 if not isinstance(line, str): 

336 line = str(line, errors="replace") 

337 self.ensure_newline() 

338 self._tw.line(line, **markup) 

339 

340 def rewrite(self, line, **markup): 

341 """ 

342 Rewinds the terminal cursor to the beginning and writes the given line. 

343 

344 :kwarg erase: if True, will also add spaces until the full terminal width to ensure 

345 previous lines are properly erased. 

346 

347 The rest of the keyword arguments are markup instructions. 

348 """ 

349 erase = markup.pop("erase", False) 

350 if erase: 

351 fill_count = self._tw.fullwidth - len(line) - 1 

352 fill = " " * fill_count 

353 else: 

354 fill = "" 

355 line = str(line) 

356 self._tw.write("\r" + line + fill, **markup) 

357 

358 def write_sep(self, sep, title=None, **markup): 

359 self.ensure_newline() 

360 self._tw.sep(sep, title, **markup) 

361 

362 def section(self, title, sep="=", **kw): 

363 self._tw.sep(sep, title, **kw) 

364 

365 def line(self, msg, **kw): 

366 self._tw.line(msg, **kw) 

367 

368 def pytest_internalerror(self, excrepr): 

369 for line in str(excrepr).split("\n"): 

370 self.write_line("INTERNALERROR> " + line) 

371 return 1 

372 

373 def pytest_warning_captured(self, warning_message, item): 

374 # from _pytest.nodes import get_fslocation_from_item 

375 from _pytest.warnings import warning_record_to_str 

376 

377 warnings = self.stats.setdefault("warnings", []) 

378 fslocation = warning_message.filename, warning_message.lineno 

379 message = warning_record_to_str(warning_message) 

380 

381 nodeid = item.nodeid if item is not None else "" 

382 warning_report = WarningReport( 

383 fslocation=fslocation, message=message, nodeid=nodeid 

384 ) 

385 warnings.append(warning_report) 

386 

387 def pytest_plugin_registered(self, plugin): 

388 if self.config.option.traceconfig: 

389 msg = "PLUGIN registered: {}".format(plugin) 

390 # XXX this event may happen during setup/teardown time 

391 # which unfortunately captures our output here 

392 # which garbles our output if we use self.write_line 

393 self.write_line(msg) 

394 

395 def pytest_deselected(self, items): 

396 self.stats.setdefault("deselected", []).extend(items) 

397 

398 def pytest_runtest_logstart(self, nodeid, location): 

399 # ensure that the path is printed before the 

400 # 1st test of a module starts running 

401 if self.showlongtestinfo: 

402 line = self._locationline(nodeid, *location) 

403 self.write_ensure_prefix(line, "") 

404 elif self.showfspath: 

405 fsid = nodeid.split("::")[0] 

406 self.write_fspath_result(fsid, "") 

407 

408 def pytest_runtest_logreport(self, report: TestReport) -> None: 

409 self._tests_ran = True 

410 rep = report 

411 res = self.config.hook.pytest_report_teststatus(report=rep, config=self.config) 

412 category, letter, word = res 

413 if isinstance(word, tuple): 

414 word, markup = word 

415 else: 

416 markup = None 

417 self.stats.setdefault(category, []).append(rep) 

418 if not letter and not word: 

419 # probably passed setup/teardown 

420 return 

421 running_xdist = hasattr(rep, "node") 

422 if markup is None: 

423 was_xfail = hasattr(report, "wasxfail") 

424 if rep.passed and not was_xfail: 

425 markup = {"green": True} 

426 elif rep.passed and was_xfail: 

427 markup = {"yellow": True} 

428 elif rep.failed: 

429 markup = {"red": True} 

430 elif rep.skipped: 

431 markup = {"yellow": True} 

432 else: 

433 markup = {} 

434 if self.verbosity <= 0: 

435 if not running_xdist and self.showfspath: 

436 self.write_fspath_result(rep.nodeid, letter, **markup) 

437 else: 

438 self._tw.write(letter, **markup) 

439 else: 

440 self._progress_nodeids_reported.add(rep.nodeid) 

441 line = self._locationline(rep.nodeid, *rep.location) 

442 if not running_xdist: 

443 self.write_ensure_prefix(line, word, **markup) 

444 if self._show_progress_info: 

445 self._write_progress_information_filling_space() 

446 else: 

447 self.ensure_newline() 

448 self._tw.write("[%s]" % rep.node.gateway.id) # type: ignore 

449 if self._show_progress_info: 

450 self._tw.write( 

451 self._get_progress_information_message() + " ", cyan=True 

452 ) 

453 else: 

454 self._tw.write(" ") 

455 self._tw.write(word, **markup) 

456 self._tw.write(" " + line) 

457 self.currentfspath = -2 

458 

459 def pytest_runtest_logfinish(self, nodeid): 

460 assert self._session 

461 if self.verbosity <= 0 and self._show_progress_info: 

462 if self._show_progress_info == "count": 

463 num_tests = self._session.testscollected 

464 progress_length = len(" [{}/{}]".format(str(num_tests), str(num_tests))) 

465 else: 

466 progress_length = len(" [100%]") 

467 

468 main_color, _ = _get_main_color(self.stats) 

469 

470 self._progress_nodeids_reported.add(nodeid) 

471 is_last_item = ( 

472 len(self._progress_nodeids_reported) == self._session.testscollected 

473 ) 

474 if is_last_item: 

475 self._write_progress_information_filling_space(color=main_color) 

476 else: 

477 w = self._width_of_current_line 

478 past_edge = w + progress_length + 1 >= self._screen_width 

479 if past_edge: 

480 msg = self._get_progress_information_message() 

481 self._tw.write(msg + "\n", **{main_color: True}) 

482 

483 def _get_progress_information_message(self) -> str: 

484 assert self._session 

485 collected = self._session.testscollected 

486 if self._show_progress_info == "count": 

487 if collected: 

488 progress = self._progress_nodeids_reported 

489 counter_format = "{{:{}d}}".format(len(str(collected))) 

490 format_string = " [{}/{{}}]".format(counter_format) 

491 return format_string.format(len(progress), collected) 

492 return " [ {} / {} ]".format(collected, collected) 

493 else: 

494 if collected: 

495 return " [{:3d}%]".format( 

496 len(self._progress_nodeids_reported) * 100 // collected 

497 ) 

498 return " [100%]" 

499 

500 def _write_progress_information_filling_space(self, color=None): 

501 if not color: 

502 color, _ = _get_main_color(self.stats) 

503 msg = self._get_progress_information_message() 

504 w = self._width_of_current_line 

505 fill = self._tw.fullwidth - w - 1 

506 self.write(msg.rjust(fill), **{color: True}) 

507 

508 @property 

509 def _width_of_current_line(self): 

510 """Return the width of current line, using the superior implementation of py-1.6 when available""" 

511 try: 

512 return self._tw.width_of_current_line 

513 except AttributeError: 

514 # py < 1.6.0 

515 return self._tw.chars_on_current_line 

516 

517 def pytest_collection(self): 

518 if self.isatty: 

519 if self.config.option.verbose >= 0: 

520 self.write("collecting ... ", bold=True) 

521 self._collect_report_last_write = time.time() 

522 elif self.config.option.verbose >= 1: 

523 self.write("collecting ... ", bold=True) 

524 

525 def pytest_collectreport(self, report: CollectReport) -> None: 

526 if report.failed: 

527 self.stats.setdefault("error", []).append(report) 

528 elif report.skipped: 

529 self.stats.setdefault("skipped", []).append(report) 

530 items = [x for x in report.result if isinstance(x, pytest.Item)] 

531 self._numcollected += len(items) 

532 if self.isatty: 

533 self.report_collect() 

534 

535 def report_collect(self, final=False): 

536 if self.config.option.verbose < 0: 

537 return 

538 

539 if not final: 

540 # Only write "collecting" report every 0.5s. 

541 t = time.time() 

542 if ( 

543 self._collect_report_last_write is not None 

544 and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION 

545 ): 

546 return 

547 self._collect_report_last_write = t 

548 

549 errors = len(self.stats.get("error", [])) 

550 skipped = len(self.stats.get("skipped", [])) 

551 deselected = len(self.stats.get("deselected", [])) 

552 selected = self._numcollected - errors - skipped - deselected 

553 if final: 

554 line = "collected " 

555 else: 

556 line = "collecting " 

557 line += ( 

558 str(self._numcollected) + " item" + ("" if self._numcollected == 1 else "s") 

559 ) 

560 if errors: 

561 line += " / %d error%s" % (errors, "s" if errors != 1 else "") 

562 if deselected: 

563 line += " / %d deselected" % deselected 

564 if skipped: 

565 line += " / %d skipped" % skipped 

566 if self._numcollected > selected > 0: 

567 line += " / %d selected" % selected 

568 if self.isatty: 

569 self.rewrite(line, bold=True, erase=True) 

570 if final: 

571 self.write("\n") 

572 else: 

573 self.write_line(line) 

574 

575 @pytest.hookimpl(trylast=True) 

576 def pytest_sessionstart(self, session: Session) -> None: 

577 self._session = session 

578 self._sessionstarttime = time.time() 

579 if not self.showheader: 

580 return 

581 self.write_sep("=", "test session starts", bold=True) 

582 verinfo = platform.python_version() 

583 msg = "platform {} -- Python {}".format(sys.platform, verinfo) 

584 pypy_version_info = getattr(sys, "pypy_version_info", None) 

585 if pypy_version_info: 

586 verinfo = ".".join(map(str, pypy_version_info[:3])) 

587 msg += "[pypy-{}-{}]".format(verinfo, pypy_version_info[3]) 

588 msg += ", pytest-{}, py-{}, pluggy-{}".format( 

589 pytest.__version__, py.__version__, pluggy.__version__ 

590 ) 

591 if ( 

592 self.verbosity > 0 

593 or self.config.option.debug 

594 or getattr(self.config.option, "pastebin", None) 

595 ): 

596 msg += " -- " + str(sys.executable) 

597 self.write_line(msg) 

598 lines = self.config.hook.pytest_report_header( 

599 config=self.config, startdir=self.startdir 

600 ) 

601 self._write_report_lines_from_hooks(lines) 

602 

603 def _write_report_lines_from_hooks(self, lines): 

604 lines.reverse() 

605 for line in collapse(lines): 

606 self.write_line(line) 

607 

608 def pytest_report_header(self, config): 

609 line = "rootdir: %s" % config.rootdir 

610 

611 if config.inifile: 

612 line += ", inifile: " + config.rootdir.bestrelpath(config.inifile) 

613 

614 testpaths = config.getini("testpaths") 

615 if testpaths and config.args == testpaths: 

616 rel_paths = [config.rootdir.bestrelpath(x) for x in testpaths] 

617 line += ", testpaths: {}".format(", ".join(rel_paths)) 

618 result = [line] 

619 

620 plugininfo = config.pluginmanager.list_plugin_distinfo() 

621 if plugininfo: 

622 result.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo))) 

623 return result 

624 

625 def pytest_collection_finish(self, session): 

626 self.report_collect(True) 

627 

628 if self.config.getoption("collectonly"): 

629 self._printcollecteditems(session.items) 

630 

631 lines = self.config.hook.pytest_report_collectionfinish( 

632 config=self.config, startdir=self.startdir, items=session.items 

633 ) 

634 self._write_report_lines_from_hooks(lines) 

635 

636 if self.config.getoption("collectonly"): 

637 failed = self.stats.get("failed") 

638 if failed: 

639 self._tw.sep("!", "collection failures") 

640 for rep in failed: 

641 rep.toterminal(self._tw) 

642 

643 def _printcollecteditems(self, items): 

644 # to print out items and their parent collectors 

645 # we take care to leave out Instances aka () 

646 # because later versions are going to get rid of them anyway 

647 if self.config.option.verbose < 0: 

648 if self.config.option.verbose < -1: 

649 counts = {} # type: Dict[str, int] 

650 for item in items: 

651 name = item.nodeid.split("::", 1)[0] 

652 counts[name] = counts.get(name, 0) + 1 

653 for name, count in sorted(counts.items()): 

654 self._tw.line("%s: %d" % (name, count)) 

655 else: 

656 for item in items: 

657 self._tw.line(item.nodeid) 

658 return 

659 stack = [] 

660 indent = "" 

661 for item in items: 

662 needed_collectors = item.listchain()[1:] # strip root node 

663 while stack: 

664 if stack == needed_collectors[: len(stack)]: 

665 break 

666 stack.pop() 

667 for col in needed_collectors[len(stack) :]: 

668 stack.append(col) 

669 if col.name == "()": # Skip Instances. 

670 continue 

671 indent = (len(stack) - 1) * " " 

672 self._tw.line("{}{}".format(indent, col)) 

673 if self.config.option.verbose >= 1: 

674 if hasattr(col, "_obj") and col._obj.__doc__: 

675 for line in col._obj.__doc__.strip().splitlines(): 

676 self._tw.line("{}{}".format(indent + " ", line.strip())) 

677 

678 @pytest.hookimpl(hookwrapper=True) 

679 def pytest_sessionfinish(self, session: Session, exitstatus: ExitCode): 

680 outcome = yield 

681 outcome.get_result() 

682 self._tw.line("") 

683 summary_exit_codes = ( 

684 ExitCode.OK, 

685 ExitCode.TESTS_FAILED, 

686 ExitCode.INTERRUPTED, 

687 ExitCode.USAGE_ERROR, 

688 ExitCode.NO_TESTS_COLLECTED, 

689 ) 

690 if exitstatus in summary_exit_codes: 

691 self.config.hook.pytest_terminal_summary( 

692 terminalreporter=self, exitstatus=exitstatus, config=self.config 

693 ) 

694 if session.shouldfail: 

695 self.write_sep("!", session.shouldfail, red=True) 

696 if exitstatus == ExitCode.INTERRUPTED: 

697 self._report_keyboardinterrupt() 

698 del self._keyboardinterrupt_memo 

699 elif session.shouldstop: 

700 self.write_sep("!", session.shouldstop, red=True) 

701 self.summary_stats() 

702 

703 @pytest.hookimpl(hookwrapper=True) 

704 def pytest_terminal_summary(self): 

705 self.summary_errors() 

706 self.summary_failures() 

707 self.summary_warnings() 

708 self.summary_passes() 

709 yield 

710 self.short_test_summary() 

711 # Display any extra warnings from teardown here (if any). 

712 self.summary_warnings() 

713 

714 def pytest_keyboard_interrupt(self, excinfo): 

715 self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) 

716 

717 def pytest_unconfigure(self): 

718 if hasattr(self, "_keyboardinterrupt_memo"): 

719 self._report_keyboardinterrupt() 

720 

721 def _report_keyboardinterrupt(self): 

722 excrepr = self._keyboardinterrupt_memo 

723 msg = excrepr.reprcrash.message 

724 self.write_sep("!", msg) 

725 if "KeyboardInterrupt" in msg: 

726 if self.config.option.fulltrace: 

727 excrepr.toterminal(self._tw) 

728 else: 

729 excrepr.reprcrash.toterminal(self._tw) 

730 self._tw.line( 

731 "(to show a full traceback on KeyboardInterrupt use --full-trace)", 

732 yellow=True, 

733 ) 

734 

735 def _locationline(self, nodeid, fspath, lineno, domain): 

736 def mkrel(nodeid): 

737 line = self.config.cwd_relative_nodeid(nodeid) 

738 if domain and line.endswith(domain): 

739 line = line[: -len(domain)] 

740 values = domain.split("[") 

741 values[0] = values[0].replace(".", "::") # don't replace '.' in params 

742 line += "[".join(values) 

743 return line 

744 

745 # collect_fspath comes from testid which has a "/"-normalized path 

746 

747 if fspath: 

748 res = mkrel(nodeid) 

749 if self.verbosity >= 2 and nodeid.split("::")[0] != fspath.replace( 

750 "\\", nodes.SEP 

751 ): 

752 res += " <- " + self.startdir.bestrelpath(fspath) 

753 else: 

754 res = "[location]" 

755 return res + " " 

756 

757 def _getfailureheadline(self, rep): 

758 head_line = rep.head_line 

759 if head_line: 

760 return head_line 

761 return "test session" # XXX? 

762 

763 def _getcrashline(self, rep): 

764 try: 

765 return str(rep.longrepr.reprcrash) 

766 except AttributeError: 

767 try: 

768 return str(rep.longrepr)[:50] 

769 except AttributeError: 

770 return "" 

771 

772 # 

773 # summaries for sessionfinish 

774 # 

775 def getreports(self, name): 

776 values = [] 

777 for x in self.stats.get(name, []): 

778 if not hasattr(x, "_pdbshown"): 

779 values.append(x) 

780 return values 

781 

782 def summary_warnings(self): 

783 if self.hasopt("w"): 

784 all_warnings = self.stats.get( 

785 "warnings" 

786 ) # type: Optional[List[WarningReport]] 

787 if not all_warnings: 

788 return 

789 

790 final = hasattr(self, "_already_displayed_warnings") 

791 if final: 

792 warning_reports = all_warnings[self._already_displayed_warnings :] 

793 else: 

794 warning_reports = all_warnings 

795 self._already_displayed_warnings = len(warning_reports) 

796 if not warning_reports: 

797 return 

798 

799 reports_grouped_by_message = ( 

800 collections.OrderedDict() 

801 ) # type: collections.OrderedDict[str, List[WarningReport]] 

802 for wr in warning_reports: 

803 reports_grouped_by_message.setdefault(wr.message, []).append(wr) 

804 

805 title = "warnings summary (final)" if final else "warnings summary" 

806 self.write_sep("=", title, yellow=True, bold=False) 

807 for message, warning_reports in reports_grouped_by_message.items(): 

808 has_any_location = False 

809 for w in warning_reports: 

810 location = w.get_location(self.config) 

811 if location: 

812 self._tw.line(str(location)) 

813 has_any_location = True 

814 if has_any_location: 

815 lines = message.splitlines() 

816 indented = "\n".join(" " + x for x in lines) 

817 message = indented.rstrip() 

818 else: 

819 message = message.rstrip() 

820 self._tw.line(message) 

821 self._tw.line() 

822 self._tw.line("-- Docs: https://docs.pytest.org/en/latest/warnings.html") 

823 

824 def summary_passes(self): 

825 if self.config.option.tbstyle != "no": 

826 if self.hasopt("P"): 

827 reports = self.getreports("passed") 

828 if not reports: 

829 return 

830 self.write_sep("=", "PASSES") 

831 for rep in reports: 

832 if rep.sections: 

833 msg = self._getfailureheadline(rep) 

834 self.write_sep("_", msg, green=True, bold=True) 

835 self._outrep_summary(rep) 

836 self._handle_teardown_sections(rep.nodeid) 

837 

838 def _get_teardown_reports(self, nodeid: str) -> List[TestReport]: 

839 return [ 

840 report 

841 for report in self.getreports("") 

842 if report.when == "teardown" and report.nodeid == nodeid 

843 ] 

844 

845 def _handle_teardown_sections(self, nodeid: str) -> None: 

846 for report in self._get_teardown_reports(nodeid): 

847 self.print_teardown_sections(report) 

848 

849 def print_teardown_sections(self, rep: TestReport) -> None: 

850 showcapture = self.config.option.showcapture 

851 if showcapture == "no": 

852 return 

853 for secname, content in rep.sections: 

854 if showcapture != "all" and showcapture not in secname: 

855 continue 

856 if "teardown" in secname: 

857 self._tw.sep("-", secname) 

858 if content[-1:] == "\n": 

859 content = content[:-1] 

860 self._tw.line(content) 

861 

862 def summary_failures(self): 

863 if self.config.option.tbstyle != "no": 

864 reports = self.getreports("failed") 

865 if not reports: 

866 return 

867 self.write_sep("=", "FAILURES") 

868 if self.config.option.tbstyle == "line": 

869 for rep in reports: 

870 line = self._getcrashline(rep) 

871 self.write_line(line) 

872 else: 

873 for rep in reports: 

874 msg = self._getfailureheadline(rep) 

875 self.write_sep("_", msg, red=True, bold=True) 

876 self._outrep_summary(rep) 

877 self._handle_teardown_sections(rep.nodeid) 

878 

879 def summary_errors(self): 

880 if self.config.option.tbstyle != "no": 

881 reports = self.getreports("error") 

882 if not reports: 

883 return 

884 self.write_sep("=", "ERRORS") 

885 for rep in self.stats["error"]: 

886 msg = self._getfailureheadline(rep) 

887 if rep.when == "collect": 

888 msg = "ERROR collecting " + msg 

889 else: 

890 msg = "ERROR at {} of {}".format(rep.when, msg) 

891 self.write_sep("_", msg, red=True, bold=True) 

892 self._outrep_summary(rep) 

893 

894 def _outrep_summary(self, rep): 

895 rep.toterminal(self._tw) 

896 showcapture = self.config.option.showcapture 

897 if showcapture == "no": 

898 return 

899 for secname, content in rep.sections: 

900 if showcapture != "all" and showcapture not in secname: 

901 continue 

902 self._tw.sep("-", secname) 

903 if content[-1:] == "\n": 

904 content = content[:-1] 

905 self._tw.line(content) 

906 

907 def summary_stats(self): 

908 if self.verbosity < -1: 

909 return 

910 

911 session_duration = time.time() - self._sessionstarttime 

912 (parts, main_color) = build_summary_stats_line(self.stats) 

913 line_parts = [] 

914 

915 display_sep = self.verbosity >= 0 

916 if display_sep: 

917 fullwidth = self._tw.fullwidth 

918 for text, markup in parts: 

919 with_markup = self._tw.markup(text, **markup) 

920 if display_sep: 

921 fullwidth += len(with_markup) - len(text) 

922 line_parts.append(with_markup) 

923 msg = ", ".join(line_parts) 

924 

925 main_markup = {main_color: True} 

926 duration = " in {}".format(format_session_duration(session_duration)) 

927 duration_with_markup = self._tw.markup(duration, **main_markup) 

928 if display_sep: 

929 fullwidth += len(duration_with_markup) - len(duration) 

930 msg += duration_with_markup 

931 

932 if display_sep: 

933 markup_for_end_sep = self._tw.markup("", **main_markup) 

934 if markup_for_end_sep.endswith("\x1b[0m"): 

935 markup_for_end_sep = markup_for_end_sep[:-4] 

936 fullwidth += len(markup_for_end_sep) 

937 msg += markup_for_end_sep 

938 

939 if display_sep: 

940 self.write_sep("=", msg, fullwidth=fullwidth, **main_markup) 

941 else: 

942 self.write_line(msg, **main_markup) 

943 

944 def short_test_summary(self) -> None: 

945 if not self.reportchars: 

946 return 

947 

948 def show_simple(stat, lines: List[str]) -> None: 

949 failed = self.stats.get(stat, []) 

950 if not failed: 

951 return 

952 termwidth = self.writer.fullwidth 

953 config = self.config 

954 for rep in failed: 

955 line = _get_line_with_reprcrash_message(config, rep, termwidth) 

956 lines.append(line) 

957 

958 def show_xfailed(lines: List[str]) -> None: 

959 xfailed = self.stats.get("xfailed", []) 

960 for rep in xfailed: 

961 verbose_word = rep._get_verbose_word(self.config) 

962 pos = _get_pos(self.config, rep) 

963 lines.append("{} {}".format(verbose_word, pos)) 

964 reason = rep.wasxfail 

965 if reason: 

966 lines.append(" " + str(reason)) 

967 

968 def show_xpassed(lines: List[str]) -> None: 

969 xpassed = self.stats.get("xpassed", []) 

970 for rep in xpassed: 

971 verbose_word = rep._get_verbose_word(self.config) 

972 pos = _get_pos(self.config, rep) 

973 reason = rep.wasxfail 

974 lines.append("{} {} {}".format(verbose_word, pos, reason)) 

975 

976 def show_skipped(lines: List[str]) -> None: 

977 skipped = self.stats.get("skipped", []) 

978 fskips = _folded_skips(skipped) if skipped else [] 

979 if not fskips: 

980 return 

981 verbose_word = skipped[0]._get_verbose_word(self.config) 

982 for num, fspath, lineno, reason in fskips: 

983 if reason.startswith("Skipped: "): 

984 reason = reason[9:] 

985 if lineno is not None: 

986 lines.append( 

987 "%s [%d] %s:%d: %s" 

988 % (verbose_word, num, fspath, lineno, reason) 

989 ) 

990 else: 

991 lines.append("%s [%d] %s: %s" % (verbose_word, num, fspath, reason)) 

992 

993 REPORTCHAR_ACTIONS = { 

994 "x": show_xfailed, 

995 "X": show_xpassed, 

996 "f": partial(show_simple, "failed"), 

997 "F": partial(show_simple, "failed"), 

998 "s": show_skipped, 

999 "S": show_skipped, 

1000 "p": partial(show_simple, "passed"), 

1001 "E": partial(show_simple, "error"), 

1002 } # type: Mapping[str, Callable[[List[str]], None]] 

1003 

1004 lines = [] # type: List[str] 

1005 for char in self.reportchars: 

1006 action = REPORTCHAR_ACTIONS.get(char) 

1007 if action: # skipping e.g. "P" (passed with output) here. 

1008 action(lines) 

1009 

1010 if lines: 

1011 self.write_sep("=", "short test summary info") 

1012 for line in lines: 

1013 self.write_line(line) 

1014 

1015 

1016def _get_pos(config, rep): 

1017 nodeid = config.cwd_relative_nodeid(rep.nodeid) 

1018 return nodeid 

1019 

1020 

1021def _get_line_with_reprcrash_message(config, rep, termwidth): 

1022 """Get summary line for a report, trying to add reprcrash message.""" 

1023 from wcwidth import wcswidth 

1024 

1025 verbose_word = rep._get_verbose_word(config) 

1026 pos = _get_pos(config, rep) 

1027 

1028 line = "{} {}".format(verbose_word, pos) 

1029 len_line = wcswidth(line) 

1030 ellipsis, len_ellipsis = "...", 3 

1031 if len_line > termwidth - len_ellipsis: 

1032 # No space for an additional message. 

1033 return line 

1034 

1035 try: 

1036 msg = rep.longrepr.reprcrash.message 

1037 except AttributeError: 

1038 pass 

1039 else: 

1040 # Only use the first line. 

1041 i = msg.find("\n") 

1042 if i != -1: 

1043 msg = msg[:i] 

1044 len_msg = wcswidth(msg) 

1045 

1046 sep, len_sep = " - ", 3 

1047 max_len_msg = termwidth - len_line - len_sep 

1048 if max_len_msg >= len_ellipsis: 

1049 if len_msg > max_len_msg: 

1050 max_len_msg -= len_ellipsis 

1051 msg = msg[:max_len_msg] 

1052 while wcswidth(msg) > max_len_msg: 

1053 msg = msg[:-1] 

1054 msg += ellipsis 

1055 line += sep + msg 

1056 return line 

1057 

1058 

1059def _folded_skips(skipped): 

1060 d = {} 

1061 for event in skipped: 

1062 key = event.longrepr 

1063 assert len(key) == 3, (event, key) 

1064 keywords = getattr(event, "keywords", {}) 

1065 # folding reports with global pytestmark variable 

1066 # this is workaround, because for now we cannot identify the scope of a skip marker 

1067 # TODO: revisit after marks scope would be fixed 

1068 if ( 

1069 event.when == "setup" 

1070 and "skip" in keywords 

1071 and "pytestmark" not in keywords 

1072 ): 

1073 key = (key[0], None, key[2]) 

1074 d.setdefault(key, []).append(event) 

1075 values = [] 

1076 for key, events in d.items(): 

1077 values.append((len(events),) + key) 

1078 return values 

1079 

1080 

1081_color_for_type = { 

1082 "failed": "red", 

1083 "error": "red", 

1084 "warnings": "yellow", 

1085 "passed": "green", 

1086} 

1087_color_for_type_default = "yellow" 

1088 

1089 

1090def _make_plural(count, noun): 

1091 # No need to pluralize words such as `failed` or `passed`. 

1092 if noun not in ["error", "warnings"]: 

1093 return count, noun 

1094 

1095 # The `warnings` key is plural. To avoid API breakage, we keep it that way but 

1096 # set it to singular here so we can determine plurality in the same way as we do 

1097 # for `error`. 

1098 noun = noun.replace("warnings", "warning") 

1099 

1100 return count, noun + "s" if count != 1 else noun 

1101 

1102 

1103def _get_main_color(stats) -> Tuple[str, List[str]]: 

1104 known_types = ( 

1105 "failed passed skipped deselected xfailed xpassed warnings error".split() 

1106 ) 

1107 unknown_type_seen = False 

1108 for found_type in stats.keys(): 

1109 if found_type not in known_types: 

1110 if found_type: # setup/teardown reports have an empty key, ignore them 

1111 known_types.append(found_type) 

1112 unknown_type_seen = True 

1113 

1114 # main color 

1115 if "failed" in stats or "error" in stats: 

1116 main_color = "red" 

1117 elif "warnings" in stats or unknown_type_seen: 

1118 main_color = "yellow" 

1119 elif "passed" in stats: 

1120 main_color = "green" 

1121 else: 

1122 main_color = "yellow" 

1123 

1124 return main_color, known_types 

1125 

1126 

1127def build_summary_stats_line(stats): 

1128 main_color, known_types = _get_main_color(stats) 

1129 

1130 parts = [] 

1131 for key in known_types: 

1132 reports = stats.get(key, None) 

1133 if reports: 

1134 count = sum( 

1135 1 for rep in reports if getattr(rep, "count_towards_summary", True) 

1136 ) 

1137 color = _color_for_type.get(key, _color_for_type_default) 

1138 markup = {color: True, "bold": color == main_color} 

1139 parts.append(("%d %s" % _make_plural(count, key), markup)) 

1140 

1141 if not parts: 

1142 parts = [("no tests ran", {_color_for_type_default: True})] 

1143 

1144 return parts, main_color 

1145 

1146 

1147def _plugin_nameversions(plugininfo) -> List[str]: 

1148 values = [] # type: List[str] 

1149 for plugin, dist in plugininfo: 

1150 # gets us name and version! 

1151 name = "{dist.project_name}-{dist.version}".format(dist=dist) 

1152 # questionable convenience, but it keeps things short 

1153 if name.startswith("pytest-"): 

1154 name = name[7:] 

1155 # we decided to print python package names 

1156 # they can have more than one plugin 

1157 if name not in values: 

1158 values.append(name) 

1159 return values 

1160 

1161 

1162def format_session_duration(seconds: float) -> str: 

1163 """Format the given seconds in a human readable manner to show in the final summary""" 

1164 if seconds < 60: 

1165 return "{:.2f}s".format(seconds) 

1166 else: 

1167 dt = datetime.timedelta(seconds=int(seconds)) 

1168 return "{:.2f}s ({})".format(seconds, dt)