terminal.py 59 KB

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