terminal.py 52 KB

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