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 datetime 

7import inspect 

8import platform 

9import sys 

10import warnings 

11from functools import partial 

12from typing import Any 

13from typing import Callable 

14from typing import Dict 

15from typing import Generator 

16from typing import List 

17from typing import Mapping 

18from typing import Optional 

19from typing import Sequence 

20from typing import Set 

21from typing import TextIO 

22from typing import Tuple 

23from typing import Union 

24 

25import attr 

26import pluggy 

27import py 

28from more_itertools import collapse 

29 

30import pytest 

31from _pytest import nodes 

32from _pytest import timing 

33from _pytest._code import ExceptionInfo 

34from _pytest._code.code import ExceptionRepr 

35from _pytest._io import TerminalWriter 

36from _pytest._io.wcwidth import wcswidth 

37from _pytest.compat import order_preserving_dict 

38from _pytest.compat import TYPE_CHECKING 

39from _pytest.config import _PluggyPlugin 

40from _pytest.config import Config 

41from _pytest.config import ExitCode 

42from _pytest.config.argparsing import Parser 

43from _pytest.deprecated import TERMINALWRITER_WRITER 

44from _pytest.nodes import Item 

45from _pytest.nodes import Node 

46from _pytest.reports import BaseReport 

47from _pytest.reports import CollectReport 

48from _pytest.reports import TestReport 

49 

50if TYPE_CHECKING: 

51 from typing_extensions import Literal 

52 

53 from _pytest.main import Session 

54 

55 

56REPORT_COLLECTING_RESOLUTION = 0.5 

57 

58KNOWN_TYPES = ( 

59 "failed", 

60 "passed", 

61 "skipped", 

62 "deselected", 

63 "xfailed", 

64 "xpassed", 

65 "warnings", 

66 "error", 

67) 

68 

69_REPORTCHARS_DEFAULT = "fE" 

70 

71 

72class MoreQuietAction(argparse.Action): 

73 """ 

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

75 the legacy quiet attribute at the same time 

76 

77 used to unify verbosity handling 

78 """ 

79 

80 def __init__( 

81 self, 

82 option_strings: Sequence[str], 

83 dest: str, 

84 default: object = None, 

85 required: bool = False, 

86 help: Optional[str] = None, 

87 ) -> None: 

88 super().__init__( 

89 option_strings=option_strings, 

90 dest=dest, 

91 nargs=0, 

92 default=default, 

93 required=required, 

94 help=help, 

95 ) 

96 

97 def __call__( 

98 self, 

99 parser: argparse.ArgumentParser, 

100 namespace: argparse.Namespace, 

101 values: Union[str, Sequence[object], None], 

102 option_string: Optional[str] = None, 

103 ) -> None: 

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

105 setattr(namespace, self.dest, new_count) 

106 # todo Deprecate config.quiet 

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

108 

109 

110def pytest_addoption(parser: Parser) -> None: 

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

112 group._addoption( 

113 "-v", 

114 "--verbose", 

115 action="count", 

116 default=0, 

117 dest="verbose", 

118 help="increase verbosity.", 

119 ) 

120 group._addoption( 

121 "--no-header", 

122 action="store_true", 

123 default=False, 

124 dest="no_header", 

125 help="disable header", 

126 ) 

127 group._addoption( 

128 "--no-summary", 

129 action="store_true", 

130 default=False, 

131 dest="no_summary", 

132 help="disable summary", 

133 ) 

134 group._addoption( 

135 "-q", 

136 "--quiet", 

137 action=MoreQuietAction, 

138 default=0, 

139 dest="verbose", 

140 help="decrease verbosity.", 

141 ) 

142 group._addoption( 

143 "--verbosity", 

144 dest="verbose", 

145 type=int, 

146 default=0, 

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

148 ) 

149 group._addoption( 

150 "-r", 

151 action="store", 

152 dest="reportchars", 

153 default=_REPORTCHARS_DEFAULT, 

154 metavar="chars", 

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

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

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

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

159 "'N' can be used to reset the list. (default: 'fE').", 

160 ) 

161 group._addoption( 

162 "--disable-warnings", 

163 "--disable-pytest-warnings", 

164 default=False, 

165 dest="disable_warnings", 

166 action="store_true", 

167 help="disable warnings summary", 

168 ) 

169 group._addoption( 

170 "-l", 

171 "--showlocals", 

172 action="store_true", 

173 dest="showlocals", 

174 default=False, 

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

176 ) 

177 group._addoption( 

178 "--tb", 

179 metavar="style", 

180 action="store", 

181 dest="tbstyle", 

182 default="auto", 

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

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

185 ) 

186 group._addoption( 

187 "--show-capture", 

188 action="store", 

189 dest="showcapture", 

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

191 default="all", 

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

193 "Default is 'all'.", 

194 ) 

195 group._addoption( 

196 "--fulltrace", 

197 "--full-trace", 

198 action="store_true", 

199 default=False, 

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

201 ) 

202 group._addoption( 

203 "--color", 

204 metavar="color", 

205 action="store", 

206 dest="color", 

207 default="auto", 

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

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

210 ) 

211 group._addoption( 

212 "--code-highlight", 

213 default="yes", 

214 choices=["yes", "no"], 

215 help="Whether code should be highlighted (only if --color is also enabled)", 

216 ) 

217 

218 parser.addini( 

219 "console_output_style", 

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

221 default="progress", 

222 ) 

223 

224 

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

226 reporter = TerminalReporter(config, sys.stdout) 

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

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

229 

230 def mywriter(tags, args): 

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

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

233 

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

235 

236 

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

238 reportchars = config.option.reportchars # type: str 

239 

240 old_aliases = {"F", "S"} 

241 reportopts = "" 

242 for char in reportchars: 

243 if char in old_aliases: 

244 char = char.lower() 

245 if char == "a": 

246 reportopts = "sxXEf" 

247 elif char == "A": 

248 reportopts = "PpsxXEf" 

249 elif char == "N": 

250 reportopts = "" 

251 elif char not in reportopts: 

252 reportopts += char 

253 

254 if not config.option.disable_warnings and "w" not in reportopts: 

255 reportopts = "w" + reportopts 

256 elif config.option.disable_warnings and "w" in reportopts: 

257 reportopts = reportopts.replace("w", "") 

258 

259 return reportopts 

260 

261 

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

263def pytest_report_teststatus(report: BaseReport) -> Tuple[str, str, str]: 

264 letter = "F" 

265 if report.passed: 

266 letter = "." 

267 elif report.skipped: 

268 letter = "s" 

269 

270 outcome = report.outcome # type: str 

271 if report.when in ("collect", "setup", "teardown") and outcome == "failed": 

272 outcome = "error" 

273 letter = "E" 

274 

275 return outcome, letter, outcome.upper() 

276 

277 

278@attr.s 

279class WarningReport: 

280 """ 

281 Simple structure to hold warnings information captured by ``pytest_warning_recorded``. 

282 

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

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

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

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

287 """ 

288 

289 message = attr.ib(type=str) 

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

291 fslocation = attr.ib( 

292 type=Optional[Union[Tuple[str, int], py.path.local]], default=None 

293 ) 

294 count_towards_summary = True 

295 

296 def get_location(self, config: Config) -> Optional[str]: 

297 """ 

298 Returns the more user-friendly information about the location 

299 of a warning, or None. 

300 """ 

301 if self.nodeid: 

302 return self.nodeid 

303 if self.fslocation: 

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

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

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

307 if not relpath: 

308 relpath = str(filename) 

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

310 else: 

311 return str(self.fslocation) 

312 return None 

313 

314 

315class TerminalReporter: 

316 def __init__(self, config: Config, file: Optional[TextIO] = None) -> None: 

317 import _pytest.config 

318 

319 self.config = config 

320 self._numcollected = 0 

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

322 self._showfspath = None # type: Optional[bool] 

323 

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

325 self._main_color = None # type: Optional[str] 

326 self._known_types = None # type: Optional[List] 

327 self.startdir = config.invocation_dir 

328 if file is None: 

329 file = sys.stdout 

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

331 self._screen_width = self._tw.fullwidth 

332 self.currentfspath = None # type: Any 

333 self.reportchars = getreportopt(config) 

334 self.hasmarkup = self._tw.hasmarkup 

335 self.isatty = file.isatty() 

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

337 self._show_progress_info = self._determine_show_progress_info() 

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

339 self._already_displayed_warnings = None # type: Optional[int] 

340 self._keyboardinterrupt_memo = None # type: Optional[ExceptionRepr] 

341 

342 @property 

343 def writer(self) -> TerminalWriter: 

344 warnings.warn(TERMINALWRITER_WRITER, stacklevel=2) 

345 return self._tw 

346 

347 @writer.setter 

348 def writer(self, value: TerminalWriter) -> None: 

349 warnings.warn(TERMINALWRITER_WRITER, stacklevel=2) 

350 self._tw = value 

351 

352 def _determine_show_progress_info(self) -> "Literal['progress', 'count', False]": 

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

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

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

356 return False 

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

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

359 return False 

360 cfg = self.config.getini("console_output_style") # type: str 

361 if cfg == "progress": 

362 return "progress" 

363 elif cfg == "count": 

364 return "count" 

365 else: 

366 return False 

367 

368 @property 

369 def verbosity(self) -> int: 

370 verbosity = self.config.option.verbose # type: int 

371 return verbosity 

372 

373 @property 

374 def showheader(self) -> bool: 

375 return self.verbosity >= 0 

376 

377 @property 

378 def no_header(self) -> bool: 

379 return bool(self.config.option.no_header) 

380 

381 @property 

382 def no_summary(self) -> bool: 

383 return bool(self.config.option.no_summary) 

384 

385 @property 

386 def showfspath(self) -> bool: 

387 if self._showfspath is None: 

388 return self.verbosity >= 0 

389 return self._showfspath 

390 

391 @showfspath.setter 

392 def showfspath(self, value: Optional[bool]) -> None: 

393 self._showfspath = value 

394 

395 @property 

396 def showlongtestinfo(self) -> bool: 

397 return self.verbosity > 0 

398 

399 def hasopt(self, char: str) -> bool: 

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

401 return char in self.reportchars 

402 

403 def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: 

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

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

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

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

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

409 self._write_progress_information_filling_space() 

410 self.currentfspath = fspath 

411 relfspath = self.startdir.bestrelpath(fspath) 

412 self._tw.line() 

413 self._tw.write(relfspath + " ") 

414 self._tw.write(res, flush=True, **markup) 

415 

416 def write_ensure_prefix(self, prefix, extra: str = "", **kwargs) -> None: 

417 if self.currentfspath != prefix: 

418 self._tw.line() 

419 self.currentfspath = prefix 

420 self._tw.write(prefix) 

421 if extra: 

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

423 self.currentfspath = -2 

424 

425 def ensure_newline(self) -> None: 

426 if self.currentfspath: 

427 self._tw.line() 

428 self.currentfspath = None 

429 

430 def write(self, content: str, *, flush: bool = False, **markup: bool) -> None: 

431 self._tw.write(content, flush=flush, **markup) 

432 

433 def flush(self) -> None: 

434 self._tw.flush() 

435 

436 def write_line(self, line: Union[str, bytes], **markup: bool) -> None: 

437 if not isinstance(line, str): 

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

439 self.ensure_newline() 

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

441 

442 def rewrite(self, line: str, **markup: bool) -> None: 

443 """ 

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

445 

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

447 previous lines are properly erased. 

448 

449 The rest of the keyword arguments are markup instructions. 

450 """ 

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

452 if erase: 

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

454 fill = " " * fill_count 

455 else: 

456 fill = "" 

457 line = str(line) 

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

459 

460 def write_sep( 

461 self, 

462 sep: str, 

463 title: Optional[str] = None, 

464 fullwidth: Optional[int] = None, 

465 **markup: bool 

466 ) -> None: 

467 self.ensure_newline() 

468 self._tw.sep(sep, title, fullwidth, **markup) 

469 

470 def section(self, title: str, sep: str = "=", **kw: bool) -> None: 

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

472 

473 def line(self, msg: str, **kw: bool) -> None: 

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

475 

476 def _add_stats(self, category: str, items: Sequence) -> None: 

477 set_main_color = category not in self.stats 

478 self.stats.setdefault(category, []).extend(items) 

479 if set_main_color: 

480 self._set_main_color() 

481 

482 def pytest_internalerror(self, excrepr: ExceptionRepr) -> bool: 

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

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

485 return True 

486 

487 def pytest_warning_recorded( 

488 self, warning_message: warnings.WarningMessage, nodeid: str, 

489 ) -> None: 

490 from _pytest.warnings import warning_record_to_str 

491 

492 fslocation = warning_message.filename, warning_message.lineno 

493 message = warning_record_to_str(warning_message) 

494 

495 warning_report = WarningReport( 

496 fslocation=fslocation, message=message, nodeid=nodeid 

497 ) 

498 self._add_stats("warnings", [warning_report]) 

499 

500 def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: 

501 if self.config.option.traceconfig: 

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

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

504 # which unfortunately captures our output here 

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

506 self.write_line(msg) 

507 

508 def pytest_deselected(self, items: Sequence[Item]) -> None: 

509 self._add_stats("deselected", items) 

510 

511 def pytest_runtest_logstart( 

512 self, nodeid: str, location: Tuple[str, Optional[int], str] 

513 ) -> None: 

514 # ensure that the path is printed before the 

515 # 1st test of a module starts running 

516 if self.showlongtestinfo: 

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

518 self.write_ensure_prefix(line, "") 

519 self.flush() 

520 elif self.showfspath: 

521 self.write_fspath_result(nodeid, "") 

522 self.flush() 

523 

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

525 self._tests_ran = True 

526 rep = report 

527 res = self.config.hook.pytest_report_teststatus( 

528 report=rep, config=self.config 

529 ) # type: Tuple[str, str, str] 

530 category, letter, word = res 

531 if isinstance(word, tuple): 

532 word, markup = word 

533 else: 

534 markup = None 

535 self._add_stats(category, [rep]) 

536 if not letter and not word: 

537 # probably passed setup/teardown 

538 return 

539 running_xdist = hasattr(rep, "node") 

540 if markup is None: 

541 was_xfail = hasattr(report, "wasxfail") 

542 if rep.passed and not was_xfail: 

543 markup = {"green": True} 

544 elif rep.passed and was_xfail: 

545 markup = {"yellow": True} 

546 elif rep.failed: 

547 markup = {"red": True} 

548 elif rep.skipped: 

549 markup = {"yellow": True} 

550 else: 

551 markup = {} 

552 if self.verbosity <= 0: 

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

554 else: 

555 self._progress_nodeids_reported.add(rep.nodeid) 

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

557 if not running_xdist: 

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

559 if self._show_progress_info: 

560 self._write_progress_information_filling_space() 

561 else: 

562 self.ensure_newline() 

563 self._tw.write("[%s]" % rep.node.gateway.id) 

564 if self._show_progress_info: 

565 self._tw.write( 

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

567 ) 

568 else: 

569 self._tw.write(" ") 

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

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

572 self.currentfspath = -2 

573 self.flush() 

574 

575 @property 

576 def _is_last_item(self) -> bool: 

577 assert self._session is not None 

578 return len(self._progress_nodeids_reported) == self._session.testscollected 

579 

580 def pytest_runtest_logfinish(self, nodeid: str) -> None: 

581 assert self._session 

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

583 if self._show_progress_info == "count": 

584 num_tests = self._session.testscollected 

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

586 else: 

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

588 

589 self._progress_nodeids_reported.add(nodeid) 

590 

591 if self._is_last_item: 

592 self._write_progress_information_filling_space() 

593 else: 

594 main_color, _ = self._get_main_color() 

595 w = self._width_of_current_line 

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

597 if past_edge: 

598 msg = self._get_progress_information_message() 

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

600 

601 def _get_progress_information_message(self) -> str: 

602 assert self._session 

603 collected = self._session.testscollected 

604 if self._show_progress_info == "count": 

605 if collected: 

606 progress = self._progress_nodeids_reported 

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

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

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

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

611 else: 

612 if collected: 

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

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

615 ) 

616 return " [100%]" 

617 

618 def _write_progress_information_filling_space(self) -> None: 

619 color, _ = self._get_main_color() 

620 msg = self._get_progress_information_message() 

621 w = self._width_of_current_line 

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

623 self.write(msg.rjust(fill), flush=True, **{color: True}) 

624 

625 @property 

626 def _width_of_current_line(self) -> int: 

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

628 return self._tw.width_of_current_line 

629 

630 def pytest_collection(self) -> None: 

631 if self.isatty: 

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

633 self.write("collecting ... ", flush=True, bold=True) 

634 self._collect_report_last_write = timing.time() 

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

636 self.write("collecting ... ", flush=True, bold=True) 

637 

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

639 if report.failed: 

640 self._add_stats("error", [report]) 

641 elif report.skipped: 

642 self._add_stats("skipped", [report]) 

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

644 self._numcollected += len(items) 

645 if self.isatty: 

646 self.report_collect() 

647 

648 def report_collect(self, final: bool = False) -> None: 

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

650 return 

651 

652 if not final: 

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

654 t = timing.time() 

655 if ( 

656 self._collect_report_last_write is not None 

657 and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION 

658 ): 

659 return 

660 self._collect_report_last_write = t 

661 

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

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

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

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

666 if final: 

667 line = "collected " 

668 else: 

669 line = "collecting " 

670 line += ( 

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

672 ) 

673 if errors: 

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

675 if deselected: 

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

677 if skipped: 

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

679 if self._numcollected > selected > 0: 

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

681 if self.isatty: 

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

683 if final: 

684 self.write("\n") 

685 else: 

686 self.write_line(line) 

687 

688 @pytest.hookimpl(trylast=True) 

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

690 self._session = session 

691 self._sessionstarttime = timing.time() 

692 if not self.showheader: 

693 return 

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

695 verinfo = platform.python_version() 

696 if not self.no_header: 

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

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

699 if pypy_version_info: 

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

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

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

703 pytest.__version__, py.__version__, pluggy.__version__ 

704 ) 

705 if ( 

706 self.verbosity > 0 

707 or self.config.option.debug 

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

709 ): 

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

711 self.write_line(msg) 

712 lines = self.config.hook.pytest_report_header( 

713 config=self.config, startdir=self.startdir 

714 ) 

715 self._write_report_lines_from_hooks(lines) 

716 

717 def _write_report_lines_from_hooks( 

718 self, lines: List[Union[str, List[str]]] 

719 ) -> None: 

720 lines.reverse() 

721 for line in collapse(lines): 

722 self.write_line(line) 

723 

724 def pytest_report_header(self, config: Config) -> List[str]: 

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

726 

727 if config.inifile: 

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

729 

730 testpaths = config.getini("testpaths") 

731 if testpaths and config.args == testpaths: 

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

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

734 result = [line] 

735 

736 plugininfo = config.pluginmanager.list_plugin_distinfo() 

737 if plugininfo: 

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

739 return result 

740 

741 def pytest_collection_finish(self, session: "Session") -> None: 

742 self.report_collect(True) 

743 

744 lines = self.config.hook.pytest_report_collectionfinish( 

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

746 ) 

747 self._write_report_lines_from_hooks(lines) 

748 

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

750 if session.items: 

751 if self.config.option.verbose > -1: 

752 self._tw.line("") 

753 self._printcollecteditems(session.items) 

754 

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

756 if failed: 

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

758 for rep in failed: 

759 rep.toterminal(self._tw) 

760 

761 def _printcollecteditems(self, items: Sequence[Item]) -> None: 

762 # to print out items and their parent collectors 

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

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

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

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

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

768 for item in items: 

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

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

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

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

773 else: 

774 for item in items: 

775 self._tw.line(item.nodeid) 

776 return 

777 stack = [] # type: List[Node] 

778 indent = "" 

779 for item in items: 

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

781 while stack: 

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

783 break 

784 stack.pop() 

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

786 stack.append(col) 

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

788 continue 

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

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

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

792 obj = getattr(col, "obj", None) 

793 doc = inspect.getdoc(obj) if obj else None 

794 if doc: 

795 for line in doc.splitlines(): 

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

797 

798 @pytest.hookimpl(hookwrapper=True) 

799 def pytest_sessionfinish( 

800 self, session: "Session", exitstatus: Union[int, ExitCode] 

801 ): 

802 outcome = yield 

803 outcome.get_result() 

804 self._tw.line("") 

805 summary_exit_codes = ( 

806 ExitCode.OK, 

807 ExitCode.TESTS_FAILED, 

808 ExitCode.INTERRUPTED, 

809 ExitCode.USAGE_ERROR, 

810 ExitCode.NO_TESTS_COLLECTED, 

811 ) 

812 if exitstatus in summary_exit_codes and not self.no_summary: 

813 self.config.hook.pytest_terminal_summary( 

814 terminalreporter=self, exitstatus=exitstatus, config=self.config 

815 ) 

816 if session.shouldfail: 

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

818 if exitstatus == ExitCode.INTERRUPTED: 

819 self._report_keyboardinterrupt() 

820 self._keyboardinterrupt_memo = None 

821 elif session.shouldstop: 

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

823 self.summary_stats() 

824 

825 @pytest.hookimpl(hookwrapper=True) 

826 def pytest_terminal_summary(self) -> Generator[None, None, None]: 

827 self.summary_errors() 

828 self.summary_failures() 

829 self.summary_warnings() 

830 self.summary_passes() 

831 yield 

832 self.short_test_summary() 

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

834 self.summary_warnings() 

835 

836 def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None: 

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

838 

839 def pytest_unconfigure(self) -> None: 

840 if self._keyboardinterrupt_memo is not None: 

841 self._report_keyboardinterrupt() 

842 

843 def _report_keyboardinterrupt(self) -> None: 

844 excrepr = self._keyboardinterrupt_memo 

845 assert excrepr is not None 

846 assert excrepr.reprcrash is not None 

847 msg = excrepr.reprcrash.message 

848 self.write_sep("!", msg) 

849 if "KeyboardInterrupt" in msg: 

850 if self.config.option.fulltrace: 

851 excrepr.toterminal(self._tw) 

852 else: 

853 excrepr.reprcrash.toterminal(self._tw) 

854 self._tw.line( 

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

856 yellow=True, 

857 ) 

858 

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

860 def mkrel(nodeid): 

861 line = self.config.cwd_relative_nodeid(nodeid) 

862 if domain and line.endswith(domain): 

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

864 values = domain.split("[") 

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

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

867 return line 

868 

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

870 

871 if fspath: 

872 res = mkrel(nodeid) 

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

874 "\\", nodes.SEP 

875 ): 

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

877 else: 

878 res = "[location]" 

879 return res + " " 

880 

881 def _getfailureheadline(self, rep): 

882 head_line = rep.head_line 

883 if head_line: 

884 return head_line 

885 return "test session" # XXX? 

886 

887 def _getcrashline(self, rep): 

888 try: 

889 return str(rep.longrepr.reprcrash) 

890 except AttributeError: 

891 try: 

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

893 except AttributeError: 

894 return "" 

895 

896 # 

897 # summaries for sessionfinish 

898 # 

899 def getreports(self, name: str): 

900 values = [] 

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

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

903 values.append(x) 

904 return values 

905 

906 def summary_warnings(self) -> None: 

907 if self.hasopt("w"): 

908 all_warnings = self.stats.get( 

909 "warnings" 

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

911 if not all_warnings: 

912 return 

913 

914 final = self._already_displayed_warnings is not None 

915 if final: 

916 warning_reports = all_warnings[self._already_displayed_warnings :] 

917 else: 

918 warning_reports = all_warnings 

919 self._already_displayed_warnings = len(warning_reports) 

920 if not warning_reports: 

921 return 

922 

923 reports_grouped_by_message = ( 

924 order_preserving_dict() 

925 ) # type: Dict[str, List[WarningReport]] 

926 for wr in warning_reports: 

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

928 

929 def collapsed_location_report(reports: List[WarningReport]) -> str: 

930 locations = [] 

931 for w in reports: 

932 location = w.get_location(self.config) 

933 if location: 

934 locations.append(location) 

935 

936 if len(locations) < 10: 

937 return "\n".join(map(str, locations)) 

938 

939 counts_by_filename = order_preserving_dict() # type: Dict[str, int] 

940 for loc in locations: 

941 key = str(loc).split("::", 1)[0] 

942 counts_by_filename[key] = counts_by_filename.get(key, 0) + 1 

943 return "\n".join( 

944 "{}: {} warning{}".format(k, v, "s" if v > 1 else "") 

945 for k, v in counts_by_filename.items() 

946 ) 

947 

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

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

950 for message, message_reports in reports_grouped_by_message.items(): 

951 maybe_location = collapsed_location_report(message_reports) 

952 if maybe_location: 

953 self._tw.line(maybe_location) 

954 lines = message.splitlines() 

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

956 message = indented.rstrip() 

957 else: 

958 message = message.rstrip() 

959 self._tw.line(message) 

960 self._tw.line() 

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

962 

963 def summary_passes(self) -> None: 

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

965 if self.hasopt("P"): 

966 reports = self.getreports("passed") # type: List[TestReport] 

967 if not reports: 

968 return 

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

970 for rep in reports: 

971 if rep.sections: 

972 msg = self._getfailureheadline(rep) 

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

974 self._outrep_summary(rep) 

975 self._handle_teardown_sections(rep.nodeid) 

976 

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

978 reports = self.getreports("") 

979 return [ 

980 report 

981 for report in reports 

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

983 ] 

984 

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

986 for report in self._get_teardown_reports(nodeid): 

987 self.print_teardown_sections(report) 

988 

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

990 showcapture = self.config.option.showcapture 

991 if showcapture == "no": 

992 return 

993 for secname, content in rep.sections: 

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

995 continue 

996 if "teardown" in secname: 

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

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

999 content = content[:-1] 

1000 self._tw.line(content) 

1001 

1002 def summary_failures(self) -> None: 

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

1004 reports = self.getreports("failed") # type: List[BaseReport] 

1005 if not reports: 

1006 return 

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

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

1009 for rep in reports: 

1010 line = self._getcrashline(rep) 

1011 self.write_line(line) 

1012 else: 

1013 for rep in reports: 

1014 msg = self._getfailureheadline(rep) 

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

1016 self._outrep_summary(rep) 

1017 self._handle_teardown_sections(rep.nodeid) 

1018 

1019 def summary_errors(self) -> None: 

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

1021 reports = self.getreports("error") # type: List[BaseReport] 

1022 if not reports: 

1023 return 

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

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

1026 msg = self._getfailureheadline(rep) 

1027 if rep.when == "collect": 

1028 msg = "ERROR collecting " + msg 

1029 else: 

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

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

1032 self._outrep_summary(rep) 

1033 

1034 def _outrep_summary(self, rep: BaseReport) -> None: 

1035 rep.toterminal(self._tw) 

1036 showcapture = self.config.option.showcapture 

1037 if showcapture == "no": 

1038 return 

1039 for secname, content in rep.sections: 

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

1041 continue 

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

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

1044 content = content[:-1] 

1045 self._tw.line(content) 

1046 

1047 def summary_stats(self) -> None: 

1048 if self.verbosity < -1: 

1049 return 

1050 

1051 session_duration = timing.time() - self._sessionstarttime 

1052 (parts, main_color) = self.build_summary_stats_line() 

1053 line_parts = [] 

1054 

1055 display_sep = self.verbosity >= 0 

1056 if display_sep: 

1057 fullwidth = self._tw.fullwidth 

1058 for text, markup in parts: 

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

1060 if display_sep: 

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

1062 line_parts.append(with_markup) 

1063 msg = ", ".join(line_parts) 

1064 

1065 main_markup = {main_color: True} 

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

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

1068 if display_sep: 

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

1070 msg += duration_with_markup 

1071 

1072 if display_sep: 

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

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

1075 markup_for_end_sep = markup_for_end_sep[:-4] 

1076 fullwidth += len(markup_for_end_sep) 

1077 msg += markup_for_end_sep 

1078 

1079 if display_sep: 

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

1081 else: 

1082 self.write_line(msg, **main_markup) 

1083 

1084 def short_test_summary(self) -> None: 

1085 if not self.reportchars: 

1086 return 

1087 

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

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

1090 if not failed: 

1091 return 

1092 termwidth = self._tw.fullwidth 

1093 config = self.config 

1094 for rep in failed: 

1095 line = _get_line_with_reprcrash_message(config, rep, termwidth) 

1096 lines.append(line) 

1097 

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

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

1100 for rep in xfailed: 

1101 verbose_word = rep._get_verbose_word(self.config) 

1102 pos = _get_pos(self.config, rep) 

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

1104 reason = rep.wasxfail 

1105 if reason: 

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

1107 

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

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

1110 for rep in xpassed: 

1111 verbose_word = rep._get_verbose_word(self.config) 

1112 pos = _get_pos(self.config, rep) 

1113 reason = rep.wasxfail 

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

1115 

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

1117 skipped = self.stats.get("skipped", []) # type: List[CollectReport] 

1118 fskips = _folded_skips(self.startdir, skipped) if skipped else [] 

1119 if not fskips: 

1120 return 

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

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

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

1124 reason = reason[9:] 

1125 if lineno is not None: 

1126 lines.append( 

1127 "%s [%d] %s:%d: %s" 

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

1129 ) 

1130 else: 

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

1132 

1133 REPORTCHAR_ACTIONS = { 

1134 "x": show_xfailed, 

1135 "X": show_xpassed, 

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

1137 "s": show_skipped, 

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

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

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

1141 

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

1143 for char in self.reportchars: 

1144 action = REPORTCHAR_ACTIONS.get(char) 

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

1146 action(lines) 

1147 

1148 if lines: 

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

1150 for line in lines: 

1151 self.write_line(line) 

1152 

1153 def _get_main_color(self) -> Tuple[str, List[str]]: 

1154 if self._main_color is None or self._known_types is None or self._is_last_item: 

1155 self._set_main_color() 

1156 assert self._main_color 

1157 assert self._known_types 

1158 return self._main_color, self._known_types 

1159 

1160 def _determine_main_color(self, unknown_type_seen: bool) -> str: 

1161 stats = self.stats 

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

1163 main_color = "red" 

1164 elif "warnings" in stats or "xpassed" in stats or unknown_type_seen: 

1165 main_color = "yellow" 

1166 elif "passed" in stats or not self._is_last_item: 

1167 main_color = "green" 

1168 else: 

1169 main_color = "yellow" 

1170 return main_color 

1171 

1172 def _set_main_color(self) -> None: 

1173 unknown_types = [] # type: List[str] 

1174 for found_type in self.stats.keys(): 

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

1176 if found_type not in KNOWN_TYPES and found_type not in unknown_types: 

1177 unknown_types.append(found_type) 

1178 self._known_types = list(KNOWN_TYPES) + unknown_types 

1179 self._main_color = self._determine_main_color(bool(unknown_types)) 

1180 

1181 def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: 

1182 main_color, known_types = self._get_main_color() 

1183 

1184 parts = [] 

1185 for key in known_types: 

1186 reports = self.stats.get(key, None) 

1187 if reports: 

1188 count = sum( 

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

1190 ) 

1191 color = _color_for_type.get(key, _color_for_type_default) 

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

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

1194 

1195 if not parts: 

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

1197 

1198 return parts, main_color 

1199 

1200 

1201def _get_pos(config: Config, rep: BaseReport): 

1202 nodeid = config.cwd_relative_nodeid(rep.nodeid) 

1203 return nodeid 

1204 

1205 

1206def _get_line_with_reprcrash_message( 

1207 config: Config, rep: BaseReport, termwidth: int 

1208) -> str: 

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

1210 verbose_word = rep._get_verbose_word(config) 

1211 pos = _get_pos(config, rep) 

1212 

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

1214 len_line = wcswidth(line) 

1215 ellipsis, len_ellipsis = "...", 3 

1216 if len_line > termwidth - len_ellipsis: 

1217 # No space for an additional message. 

1218 return line 

1219 

1220 try: 

1221 # Type ignored intentionally -- possible AttributeError expected. 

1222 msg = rep.longrepr.reprcrash.message # type: ignore[union-attr] 

1223 except AttributeError: 

1224 pass 

1225 else: 

1226 # Only use the first line. 

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

1228 if i != -1: 

1229 msg = msg[:i] 

1230 len_msg = wcswidth(msg) 

1231 

1232 sep, len_sep = " - ", 3 

1233 max_len_msg = termwidth - len_line - len_sep 

1234 if max_len_msg >= len_ellipsis: 

1235 if len_msg > max_len_msg: 

1236 max_len_msg -= len_ellipsis 

1237 msg = msg[:max_len_msg] 

1238 while wcswidth(msg) > max_len_msg: 

1239 msg = msg[:-1] 

1240 msg += ellipsis 

1241 line += sep + msg 

1242 return line 

1243 

1244 

1245def _folded_skips( 

1246 startdir: py.path.local, skipped: Sequence[CollectReport], 

1247) -> List[Tuple[int, str, Optional[int], str]]: 

1248 d = {} # type: Dict[Tuple[str, Optional[int], str], List[CollectReport]] 

1249 for event in skipped: 

1250 assert event.longrepr is not None 

1251 assert len(event.longrepr) == 3, (event, event.longrepr) 

1252 fspath, lineno, reason = event.longrepr 

1253 # For consistency, report all fspaths in relative form. 

1254 fspath = startdir.bestrelpath(py.path.local(fspath)) 

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

1256 # folding reports with global pytestmark variable 

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

1258 # TODO: revisit after marks scope would be fixed 

1259 if ( 

1260 event.when == "setup" 

1261 and "skip" in keywords 

1262 and "pytestmark" not in keywords 

1263 ): 

1264 key = (fspath, None, reason) # type: Tuple[str, Optional[int], str] 

1265 else: 

1266 key = (fspath, lineno, reason) 

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

1268 values = [] # type: List[Tuple[int, str, Optional[int], str]] 

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

1270 values.append((len(events), *key)) 

1271 return values 

1272 

1273 

1274_color_for_type = { 

1275 "failed": "red", 

1276 "error": "red", 

1277 "warnings": "yellow", 

1278 "passed": "green", 

1279} 

1280_color_for_type_default = "yellow" 

1281 

1282 

1283def _make_plural(count: int, noun: str) -> Tuple[int, str]: 

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

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

1286 return count, noun 

1287 

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

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

1290 # for `error`. 

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

1292 

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

1294 

1295 

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

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

1298 for plugin, dist in plugininfo: 

1299 # gets us name and version! 

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

1301 # questionable convenience, but it keeps things short 

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

1303 name = name[7:] 

1304 # we decided to print python package names 

1305 # they can have more than one plugin 

1306 if name not in values: 

1307 values.append(name) 

1308 return values 

1309 

1310 

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

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

1313 if seconds < 60: 

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

1315 else: 

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

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