Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/terminal.py: 54%
903 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
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 collections import Counter
12from functools import partial
13from pathlib import Path
14from typing import Any
15from typing import Callable
16from typing import cast
17from typing import ClassVar
18from typing import Dict
19from typing import Generator
20from typing import List
21from typing import Mapping
22from typing import Optional
23from typing import Sequence
24from typing import Set
25from typing import TextIO
26from typing import Tuple
27from typing import TYPE_CHECKING
28from typing import Union
30import attr
31import pluggy
33import _pytest._version
34from _pytest import nodes
35from _pytest import timing
36from _pytest._code import ExceptionInfo
37from _pytest._code.code import ExceptionRepr
38from _pytest._io import TerminalWriter
39from _pytest._io.wcwidth import wcswidth
40from _pytest.assertion.util import running_on_ci
41from _pytest.compat import final
42from _pytest.config import _PluggyPlugin
43from _pytest.config import Config
44from _pytest.config import ExitCode
45from _pytest.config import hookimpl
46from _pytest.config.argparsing import Parser
47from _pytest.nodes import Item
48from _pytest.nodes import Node
49from _pytest.pathlib import absolutepath
50from _pytest.pathlib import bestrelpath
51from _pytest.reports import BaseReport
52from _pytest.reports import CollectReport
53from _pytest.reports import TestReport
55if TYPE_CHECKING:
56 from typing_extensions import Literal
58 from _pytest.main import Session
61REPORT_COLLECTING_RESOLUTION = 0.5
63KNOWN_TYPES = (
64 "failed",
65 "passed",
66 "skipped",
67 "deselected",
68 "xfailed",
69 "xpassed",
70 "warnings",
71 "error",
72)
74_REPORTCHARS_DEFAULT = "fE"
77class MoreQuietAction(argparse.Action):
78 """A modified copy of the argparse count action which counts down and updates
79 the legacy quiet attribute at the same time.
81 Used to unify verbosity handling.
82 """
84 def __init__(
85 self,
86 option_strings: Sequence[str],
87 dest: str,
88 default: object = None,
89 required: bool = False,
90 help: Optional[str] = None,
91 ) -> None:
92 super().__init__(
93 option_strings=option_strings,
94 dest=dest,
95 nargs=0,
96 default=default,
97 required=required,
98 help=help,
99 )
101 def __call__(
102 self,
103 parser: argparse.ArgumentParser,
104 namespace: argparse.Namespace,
105 values: Union[str, Sequence[object], None],
106 option_string: Optional[str] = None,
107 ) -> None:
108 new_count = getattr(namespace, self.dest, 0) - 1
109 setattr(namespace, self.dest, new_count)
110 # todo Deprecate config.quiet
111 namespace.quiet = getattr(namespace, "quiet", 0) + 1
114def pytest_addoption(parser: Parser) -> None:
115 group = parser.getgroup("terminal reporting", "Reporting", after="general")
116 group._addoption(
117 "-v",
118 "--verbose",
119 action="count",
120 default=0,
121 dest="verbose",
122 help="Increase verbosity",
123 )
124 group._addoption(
125 "--no-header",
126 action="store_true",
127 default=False,
128 dest="no_header",
129 help="Disable header",
130 )
131 group._addoption(
132 "--no-summary",
133 action="store_true",
134 default=False,
135 dest="no_summary",
136 help="Disable summary",
137 )
138 group._addoption(
139 "-q",
140 "--quiet",
141 action=MoreQuietAction,
142 default=0,
143 dest="verbose",
144 help="Decrease verbosity",
145 )
146 group._addoption(
147 "--verbosity",
148 dest="verbose",
149 type=int,
150 default=0,
151 help="Set verbosity. Default: 0.",
152 )
153 group._addoption(
154 "-r",
155 action="store",
156 dest="reportchars",
157 default=_REPORTCHARS_DEFAULT,
158 metavar="chars",
159 help="Show extra test summary info as specified by chars: (f)ailed, "
160 "(E)rror, (s)kipped, (x)failed, (X)passed, "
161 "(p)assed, (P)assed with output, (a)ll except passed (p/P), or (A)ll. "
162 "(w)arnings are enabled by default (see --disable-warnings), "
163 "'N' can be used to reset the list. (default: 'fE').",
164 )
165 group._addoption(
166 "--disable-warnings",
167 "--disable-pytest-warnings",
168 default=False,
169 dest="disable_warnings",
170 action="store_true",
171 help="Disable warnings summary",
172 )
173 group._addoption(
174 "-l",
175 "--showlocals",
176 action="store_true",
177 dest="showlocals",
178 default=False,
179 help="Show locals in tracebacks (disabled by default)",
180 )
181 group._addoption(
182 "--no-showlocals",
183 action="store_false",
184 dest="showlocals",
185 help="Hide locals in tracebacks (negate --showlocals passed through addopts)",
186 )
187 group._addoption(
188 "--tb",
189 metavar="style",
190 action="store",
191 dest="tbstyle",
192 default="auto",
193 choices=["auto", "long", "short", "no", "line", "native"],
194 help="Traceback print mode (auto/long/short/line/native/no)",
195 )
196 group._addoption(
197 "--show-capture",
198 action="store",
199 dest="showcapture",
200 choices=["no", "stdout", "stderr", "log", "all"],
201 default="all",
202 help="Controls how captured stdout/stderr/log is shown on failed tests. "
203 "Default: all.",
204 )
205 group._addoption(
206 "--fulltrace",
207 "--full-trace",
208 action="store_true",
209 default=False,
210 help="Don't cut any tracebacks (default is to cut)",
211 )
212 group._addoption(
213 "--color",
214 metavar="color",
215 action="store",
216 dest="color",
217 default="auto",
218 choices=["yes", "no", "auto"],
219 help="Color terminal output (yes/no/auto)",
220 )
221 group._addoption(
222 "--code-highlight",
223 default="yes",
224 choices=["yes", "no"],
225 help="Whether code should be highlighted (only if --color is also enabled). "
226 "Default: yes.",
227 )
229 parser.addini(
230 "console_output_style",
231 help='Console output: "classic", or with additional progress information '
232 '("progress" (percentage) | "count")',
233 default="progress",
234 )
237def pytest_configure(config: Config) -> None:
238 reporter = TerminalReporter(config, sys.stdout)
239 config.pluginmanager.register(reporter, "terminalreporter")
240 if config.option.debug or config.option.traceconfig:
242 def mywriter(tags, args):
243 msg = " ".join(map(str, args))
244 reporter.write_line("[traceconfig] " + msg)
246 config.trace.root.setprocessor("pytest:config", mywriter)
249def getreportopt(config: Config) -> str:
250 reportchars: str = config.option.reportchars
252 old_aliases = {"F", "S"}
253 reportopts = ""
254 for char in reportchars:
255 if char in old_aliases:
256 char = char.lower()
257 if char == "a":
258 reportopts = "sxXEf"
259 elif char == "A":
260 reportopts = "PpsxXEf"
261 elif char == "N":
262 reportopts = ""
263 elif char not in reportopts:
264 reportopts += char
266 if not config.option.disable_warnings and "w" not in reportopts:
267 reportopts = "w" + reportopts
268 elif config.option.disable_warnings and "w" in reportopts:
269 reportopts = reportopts.replace("w", "")
271 return reportopts
274@hookimpl(trylast=True) # after _pytest.runner
275def pytest_report_teststatus(report: BaseReport) -> Tuple[str, str, str]:
276 letter = "F"
277 if report.passed:
278 letter = "."
279 elif report.skipped:
280 letter = "s"
282 outcome: str = report.outcome
283 if report.when in ("collect", "setup", "teardown") and outcome == "failed":
284 outcome = "error"
285 letter = "E"
287 return outcome, letter, outcome.upper()
290@attr.s(auto_attribs=True)
291class WarningReport:
292 """Simple structure to hold warnings information captured by ``pytest_warning_recorded``.
294 :ivar str message:
295 User friendly message about the warning.
296 :ivar str|None nodeid:
297 nodeid that generated the warning (see ``get_location``).
298 :ivar tuple fslocation:
299 File system location of the source of the warning (see ``get_location``).
300 """
302 message: str
303 nodeid: Optional[str] = None
304 fslocation: Optional[Tuple[str, int]] = None
306 count_towards_summary: ClassVar = True
308 def get_location(self, config: Config) -> Optional[str]:
309 """Return the more user-friendly information about the location of a warning, or None."""
310 if self.nodeid:
311 return self.nodeid
312 if self.fslocation:
313 filename, linenum = self.fslocation
314 relpath = bestrelpath(config.invocation_params.dir, absolutepath(filename))
315 return f"{relpath}:{linenum}"
316 return None
319@final
320class TerminalReporter:
321 def __init__(self, config: Config, file: Optional[TextIO] = None) -> None:
322 import _pytest.config
324 self.config = config
325 self._numcollected = 0
326 self._session: Optional[Session] = None
327 self._showfspath: Optional[bool] = None
329 self.stats: Dict[str, List[Any]] = {}
330 self._main_color: Optional[str] = None
331 self._known_types: Optional[List[str]] = None
332 self.startpath = config.invocation_params.dir
333 if file is None:
334 file = sys.stdout
335 self._tw = _pytest.config.create_terminal_writer(config, file)
336 self._screen_width = self._tw.fullwidth
337 self.currentfspath: Union[None, Path, str, int] = None
338 self.reportchars = getreportopt(config)
339 self.hasmarkup = self._tw.hasmarkup
340 self.isatty = file.isatty()
341 self._progress_nodeids_reported: Set[str] = set()
342 self._show_progress_info = self._determine_show_progress_info()
343 self._collect_report_last_write: Optional[float] = None
344 self._already_displayed_warnings: Optional[int] = None
345 self._keyboardinterrupt_memo: Optional[ExceptionRepr] = None
347 def _determine_show_progress_info(self) -> "Literal['progress', 'count', False]":
348 """Return whether we should display progress information based on the current config."""
349 # do not show progress if we are not capturing output (#3038)
350 if self.config.getoption("capture", "no") == "no":
351 return False
352 # do not show progress if we are showing fixture setup/teardown
353 if self.config.getoption("setupshow", False):
354 return False
355 cfg: str = self.config.getini("console_output_style")
356 if cfg == "progress":
357 return "progress"
358 elif cfg == "count":
359 return "count"
360 else:
361 return False
363 @property
364 def verbosity(self) -> int:
365 verbosity: int = self.config.option.verbose
366 return verbosity
368 @property
369 def showheader(self) -> bool:
370 return self.verbosity >= 0
372 @property
373 def no_header(self) -> bool:
374 return bool(self.config.option.no_header)
376 @property
377 def no_summary(self) -> bool:
378 return bool(self.config.option.no_summary)
380 @property
381 def showfspath(self) -> bool:
382 if self._showfspath is None:
383 return self.verbosity >= 0
384 return self._showfspath
386 @showfspath.setter
387 def showfspath(self, value: Optional[bool]) -> None:
388 self._showfspath = value
390 @property
391 def showlongtestinfo(self) -> bool:
392 return self.verbosity > 0
394 def hasopt(self, char: str) -> bool:
395 char = {"xfailed": "x", "skipped": "s"}.get(char, char)
396 return char in self.reportchars
398 def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None:
399 fspath = self.config.rootpath / nodeid.split("::")[0]
400 if self.currentfspath is None or fspath != self.currentfspath:
401 if self.currentfspath is not None and self._show_progress_info:
402 self._write_progress_information_filling_space()
403 self.currentfspath = fspath
404 relfspath = bestrelpath(self.startpath, fspath)
405 self._tw.line()
406 self._tw.write(relfspath + " ")
407 self._tw.write(res, flush=True, **markup)
409 def write_ensure_prefix(self, prefix: str, extra: str = "", **kwargs) -> None:
410 if self.currentfspath != prefix:
411 self._tw.line()
412 self.currentfspath = prefix
413 self._tw.write(prefix)
414 if extra:
415 self._tw.write(extra, **kwargs)
416 self.currentfspath = -2
418 def ensure_newline(self) -> None:
419 if self.currentfspath:
420 self._tw.line()
421 self.currentfspath = None
423 def write(self, content: str, *, flush: bool = False, **markup: bool) -> None:
424 self._tw.write(content, flush=flush, **markup)
426 def flush(self) -> None:
427 self._tw.flush()
429 def write_line(self, line: Union[str, bytes], **markup: bool) -> None:
430 if not isinstance(line, str):
431 line = str(line, errors="replace")
432 self.ensure_newline()
433 self._tw.line(line, **markup)
435 def rewrite(self, line: str, **markup: bool) -> None:
436 """Rewinds the terminal cursor to the beginning and writes the given line.
438 :param erase:
439 If True, will also add spaces until the full terminal width to ensure
440 previous lines are properly erased.
442 The rest of the keyword arguments are markup instructions.
443 """
444 erase = markup.pop("erase", False)
445 if erase:
446 fill_count = self._tw.fullwidth - len(line) - 1
447 fill = " " * fill_count
448 else:
449 fill = ""
450 line = str(line)
451 self._tw.write("\r" + line + fill, **markup)
453 def write_sep(
454 self,
455 sep: str,
456 title: Optional[str] = None,
457 fullwidth: Optional[int] = None,
458 **markup: bool,
459 ) -> None:
460 self.ensure_newline()
461 self._tw.sep(sep, title, fullwidth, **markup)
463 def section(self, title: str, sep: str = "=", **kw: bool) -> None:
464 self._tw.sep(sep, title, **kw)
466 def line(self, msg: str, **kw: bool) -> None:
467 self._tw.line(msg, **kw)
469 def _add_stats(self, category: str, items: Sequence[Any]) -> None:
470 set_main_color = category not in self.stats
471 self.stats.setdefault(category, []).extend(items)
472 if set_main_color:
473 self._set_main_color()
475 def pytest_internalerror(self, excrepr: ExceptionRepr) -> bool:
476 for line in str(excrepr).split("\n"):
477 self.write_line("INTERNALERROR> " + line)
478 return True
480 def pytest_warning_recorded(
481 self,
482 warning_message: warnings.WarningMessage,
483 nodeid: str,
484 ) -> None:
485 from _pytest.warnings import warning_record_to_str
487 fslocation = warning_message.filename, warning_message.lineno
488 message = warning_record_to_str(warning_message)
490 warning_report = WarningReport(
491 fslocation=fslocation, message=message, nodeid=nodeid
492 )
493 self._add_stats("warnings", [warning_report])
495 def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
496 if self.config.option.traceconfig:
497 msg = f"PLUGIN registered: {plugin}"
498 # XXX This event may happen during setup/teardown time
499 # which unfortunately captures our output here
500 # which garbles our output if we use self.write_line.
501 self.write_line(msg)
503 def pytest_deselected(self, items: Sequence[Item]) -> None:
504 self._add_stats("deselected", items)
506 def pytest_runtest_logstart(
507 self, nodeid: str, location: Tuple[str, Optional[int], str]
508 ) -> None:
509 # Ensure that the path is printed before the
510 # 1st test of a module starts running.
511 if self.showlongtestinfo:
512 line = self._locationline(nodeid, *location)
513 self.write_ensure_prefix(line, "")
514 self.flush()
515 elif self.showfspath:
516 self.write_fspath_result(nodeid, "")
517 self.flush()
519 def pytest_runtest_logreport(self, report: TestReport) -> None:
520 self._tests_ran = True
521 rep = report
522 res: Tuple[
523 str, str, Union[str, Tuple[str, Mapping[str, bool]]]
524 ] = self.config.hook.pytest_report_teststatus(report=rep, config=self.config)
525 category, letter, word = res
526 if not isinstance(word, tuple):
527 markup = None
528 else:
529 word, markup = word
530 self._add_stats(category, [rep])
531 if not letter and not word:
532 # Probably passed setup/teardown.
533 return
534 running_xdist = hasattr(rep, "node")
535 if markup is None:
536 was_xfail = hasattr(report, "wasxfail")
537 if rep.passed and not was_xfail:
538 markup = {"green": True}
539 elif rep.passed and was_xfail:
540 markup = {"yellow": True}
541 elif rep.failed:
542 markup = {"red": True}
543 elif rep.skipped:
544 markup = {"yellow": True}
545 else:
546 markup = {}
547 if self.verbosity <= 0:
548 self._tw.write(letter, **markup)
549 else:
550 self._progress_nodeids_reported.add(rep.nodeid)
551 line = self._locationline(rep.nodeid, *rep.location)
552 if not running_xdist:
553 self.write_ensure_prefix(line, word, **markup)
554 if rep.skipped or hasattr(report, "wasxfail"):
555 reason = _get_raw_skip_reason(rep)
556 if self.config.option.verbose < 2:
557 available_width = (
558 (self._tw.fullwidth - self._tw.width_of_current_line)
559 - len(" [100%]")
560 - 1
561 )
562 formatted_reason = _format_trimmed(
563 " ({})", reason, available_width
564 )
565 else:
566 formatted_reason = f" ({reason})"
568 if reason and formatted_reason is not None:
569 self._tw.write(formatted_reason)
570 if self._show_progress_info:
571 self._write_progress_information_filling_space()
572 else:
573 self.ensure_newline()
574 self._tw.write("[%s]" % rep.node.gateway.id)
575 if self._show_progress_info:
576 self._tw.write(
577 self._get_progress_information_message() + " ", cyan=True
578 )
579 else:
580 self._tw.write(" ")
581 self._tw.write(word, **markup)
582 self._tw.write(" " + line)
583 self.currentfspath = -2
584 self.flush()
586 @property
587 def _is_last_item(self) -> bool:
588 assert self._session is not None
589 return len(self._progress_nodeids_reported) == self._session.testscollected
591 def pytest_runtest_logfinish(self, nodeid: str) -> None:
592 assert self._session
593 if self.verbosity <= 0 and self._show_progress_info:
594 if self._show_progress_info == "count":
595 num_tests = self._session.testscollected
596 progress_length = len(f" [{num_tests}/{num_tests}]")
597 else:
598 progress_length = len(" [100%]")
600 self._progress_nodeids_reported.add(nodeid)
602 if self._is_last_item:
603 self._write_progress_information_filling_space()
604 else:
605 main_color, _ = self._get_main_color()
606 w = self._width_of_current_line
607 past_edge = w + progress_length + 1 >= self._screen_width
608 if past_edge:
609 msg = self._get_progress_information_message()
610 self._tw.write(msg + "\n", **{main_color: True})
612 def _get_progress_information_message(self) -> str:
613 assert self._session
614 collected = self._session.testscollected
615 if self._show_progress_info == "count":
616 if collected:
617 progress = self._progress_nodeids_reported
618 counter_format = f"{{:{len(str(collected))}d}}"
619 format_string = f" [{counter_format}/{{}}]"
620 return format_string.format(len(progress), collected)
621 return f" [ {collected} / {collected} ]"
622 else:
623 if collected:
624 return " [{:3d}%]".format(
625 len(self._progress_nodeids_reported) * 100 // collected
626 )
627 return " [100%]"
629 def _write_progress_information_filling_space(self) -> None:
630 color, _ = self._get_main_color()
631 msg = self._get_progress_information_message()
632 w = self._width_of_current_line
633 fill = self._tw.fullwidth - w - 1
634 self.write(msg.rjust(fill), flush=True, **{color: True})
636 @property
637 def _width_of_current_line(self) -> int:
638 """Return the width of the current line."""
639 return self._tw.width_of_current_line
641 def pytest_collection(self) -> None:
642 if self.isatty:
643 if self.config.option.verbose >= 0:
644 self.write("collecting ... ", flush=True, bold=True)
645 self._collect_report_last_write = timing.time()
646 elif self.config.option.verbose >= 1:
647 self.write("collecting ... ", flush=True, bold=True)
649 def pytest_collectreport(self, report: CollectReport) -> None:
650 if report.failed:
651 self._add_stats("error", [report])
652 elif report.skipped:
653 self._add_stats("skipped", [report])
654 items = [x for x in report.result if isinstance(x, Item)]
655 self._numcollected += len(items)
656 if self.isatty:
657 self.report_collect()
659 def report_collect(self, final: bool = False) -> None:
660 if self.config.option.verbose < 0:
661 return
663 if not final:
664 # Only write "collecting" report every 0.5s.
665 t = timing.time()
666 if (
667 self._collect_report_last_write is not None
668 and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION
669 ):
670 return
671 self._collect_report_last_write = t
673 errors = len(self.stats.get("error", []))
674 skipped = len(self.stats.get("skipped", []))
675 deselected = len(self.stats.get("deselected", []))
676 selected = self._numcollected - deselected
677 line = "collected " if final else "collecting "
678 line += (
679 str(self._numcollected) + " item" + ("" if self._numcollected == 1 else "s")
680 )
681 if errors:
682 line += " / %d error%s" % (errors, "s" if errors != 1 else "")
683 if deselected:
684 line += " / %d deselected" % deselected
685 if skipped:
686 line += " / %d skipped" % skipped
687 if self._numcollected > selected:
688 line += " / %d selected" % selected
689 if self.isatty:
690 self.rewrite(line, bold=True, erase=True)
691 if final:
692 self.write("\n")
693 else:
694 self.write_line(line)
696 @hookimpl(trylast=True)
697 def pytest_sessionstart(self, session: "Session") -> None:
698 self._session = session
699 self._sessionstarttime = timing.time()
700 if not self.showheader:
701 return
702 self.write_sep("=", "test session starts", bold=True)
703 verinfo = platform.python_version()
704 if not self.no_header:
705 msg = f"platform {sys.platform} -- Python {verinfo}"
706 pypy_version_info = getattr(sys, "pypy_version_info", None)
707 if pypy_version_info:
708 verinfo = ".".join(map(str, pypy_version_info[:3]))
709 msg += f"[pypy-{verinfo}-{pypy_version_info[3]}]"
710 msg += ", pytest-{}, pluggy-{}".format(
711 _pytest._version.version, pluggy.__version__
712 )
713 if (
714 self.verbosity > 0
715 or self.config.option.debug
716 or getattr(self.config.option, "pastebin", None)
717 ):
718 msg += " -- " + str(sys.executable)
719 self.write_line(msg)
720 lines = self.config.hook.pytest_report_header(
721 config=self.config, start_path=self.startpath
722 )
723 self._write_report_lines_from_hooks(lines)
725 def _write_report_lines_from_hooks(
726 self, lines: Sequence[Union[str, Sequence[str]]]
727 ) -> None:
728 for line_or_lines in reversed(lines):
729 if isinstance(line_or_lines, str):
730 self.write_line(line_or_lines)
731 else:
732 for line in line_or_lines:
733 self.write_line(line)
735 def pytest_report_header(self, config: Config) -> List[str]:
736 line = "rootdir: %s" % config.rootpath
738 if config.inipath:
739 line += ", configfile: " + bestrelpath(config.rootpath, config.inipath)
741 if config.args_source == Config.ArgsSource.TESTPATHS:
742 testpaths: List[str] = config.getini("testpaths")
743 line += ", testpaths: {}".format(", ".join(testpaths))
745 result = [line]
747 plugininfo = config.pluginmanager.list_plugin_distinfo()
748 if plugininfo:
749 result.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo)))
750 return result
752 def pytest_collection_finish(self, session: "Session") -> None:
753 self.report_collect(True)
755 lines = self.config.hook.pytest_report_collectionfinish(
756 config=self.config,
757 start_path=self.startpath,
758 items=session.items,
759 )
760 self._write_report_lines_from_hooks(lines)
762 if self.config.getoption("collectonly"):
763 if session.items:
764 if self.config.option.verbose > -1:
765 self._tw.line("")
766 self._printcollecteditems(session.items)
768 failed = self.stats.get("failed")
769 if failed:
770 self._tw.sep("!", "collection failures")
771 for rep in failed:
772 rep.toterminal(self._tw)
774 def _printcollecteditems(self, items: Sequence[Item]) -> None:
775 if self.config.option.verbose < 0:
776 if self.config.option.verbose < -1:
777 counts = Counter(item.nodeid.split("::", 1)[0] for item in items)
778 for name, count in sorted(counts.items()):
779 self._tw.line("%s: %d" % (name, count))
780 else:
781 for item in items:
782 self._tw.line(item.nodeid)
783 return
784 stack: List[Node] = []
785 indent = ""
786 for item in items:
787 needed_collectors = item.listchain()[1:] # strip root node
788 while stack:
789 if stack == needed_collectors[: len(stack)]:
790 break
791 stack.pop()
792 for col in needed_collectors[len(stack) :]:
793 stack.append(col)
794 indent = (len(stack) - 1) * " "
795 self._tw.line(f"{indent}{col}")
796 if self.config.option.verbose >= 1:
797 obj = getattr(col, "obj", None)
798 doc = inspect.getdoc(obj) if obj else None
799 if doc:
800 for line in doc.splitlines():
801 self._tw.line("{}{}".format(indent + " ", line))
803 @hookimpl(hookwrapper=True)
804 def pytest_sessionfinish(
805 self, session: "Session", exitstatus: Union[int, ExitCode]
806 ):
807 outcome = yield
808 outcome.get_result()
809 self._tw.line("")
810 summary_exit_codes = (
811 ExitCode.OK,
812 ExitCode.TESTS_FAILED,
813 ExitCode.INTERRUPTED,
814 ExitCode.USAGE_ERROR,
815 ExitCode.NO_TESTS_COLLECTED,
816 )
817 if exitstatus in summary_exit_codes and not self.no_summary:
818 self.config.hook.pytest_terminal_summary(
819 terminalreporter=self, exitstatus=exitstatus, config=self.config
820 )
821 if session.shouldfail:
822 self.write_sep("!", str(session.shouldfail), red=True)
823 if exitstatus == ExitCode.INTERRUPTED:
824 self._report_keyboardinterrupt()
825 self._keyboardinterrupt_memo = None
826 elif session.shouldstop:
827 self.write_sep("!", str(session.shouldstop), red=True)
828 self.summary_stats()
830 @hookimpl(hookwrapper=True)
831 def pytest_terminal_summary(self) -> Generator[None, None, None]:
832 self.summary_errors()
833 self.summary_failures()
834 self.summary_warnings()
835 self.summary_passes()
836 yield
837 self.short_test_summary()
838 # Display any extra warnings from teardown here (if any).
839 self.summary_warnings()
841 def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None:
842 self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True)
844 def pytest_unconfigure(self) -> None:
845 if self._keyboardinterrupt_memo is not None:
846 self._report_keyboardinterrupt()
848 def _report_keyboardinterrupt(self) -> None:
849 excrepr = self._keyboardinterrupt_memo
850 assert excrepr is not None
851 assert excrepr.reprcrash is not None
852 msg = excrepr.reprcrash.message
853 self.write_sep("!", msg)
854 if "KeyboardInterrupt" in msg:
855 if self.config.option.fulltrace:
856 excrepr.toterminal(self._tw)
857 else:
858 excrepr.reprcrash.toterminal(self._tw)
859 self._tw.line(
860 "(to show a full traceback on KeyboardInterrupt use --full-trace)",
861 yellow=True,
862 )
864 def _locationline(
865 self, nodeid: str, fspath: str, lineno: Optional[int], domain: str
866 ) -> str:
867 def mkrel(nodeid: str) -> str:
868 line = self.config.cwd_relative_nodeid(nodeid)
869 if domain and line.endswith(domain):
870 line = line[: -len(domain)]
871 values = domain.split("[")
872 values[0] = values[0].replace(".", "::") # don't replace '.' in params
873 line += "[".join(values)
874 return line
876 # collect_fspath comes from testid which has a "/"-normalized path.
877 if fspath:
878 res = mkrel(nodeid)
879 if self.verbosity >= 2 and nodeid.split("::")[0] != fspath.replace(
880 "\\", nodes.SEP
881 ):
882 res += " <- " + bestrelpath(self.startpath, Path(fspath))
883 else:
884 res = "[location]"
885 return res + " "
887 def _getfailureheadline(self, rep):
888 head_line = rep.head_line
889 if head_line:
890 return head_line
891 return "test session" # XXX?
893 def _getcrashline(self, rep):
894 try:
895 return str(rep.longrepr.reprcrash)
896 except AttributeError:
897 try:
898 return str(rep.longrepr)[:50]
899 except AttributeError:
900 return ""
902 #
903 # Summaries for sessionfinish.
904 #
905 def getreports(self, name: str):
906 return [x for x in self.stats.get(name, ()) if not hasattr(x, "_pdbshown")]
908 def summary_warnings(self) -> None:
909 if self.hasopt("w"):
910 all_warnings: Optional[List[WarningReport]] = self.stats.get("warnings")
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: Dict[str, List[WarningReport]] = {}
924 for wr in warning_reports:
925 reports_grouped_by_message.setdefault(wr.message, []).append(wr)
927 def collapsed_location_report(reports: List[WarningReport]) -> str:
928 locations = []
929 for w in reports:
930 location = w.get_location(self.config)
931 if location:
932 locations.append(location)
934 if len(locations) < 10:
935 return "\n".join(map(str, locations))
937 counts_by_filename = Counter(
938 str(loc).split("::", 1)[0] for loc in locations
939 )
940 return "\n".join(
941 "{}: {} warning{}".format(k, v, "s" if v > 1 else "")
942 for k, v in counts_by_filename.items()
943 )
945 title = "warnings summary (final)" if final else "warnings summary"
946 self.write_sep("=", title, yellow=True, bold=False)
947 for message, message_reports in reports_grouped_by_message.items():
948 maybe_location = collapsed_location_report(message_reports)
949 if maybe_location:
950 self._tw.line(maybe_location)
951 lines = message.splitlines()
952 indented = "\n".join(" " + x for x in lines)
953 message = indented.rstrip()
954 else:
955 message = message.rstrip()
956 self._tw.line(message)
957 self._tw.line()
958 self._tw.line(
959 "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html"
960 )
962 def summary_passes(self) -> None:
963 if self.config.option.tbstyle != "no":
964 if self.hasopt("P"):
965 reports: List[TestReport] = self.getreports("passed")
966 if not reports:
967 return
968 self.write_sep("=", "PASSES")
969 for rep in reports:
970 if rep.sections:
971 msg = self._getfailureheadline(rep)
972 self.write_sep("_", msg, green=True, bold=True)
973 self._outrep_summary(rep)
974 self._handle_teardown_sections(rep.nodeid)
976 def _get_teardown_reports(self, nodeid: str) -> List[TestReport]:
977 reports = self.getreports("")
978 return [
979 report
980 for report in reports
981 if report.when == "teardown" and report.nodeid == nodeid
982 ]
984 def _handle_teardown_sections(self, nodeid: str) -> None:
985 for report in self._get_teardown_reports(nodeid):
986 self.print_teardown_sections(report)
988 def print_teardown_sections(self, rep: TestReport) -> None:
989 showcapture = self.config.option.showcapture
990 if showcapture == "no":
991 return
992 for secname, content in rep.sections:
993 if showcapture != "all" and showcapture not in secname:
994 continue
995 if "teardown" in secname:
996 self._tw.sep("-", secname)
997 if content[-1:] == "\n":
998 content = content[:-1]
999 self._tw.line(content)
1001 def summary_failures(self) -> None:
1002 if self.config.option.tbstyle != "no":
1003 reports: List[BaseReport] = self.getreports("failed")
1004 if not reports:
1005 return
1006 self.write_sep("=", "FAILURES")
1007 if self.config.option.tbstyle == "line":
1008 for rep in reports:
1009 line = self._getcrashline(rep)
1010 self.write_line(line)
1011 else:
1012 for rep in reports:
1013 msg = self._getfailureheadline(rep)
1014 self.write_sep("_", msg, red=True, bold=True)
1015 self._outrep_summary(rep)
1016 self._handle_teardown_sections(rep.nodeid)
1018 def summary_errors(self) -> None:
1019 if self.config.option.tbstyle != "no":
1020 reports: List[BaseReport] = self.getreports("error")
1021 if not reports:
1022 return
1023 self.write_sep("=", "ERRORS")
1024 for rep in self.stats["error"]:
1025 msg = self._getfailureheadline(rep)
1026 if rep.when == "collect":
1027 msg = "ERROR collecting " + msg
1028 else:
1029 msg = f"ERROR at {rep.when} of {msg}"
1030 self.write_sep("_", msg, red=True, bold=True)
1031 self._outrep_summary(rep)
1033 def _outrep_summary(self, rep: BaseReport) -> None:
1034 rep.toterminal(self._tw)
1035 showcapture = self.config.option.showcapture
1036 if showcapture == "no":
1037 return
1038 for secname, content in rep.sections:
1039 if showcapture != "all" and showcapture not in secname:
1040 continue
1041 self._tw.sep("-", secname)
1042 if content[-1:] == "\n":
1043 content = content[:-1]
1044 self._tw.line(content)
1046 def summary_stats(self) -> None:
1047 if self.verbosity < -1:
1048 return
1050 session_duration = timing.time() - self._sessionstarttime
1051 (parts, main_color) = self.build_summary_stats_line()
1052 line_parts = []
1054 display_sep = self.verbosity >= 0
1055 if display_sep:
1056 fullwidth = self._tw.fullwidth
1057 for text, markup in parts:
1058 with_markup = self._tw.markup(text, **markup)
1059 if display_sep:
1060 fullwidth += len(with_markup) - len(text)
1061 line_parts.append(with_markup)
1062 msg = ", ".join(line_parts)
1064 main_markup = {main_color: True}
1065 duration = f" in {format_session_duration(session_duration)}"
1066 duration_with_markup = self._tw.markup(duration, **main_markup)
1067 if display_sep:
1068 fullwidth += len(duration_with_markup) - len(duration)
1069 msg += duration_with_markup
1071 if display_sep:
1072 markup_for_end_sep = self._tw.markup("", **main_markup)
1073 if markup_for_end_sep.endswith("\x1b[0m"):
1074 markup_for_end_sep = markup_for_end_sep[:-4]
1075 fullwidth += len(markup_for_end_sep)
1076 msg += markup_for_end_sep
1078 if display_sep:
1079 self.write_sep("=", msg, fullwidth=fullwidth, **main_markup)
1080 else:
1081 self.write_line(msg, **main_markup)
1083 def short_test_summary(self) -> None:
1084 if not self.reportchars:
1085 return
1087 def show_simple(lines: List[str], *, stat: str) -> None:
1088 failed = self.stats.get(stat, [])
1089 if not failed:
1090 return
1091 config = self.config
1092 for rep in failed:
1093 color = _color_for_type.get(stat, _color_for_type_default)
1094 line = _get_line_with_reprcrash_message(
1095 config, rep, self._tw, {color: True}
1096 )
1097 lines.append(line)
1099 def show_xfailed(lines: List[str]) -> None:
1100 xfailed = self.stats.get("xfailed", [])
1101 for rep in xfailed:
1102 verbose_word = rep._get_verbose_word(self.config)
1103 markup_word = self._tw.markup(
1104 verbose_word, **{_color_for_type["warnings"]: True}
1105 )
1106 nodeid = _get_node_id_with_markup(self._tw, self.config, rep)
1107 line = f"{markup_word} {nodeid}"
1108 reason = rep.wasxfail
1109 if reason:
1110 line += " - " + str(reason)
1112 lines.append(line)
1114 def show_xpassed(lines: List[str]) -> None:
1115 xpassed = self.stats.get("xpassed", [])
1116 for rep in xpassed:
1117 verbose_word = rep._get_verbose_word(self.config)
1118 markup_word = self._tw.markup(
1119 verbose_word, **{_color_for_type["warnings"]: True}
1120 )
1121 nodeid = _get_node_id_with_markup(self._tw, self.config, rep)
1122 reason = rep.wasxfail
1123 lines.append(f"{markup_word} {nodeid} {reason}")
1125 def show_skipped(lines: List[str]) -> None:
1126 skipped: List[CollectReport] = self.stats.get("skipped", [])
1127 fskips = _folded_skips(self.startpath, skipped) if skipped else []
1128 if not fskips:
1129 return
1130 verbose_word = skipped[0]._get_verbose_word(self.config)
1131 markup_word = self._tw.markup(
1132 verbose_word, **{_color_for_type["warnings"]: True}
1133 )
1134 prefix = "Skipped: "
1135 for num, fspath, lineno, reason in fskips:
1136 if reason.startswith(prefix):
1137 reason = reason[len(prefix) :]
1138 if lineno is not None:
1139 lines.append(
1140 "%s [%d] %s:%d: %s" % (markup_word, num, fspath, lineno, reason)
1141 )
1142 else:
1143 lines.append("%s [%d] %s: %s" % (markup_word, num, fspath, reason))
1145 REPORTCHAR_ACTIONS: Mapping[str, Callable[[List[str]], None]] = {
1146 "x": show_xfailed,
1147 "X": show_xpassed,
1148 "f": partial(show_simple, stat="failed"),
1149 "s": show_skipped,
1150 "p": partial(show_simple, stat="passed"),
1151 "E": partial(show_simple, stat="error"),
1152 }
1154 lines: List[str] = []
1155 for char in self.reportchars:
1156 action = REPORTCHAR_ACTIONS.get(char)
1157 if action: # skipping e.g. "P" (passed with output) here.
1158 action(lines)
1160 if lines:
1161 self.write_sep("=", "short test summary info", cyan=True, bold=True)
1162 for line in lines:
1163 self.write_line(line)
1165 def _get_main_color(self) -> Tuple[str, List[str]]:
1166 if self._main_color is None or self._known_types is None or self._is_last_item:
1167 self._set_main_color()
1168 assert self._main_color
1169 assert self._known_types
1170 return self._main_color, self._known_types
1172 def _determine_main_color(self, unknown_type_seen: bool) -> str:
1173 stats = self.stats
1174 if "failed" in stats or "error" in stats:
1175 main_color = "red"
1176 elif "warnings" in stats or "xpassed" in stats or unknown_type_seen:
1177 main_color = "yellow"
1178 elif "passed" in stats or not self._is_last_item:
1179 main_color = "green"
1180 else:
1181 main_color = "yellow"
1182 return main_color
1184 def _set_main_color(self) -> None:
1185 unknown_types: List[str] = []
1186 for found_type in self.stats.keys():
1187 if found_type: # setup/teardown reports have an empty key, ignore them
1188 if found_type not in KNOWN_TYPES and found_type not in unknown_types:
1189 unknown_types.append(found_type)
1190 self._known_types = list(KNOWN_TYPES) + unknown_types
1191 self._main_color = self._determine_main_color(bool(unknown_types))
1193 def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]:
1194 """
1195 Build the parts used in the last summary stats line.
1197 The summary stats line is the line shown at the end, "=== 12 passed, 2 errors in Xs===".
1199 This function builds a list of the "parts" that make up for the text in that line, in
1200 the example above it would be:
1202 [
1203 ("12 passed", {"green": True}),
1204 ("2 errors", {"red": True}
1205 ]
1207 That last dict for each line is a "markup dictionary", used by TerminalWriter to
1208 color output.
1210 The final color of the line is also determined by this function, and is the second
1211 element of the returned tuple.
1212 """
1213 if self.config.getoption("collectonly"):
1214 return self._build_collect_only_summary_stats_line()
1215 else:
1216 return self._build_normal_summary_stats_line()
1218 def _get_reports_to_display(self, key: str) -> List[Any]:
1219 """Get test/collection reports for the given status key, such as `passed` or `error`."""
1220 reports = self.stats.get(key, [])
1221 return [x for x in reports if getattr(x, "count_towards_summary", True)]
1223 def _build_normal_summary_stats_line(
1224 self,
1225 ) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]:
1226 main_color, known_types = self._get_main_color()
1227 parts = []
1229 for key in known_types:
1230 reports = self._get_reports_to_display(key)
1231 if reports:
1232 count = len(reports)
1233 color = _color_for_type.get(key, _color_for_type_default)
1234 markup = {color: True, "bold": color == main_color}
1235 parts.append(("%d %s" % pluralize(count, key), markup))
1237 if not parts:
1238 parts = [("no tests ran", {_color_for_type_default: True})]
1240 return parts, main_color
1242 def _build_collect_only_summary_stats_line(
1243 self,
1244 ) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]:
1245 deselected = len(self._get_reports_to_display("deselected"))
1246 errors = len(self._get_reports_to_display("error"))
1248 if self._numcollected == 0:
1249 parts = [("no tests collected", {"yellow": True})]
1250 main_color = "yellow"
1252 elif deselected == 0:
1253 main_color = "green"
1254 collected_output = "%d %s collected" % pluralize(self._numcollected, "test")
1255 parts = [(collected_output, {main_color: True})]
1256 else:
1257 all_tests_were_deselected = self._numcollected == deselected
1258 if all_tests_were_deselected:
1259 main_color = "yellow"
1260 collected_output = f"no tests collected ({deselected} deselected)"
1261 else:
1262 main_color = "green"
1263 selected = self._numcollected - deselected
1264 collected_output = f"{selected}/{self._numcollected} tests collected ({deselected} deselected)"
1266 parts = [(collected_output, {main_color: True})]
1268 if errors:
1269 main_color = _color_for_type["error"]
1270 parts += [("%d %s" % pluralize(errors, "error"), {main_color: True})]
1272 return parts, main_color
1275def _get_node_id_with_markup(tw: TerminalWriter, config: Config, rep: BaseReport):
1276 nodeid = config.cwd_relative_nodeid(rep.nodeid)
1277 path, *parts = nodeid.split("::")
1278 if parts:
1279 parts_markup = tw.markup("::".join(parts), bold=True)
1280 return path + "::" + parts_markup
1281 else:
1282 return path
1285def _format_trimmed(format: str, msg: str, available_width: int) -> Optional[str]:
1286 """Format msg into format, ellipsizing it if doesn't fit in available_width.
1288 Returns None if even the ellipsis can't fit.
1289 """
1290 # Only use the first line.
1291 i = msg.find("\n")
1292 if i != -1:
1293 msg = msg[:i]
1295 ellipsis = "..."
1296 format_width = wcswidth(format.format(""))
1297 if format_width + len(ellipsis) > available_width:
1298 return None
1300 if format_width + wcswidth(msg) > available_width:
1301 available_width -= len(ellipsis)
1302 msg = msg[:available_width]
1303 while format_width + wcswidth(msg) > available_width:
1304 msg = msg[:-1]
1305 msg += ellipsis
1307 return format.format(msg)
1310def _get_line_with_reprcrash_message(
1311 config: Config, rep: BaseReport, tw: TerminalWriter, word_markup: Dict[str, bool]
1312) -> str:
1313 """Get summary line for a report, trying to add reprcrash message."""
1314 verbose_word = rep._get_verbose_word(config)
1315 word = tw.markup(verbose_word, **word_markup)
1316 node = _get_node_id_with_markup(tw, config, rep)
1318 line = f"{word} {node}"
1319 line_width = wcswidth(line)
1321 try:
1322 # Type ignored intentionally -- possible AttributeError expected.
1323 msg = rep.longrepr.reprcrash.message # type: ignore[union-attr]
1324 except AttributeError:
1325 pass
1326 else:
1327 if not running_on_ci():
1328 available_width = tw.fullwidth - line_width
1329 msg = _format_trimmed(" - {}", msg, available_width)
1330 else:
1331 msg = f" - {msg}"
1332 if msg is not None:
1333 line += msg
1335 return line
1338def _folded_skips(
1339 startpath: Path,
1340 skipped: Sequence[CollectReport],
1341) -> List[Tuple[int, str, Optional[int], str]]:
1342 d: Dict[Tuple[str, Optional[int], str], List[CollectReport]] = {}
1343 for event in skipped:
1344 assert event.longrepr is not None
1345 assert isinstance(event.longrepr, tuple), (event, event.longrepr)
1346 assert len(event.longrepr) == 3, (event, event.longrepr)
1347 fspath, lineno, reason = event.longrepr
1348 # For consistency, report all fspaths in relative form.
1349 fspath = bestrelpath(startpath, Path(fspath))
1350 keywords = getattr(event, "keywords", {})
1351 # Folding reports with global pytestmark variable.
1352 # This is a workaround, because for now we cannot identify the scope of a skip marker
1353 # TODO: Revisit after marks scope would be fixed.
1354 if (
1355 event.when == "setup"
1356 and "skip" in keywords
1357 and "pytestmark" not in keywords
1358 ):
1359 key: Tuple[str, Optional[int], str] = (fspath, None, reason)
1360 else:
1361 key = (fspath, lineno, reason)
1362 d.setdefault(key, []).append(event)
1363 values: List[Tuple[int, str, Optional[int], str]] = []
1364 for key, events in d.items():
1365 values.append((len(events), *key))
1366 return values
1369_color_for_type = {
1370 "failed": "red",
1371 "error": "red",
1372 "warnings": "yellow",
1373 "passed": "green",
1374}
1375_color_for_type_default = "yellow"
1378def pluralize(count: int, noun: str) -> Tuple[int, str]:
1379 # No need to pluralize words such as `failed` or `passed`.
1380 if noun not in ["error", "warnings", "test"]:
1381 return count, noun
1383 # The `warnings` key is plural. To avoid API breakage, we keep it that way but
1384 # set it to singular here so we can determine plurality in the same way as we do
1385 # for `error`.
1386 noun = noun.replace("warnings", "warning")
1388 return count, noun + "s" if count != 1 else noun
1391def _plugin_nameversions(plugininfo) -> List[str]:
1392 values: List[str] = []
1393 for plugin, dist in plugininfo:
1394 # Gets us name and version!
1395 name = "{dist.project_name}-{dist.version}".format(dist=dist)
1396 # Questionable convenience, but it keeps things short.
1397 if name.startswith("pytest-"):
1398 name = name[7:]
1399 # We decided to print python package names they can have more than one plugin.
1400 if name not in values:
1401 values.append(name)
1402 return values
1405def format_session_duration(seconds: float) -> str:
1406 """Format the given seconds in a human readable manner to show in the final summary."""
1407 if seconds < 60:
1408 return f"{seconds:.2f}s"
1409 else:
1410 dt = datetime.timedelta(seconds=int(seconds))
1411 return f"{seconds:.2f}s ({dt})"
1414def _get_raw_skip_reason(report: TestReport) -> str:
1415 """Get the reason string of a skip/xfail/xpass test report.
1417 The string is just the part given by the user.
1418 """
1419 if hasattr(report, "wasxfail"):
1420 reason = cast(str, report.wasxfail)
1421 if reason.startswith("reason: "):
1422 reason = reason[len("reason: ") :]
1423 return reason
1424 else:
1425 assert report.skipped
1426 assert isinstance(report.longrepr, tuple)
1427 _, _, reason = report.longrepr
1428 if reason.startswith("Skipped: "):
1429 reason = reason[len("Skipped: ") :]
1430 elif reason == "Skipped":
1431 reason = ""
1432 return reason