terminal.py 49 KB

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