You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1406 lines
50KB

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