Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/_pytest/terminal.py : 27%

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.
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
25import attr
26import pluggy
27import py
28from more_itertools import collapse
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
50if TYPE_CHECKING:
51 from typing_extensions import Literal
53 from _pytest.main import Session
56REPORT_COLLECTING_RESOLUTION = 0.5
58KNOWN_TYPES = (
59 "failed",
60 "passed",
61 "skipped",
62 "deselected",
63 "xfailed",
64 "xpassed",
65 "warnings",
66 "error",
67)
69_REPORTCHARS_DEFAULT = "fE"
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
77 used to unify verbosity handling
78 """
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 )
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
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 )
218 parser.addini(
219 "console_output_style",
220 help='console output: "classic", or with additional progress information ("progress" (percentage) | "count").',
221 default="progress",
222 )
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:
230 def mywriter(tags, args):
231 msg = " ".join(map(str, args))
232 reporter.write_line("[traceconfig] " + msg)
234 config.trace.root.setprocessor("pytest:config", mywriter)
237def getreportopt(config: Config) -> str:
238 reportchars = config.option.reportchars # type: str
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
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", "")
259 return reportopts
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"
270 outcome = report.outcome # type: str
271 if report.when in ("collect", "setup", "teardown") and outcome == "failed":
272 outcome = "error"
273 letter = "E"
275 return outcome, letter, outcome.upper()
278@attr.s
279class WarningReport:
280 """
281 Simple structure to hold warnings information captured by ``pytest_warning_recorded``.
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 """
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
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
315class TerminalReporter:
316 def __init__(self, config: Config, file: Optional[TextIO] = None) -> None:
317 import _pytest.config
319 self.config = config
320 self._numcollected = 0
321 self._session = None # type: Optional[Session]
322 self._showfspath = None # type: Optional[bool]
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]
342 @property
343 def writer(self) -> TerminalWriter:
344 warnings.warn(TERMINALWRITER_WRITER, stacklevel=2)
345 return self._tw
347 @writer.setter
348 def writer(self, value: TerminalWriter) -> None:
349 warnings.warn(TERMINALWRITER_WRITER, stacklevel=2)
350 self._tw = value
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
368 @property
369 def verbosity(self) -> int:
370 verbosity = self.config.option.verbose # type: int
371 return verbosity
373 @property
374 def showheader(self) -> bool:
375 return self.verbosity >= 0
377 @property
378 def no_header(self) -> bool:
379 return bool(self.config.option.no_header)
381 @property
382 def no_summary(self) -> bool:
383 return bool(self.config.option.no_summary)
385 @property
386 def showfspath(self) -> bool:
387 if self._showfspath is None:
388 return self.verbosity >= 0
389 return self._showfspath
391 @showfspath.setter
392 def showfspath(self, value: Optional[bool]) -> None:
393 self._showfspath = value
395 @property
396 def showlongtestinfo(self) -> bool:
397 return self.verbosity > 0
399 def hasopt(self, char: str) -> bool:
400 char = {"xfailed": "x", "skipped": "s"}.get(char, char)
401 return char in self.reportchars
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)
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
425 def ensure_newline(self) -> None:
426 if self.currentfspath:
427 self._tw.line()
428 self.currentfspath = None
430 def write(self, content: str, *, flush: bool = False, **markup: bool) -> None:
431 self._tw.write(content, flush=flush, **markup)
433 def flush(self) -> None:
434 self._tw.flush()
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)
442 def rewrite(self, line: str, **markup: bool) -> None:
443 """
444 Rewinds the terminal cursor to the beginning and writes the given line.
446 :kwarg erase: if True, will also add spaces until the full terminal width to ensure
447 previous lines are properly erased.
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)
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)
470 def section(self, title: str, sep: str = "=", **kw: bool) -> None:
471 self._tw.sep(sep, title, **kw)
473 def line(self, msg: str, **kw: bool) -> None:
474 self._tw.line(msg, **kw)
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()
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
487 def pytest_warning_recorded(
488 self, warning_message: warnings.WarningMessage, nodeid: str,
489 ) -> None:
490 from _pytest.warnings import warning_record_to_str
492 fslocation = warning_message.filename, warning_message.lineno
493 message = warning_record_to_str(warning_message)
495 warning_report = WarningReport(
496 fslocation=fslocation, message=message, nodeid=nodeid
497 )
498 self._add_stats("warnings", [warning_report])
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)
508 def pytest_deselected(self, items: Sequence[Item]) -> None:
509 self._add_stats("deselected", items)
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()
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()
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
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%]")
589 self._progress_nodeids_reported.add(nodeid)
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})
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%]"
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})
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
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)
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()
648 def report_collect(self, final: bool = False) -> None:
649 if self.config.option.verbose < 0:
650 return
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
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)
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)
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)
724 def pytest_report_header(self, config: Config) -> List[str]:
725 line = "rootdir: %s" % config.rootdir
727 if config.inifile:
728 line += ", configfile: " + config.rootdir.bestrelpath(config.inifile)
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]
736 plugininfo = config.pluginmanager.list_plugin_distinfo()
737 if plugininfo:
738 result.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo)))
739 return result
741 def pytest_collection_finish(self, session: "Session") -> None:
742 self.report_collect(True)
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)
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)
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)
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))
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()
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()
836 def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None:
837 self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True)
839 def pytest_unconfigure(self) -> None:
840 if self._keyboardinterrupt_memo is not None:
841 self._report_keyboardinterrupt()
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 )
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
869 # collect_fspath comes from testid which has a "/"-normalized path
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 + " "
881 def _getfailureheadline(self, rep):
882 head_line = rep.head_line
883 if head_line:
884 return head_line
885 return "test session" # XXX?
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 ""
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
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
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
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)
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)
936 if len(locations) < 10:
937 return "\n".join(map(str, locations))
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 )
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")
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)
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 ]
985 def _handle_teardown_sections(self, nodeid: str) -> None:
986 for report in self._get_teardown_reports(nodeid):
987 self.print_teardown_sections(report)
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)
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)
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)
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)
1047 def summary_stats(self) -> None:
1048 if self.verbosity < -1:
1049 return
1051 session_duration = timing.time() - self._sessionstarttime
1052 (parts, main_color) = self.build_summary_stats_line()
1053 line_parts = []
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)
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
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
1079 if display_sep:
1080 self.write_sep("=", msg, fullwidth=fullwidth, **main_markup)
1081 else:
1082 self.write_line(msg, **main_markup)
1084 def short_test_summary(self) -> None:
1085 if not self.reportchars:
1086 return
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)
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))
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))
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))
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]]
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)
1148 if lines:
1149 self.write_sep("=", "short test summary info")
1150 for line in lines:
1151 self.write_line(line)
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
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
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))
1181 def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]:
1182 main_color, known_types = self._get_main_color()
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))
1195 if not parts:
1196 parts = [("no tests ran", {_color_for_type_default: True})]
1198 return parts, main_color
1201def _get_pos(config: Config, rep: BaseReport):
1202 nodeid = config.cwd_relative_nodeid(rep.nodeid)
1203 return nodeid
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)
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
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)
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
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
1274_color_for_type = {
1275 "failed": "red",
1276 "error": "red",
1277 "warnings": "yellow",
1278 "passed": "green",
1279}
1280_color_for_type_default = "yellow"
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
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")
1293 return count, noun + "s" if count != 1 else noun
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
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)