reports.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. # mypy: allow-untyped-defs
  2. from __future__ import annotations
  3. from collections.abc import Iterable
  4. from collections.abc import Iterator
  5. from collections.abc import Mapping
  6. from collections.abc import Sequence
  7. import dataclasses
  8. from io import StringIO
  9. import os
  10. from pprint import pprint
  11. from typing import Any
  12. from typing import cast
  13. from typing import final
  14. from typing import Literal
  15. from typing import NoReturn
  16. from typing import TYPE_CHECKING
  17. from _pytest._code.code import ExceptionChainRepr
  18. from _pytest._code.code import ExceptionInfo
  19. from _pytest._code.code import ExceptionRepr
  20. from _pytest._code.code import ReprEntry
  21. from _pytest._code.code import ReprEntryNative
  22. from _pytest._code.code import ReprExceptionInfo
  23. from _pytest._code.code import ReprFileLocation
  24. from _pytest._code.code import ReprFuncArgs
  25. from _pytest._code.code import ReprLocals
  26. from _pytest._code.code import ReprTraceback
  27. from _pytest._code.code import TerminalRepr
  28. from _pytest._io import TerminalWriter
  29. from _pytest.config import Config
  30. from _pytest.nodes import Collector
  31. from _pytest.nodes import Item
  32. from _pytest.outcomes import fail
  33. from _pytest.outcomes import skip
  34. if TYPE_CHECKING:
  35. from typing_extensions import Self
  36. from _pytest.runner import CallInfo
  37. def getworkerinfoline(node):
  38. try:
  39. return node._workerinfocache
  40. except AttributeError:
  41. d = node.workerinfo
  42. ver = "{}.{}.{}".format(*d["version_info"][:3])
  43. node._workerinfocache = s = "[{}] {} -- Python {} {}".format(
  44. d["id"], d["sysplatform"], ver, d["executable"]
  45. )
  46. return s
  47. class BaseReport:
  48. when: str | None
  49. location: tuple[str, int | None, str] | None
  50. longrepr: (
  51. None | ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr
  52. )
  53. sections: list[tuple[str, str]]
  54. nodeid: str
  55. outcome: Literal["passed", "failed", "skipped"]
  56. def __init__(self, **kw: Any) -> None:
  57. self.__dict__.update(kw)
  58. if TYPE_CHECKING:
  59. # Can have arbitrary fields given to __init__().
  60. def __getattr__(self, key: str) -> Any: ...
  61. def toterminal(self, out: TerminalWriter) -> None:
  62. if hasattr(self, "node"):
  63. worker_info = getworkerinfoline(self.node)
  64. if worker_info:
  65. out.line(worker_info)
  66. longrepr = self.longrepr
  67. if longrepr is None:
  68. return
  69. if hasattr(longrepr, "toterminal"):
  70. longrepr_terminal = cast(TerminalRepr, longrepr)
  71. longrepr_terminal.toterminal(out)
  72. else:
  73. try:
  74. s = str(longrepr)
  75. except UnicodeEncodeError:
  76. s = "<unprintable longrepr>"
  77. out.line(s)
  78. def get_sections(self, prefix: str) -> Iterator[tuple[str, str]]:
  79. for name, content in self.sections:
  80. if name.startswith(prefix):
  81. yield prefix, content
  82. @property
  83. def longreprtext(self) -> str:
  84. """Read-only property that returns the full string representation of
  85. ``longrepr``.
  86. .. versionadded:: 3.0
  87. """
  88. file = StringIO()
  89. tw = TerminalWriter(file)
  90. tw.hasmarkup = False
  91. self.toterminal(tw)
  92. exc = file.getvalue()
  93. return exc.strip()
  94. @property
  95. def caplog(self) -> str:
  96. """Return captured log lines, if log capturing is enabled.
  97. .. versionadded:: 3.5
  98. """
  99. return "\n".join(
  100. content for (prefix, content) in self.get_sections("Captured log")
  101. )
  102. @property
  103. def capstdout(self) -> str:
  104. """Return captured text from stdout, if capturing is enabled.
  105. .. versionadded:: 3.0
  106. """
  107. return "".join(
  108. content for (prefix, content) in self.get_sections("Captured stdout")
  109. )
  110. @property
  111. def capstderr(self) -> str:
  112. """Return captured text from stderr, if capturing is enabled.
  113. .. versionadded:: 3.0
  114. """
  115. return "".join(
  116. content for (prefix, content) in self.get_sections("Captured stderr")
  117. )
  118. @property
  119. def passed(self) -> bool:
  120. """Whether the outcome is passed."""
  121. return self.outcome == "passed"
  122. @property
  123. def failed(self) -> bool:
  124. """Whether the outcome is failed."""
  125. return self.outcome == "failed"
  126. @property
  127. def skipped(self) -> bool:
  128. """Whether the outcome is skipped."""
  129. return self.outcome == "skipped"
  130. @property
  131. def fspath(self) -> str:
  132. """The path portion of the reported node, as a string."""
  133. return self.nodeid.split("::")[0]
  134. @property
  135. def count_towards_summary(self) -> bool:
  136. """**Experimental** Whether this report should be counted towards the
  137. totals shown at the end of the test session: "1 passed, 1 failure, etc".
  138. .. note::
  139. This function is considered **experimental**, so beware that it is subject to changes
  140. even in patch releases.
  141. """
  142. return True
  143. @property
  144. def head_line(self) -> str | None:
  145. """**Experimental** The head line shown with longrepr output for this
  146. report, more commonly during traceback representation during
  147. failures::
  148. ________ Test.foo ________
  149. In the example above, the head_line is "Test.foo".
  150. .. note::
  151. This function is considered **experimental**, so beware that it is subject to changes
  152. even in patch releases.
  153. """
  154. if self.location is not None:
  155. fspath, lineno, domain = self.location
  156. return domain
  157. return None
  158. def _get_verbose_word_with_markup(
  159. self, config: Config, default_markup: Mapping[str, bool]
  160. ) -> tuple[str, Mapping[str, bool]]:
  161. _category, _short, verbose = config.hook.pytest_report_teststatus(
  162. report=self, config=config
  163. )
  164. if isinstance(verbose, str):
  165. return verbose, default_markup
  166. if isinstance(verbose, Sequence) and len(verbose) == 2:
  167. word, markup = verbose
  168. if isinstance(word, str) and isinstance(markup, Mapping):
  169. return word, markup
  170. fail( # pragma: no cover
  171. "pytest_report_teststatus() hook (from a plugin) returned "
  172. f"an invalid verbose value: {verbose!r}.\nExpected either a string "
  173. "or a tuple of (word, markup)."
  174. )
  175. def _to_json(self) -> dict[str, Any]:
  176. """Return the contents of this report as a dict of builtin entries,
  177. suitable for serialization.
  178. This was originally the serialize_report() function from xdist (ca03269).
  179. Experimental method.
  180. """
  181. return _report_to_json(self)
  182. @classmethod
  183. def _from_json(cls, reportdict: dict[str, object]) -> Self:
  184. """Create either a TestReport or CollectReport, depending on the calling class.
  185. It is the callers responsibility to know which class to pass here.
  186. This was originally the serialize_report() function from xdist (ca03269).
  187. Experimental method.
  188. """
  189. kwargs = _report_kwargs_from_json(reportdict)
  190. return cls(**kwargs)
  191. def _report_unserialization_failure(
  192. type_name: str, report_class: type[BaseReport], reportdict
  193. ) -> NoReturn:
  194. url = "https://github.com/pytest-dev/pytest/issues"
  195. stream = StringIO()
  196. pprint("-" * 100, stream=stream)
  197. pprint(f"INTERNALERROR: Unknown entry type returned: {type_name}", stream=stream)
  198. pprint(f"report_name: {report_class}", stream=stream)
  199. pprint(reportdict, stream=stream)
  200. pprint(f"Please report this bug at {url}", stream=stream)
  201. pprint("-" * 100, stream=stream)
  202. raise RuntimeError(stream.getvalue())
  203. @final
  204. class TestReport(BaseReport):
  205. """Basic test report object (also used for setup and teardown calls if
  206. they fail).
  207. Reports can contain arbitrary extra attributes.
  208. """
  209. __test__ = False
  210. # Defined by skipping plugin.
  211. # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish.
  212. wasxfail: str
  213. def __init__(
  214. self,
  215. nodeid: str,
  216. location: tuple[str, int | None, str],
  217. keywords: Mapping[str, Any],
  218. outcome: Literal["passed", "failed", "skipped"],
  219. longrepr: None
  220. | ExceptionInfo[BaseException]
  221. | tuple[str, int, str]
  222. | str
  223. | TerminalRepr,
  224. when: Literal["setup", "call", "teardown"],
  225. sections: Iterable[tuple[str, str]] = (),
  226. duration: float = 0,
  227. start: float = 0,
  228. stop: float = 0,
  229. user_properties: Iterable[tuple[str, object]] | None = None,
  230. **extra,
  231. ) -> None:
  232. #: Normalized collection nodeid.
  233. self.nodeid = nodeid
  234. #: A (filesystempath, lineno, domaininfo) tuple indicating the
  235. #: actual location of a test item - it might be different from the
  236. #: collected one e.g. if a method is inherited from a different module.
  237. #: The filesystempath may be relative to ``config.rootdir``.
  238. #: The line number is 0-based.
  239. self.location: tuple[str, int | None, str] = location
  240. #: A name -> value dictionary containing all keywords and
  241. #: markers associated with a test invocation.
  242. self.keywords: Mapping[str, Any] = keywords
  243. #: Test outcome, always one of "passed", "failed", "skipped".
  244. self.outcome = outcome
  245. #: None or a failure representation.
  246. self.longrepr = longrepr
  247. #: One of 'setup', 'call', 'teardown' to indicate runtest phase.
  248. self.when: Literal["setup", "call", "teardown"] = when
  249. #: User properties is a list of tuples (name, value) that holds user
  250. #: defined properties of the test.
  251. self.user_properties = list(user_properties or [])
  252. #: Tuples of str ``(heading, content)`` with extra information
  253. #: for the test report. Used by pytest to add text captured
  254. #: from ``stdout``, ``stderr``, and intercepted logging events. May
  255. #: be used by other plugins to add arbitrary information to reports.
  256. self.sections = list(sections)
  257. #: Time it took to run just the test.
  258. self.duration: float = duration
  259. #: The system time when the call started, in seconds since the epoch.
  260. self.start: float = start
  261. #: The system time when the call ended, in seconds since the epoch.
  262. self.stop: float = stop
  263. self.__dict__.update(extra)
  264. def __repr__(self) -> str:
  265. return f"<{self.__class__.__name__} {self.nodeid!r} when={self.when!r} outcome={self.outcome!r}>"
  266. @classmethod
  267. def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
  268. """Create and fill a TestReport with standard item and call info.
  269. :param item: The item.
  270. :param call: The call info.
  271. """
  272. when = call.when
  273. # Remove "collect" from the Literal type -- only for collection calls.
  274. assert when != "collect"
  275. duration = call.duration
  276. start = call.start
  277. stop = call.stop
  278. keywords = {x: 1 for x in item.keywords}
  279. excinfo = call.excinfo
  280. sections = []
  281. if not call.excinfo:
  282. outcome: Literal["passed", "failed", "skipped"] = "passed"
  283. longrepr: (
  284. None
  285. | ExceptionInfo[BaseException]
  286. | tuple[str, int, str]
  287. | str
  288. | TerminalRepr
  289. ) = None
  290. else:
  291. if not isinstance(excinfo, ExceptionInfo):
  292. outcome = "failed"
  293. longrepr = excinfo
  294. elif isinstance(excinfo.value, skip.Exception):
  295. outcome = "skipped"
  296. r = excinfo._getreprcrash()
  297. assert r is not None, (
  298. "There should always be a traceback entry for skipping a test."
  299. )
  300. if excinfo.value._use_item_location:
  301. path, line = item.reportinfo()[:2]
  302. assert line is not None
  303. longrepr = os.fspath(path), line + 1, r.message
  304. else:
  305. longrepr = (str(r.path), r.lineno, r.message)
  306. else:
  307. outcome = "failed"
  308. if call.when == "call":
  309. longrepr = item.repr_failure(excinfo)
  310. else: # exception in setup or teardown
  311. longrepr = item._repr_failure_py(
  312. excinfo, style=item.config.getoption("tbstyle", "auto")
  313. )
  314. for rwhen, key, content in item._report_sections:
  315. sections.append((f"Captured {key} {rwhen}", content))
  316. return cls(
  317. item.nodeid,
  318. item.location,
  319. keywords,
  320. outcome,
  321. longrepr,
  322. when,
  323. sections,
  324. duration,
  325. start,
  326. stop,
  327. user_properties=item.user_properties,
  328. )
  329. @final
  330. class CollectReport(BaseReport):
  331. """Collection report object.
  332. Reports can contain arbitrary extra attributes.
  333. """
  334. when = "collect"
  335. def __init__(
  336. self,
  337. nodeid: str,
  338. outcome: Literal["passed", "failed", "skipped"],
  339. longrepr: None
  340. | ExceptionInfo[BaseException]
  341. | tuple[str, int, str]
  342. | str
  343. | TerminalRepr,
  344. result: list[Item | Collector] | None,
  345. sections: Iterable[tuple[str, str]] = (),
  346. **extra,
  347. ) -> None:
  348. #: Normalized collection nodeid.
  349. self.nodeid = nodeid
  350. #: Test outcome, always one of "passed", "failed", "skipped".
  351. self.outcome = outcome
  352. #: None or a failure representation.
  353. self.longrepr = longrepr
  354. #: The collected items and collection nodes.
  355. self.result = result or []
  356. #: Tuples of str ``(heading, content)`` with extra information
  357. #: for the test report. Used by pytest to add text captured
  358. #: from ``stdout``, ``stderr``, and intercepted logging events. May
  359. #: be used by other plugins to add arbitrary information to reports.
  360. self.sections = list(sections)
  361. self.__dict__.update(extra)
  362. @property
  363. def location( # type:ignore[override]
  364. self,
  365. ) -> tuple[str, int | None, str] | None:
  366. return (self.fspath, None, self.fspath)
  367. def __repr__(self) -> str:
  368. return f"<CollectReport {self.nodeid!r} lenresult={len(self.result)} outcome={self.outcome!r}>"
  369. class CollectErrorRepr(TerminalRepr):
  370. def __init__(self, msg: str) -> None:
  371. self.longrepr = msg
  372. def toterminal(self, out: TerminalWriter) -> None:
  373. out.line(self.longrepr, red=True)
  374. def pytest_report_to_serializable(
  375. report: CollectReport | TestReport,
  376. ) -> dict[str, Any] | None:
  377. if isinstance(report, (TestReport, CollectReport)):
  378. data = report._to_json()
  379. data["$report_type"] = report.__class__.__name__
  380. return data
  381. # TODO: Check if this is actually reachable.
  382. return None # type: ignore[unreachable]
  383. def pytest_report_from_serializable(
  384. data: dict[str, Any],
  385. ) -> CollectReport | TestReport | None:
  386. if "$report_type" in data:
  387. if data["$report_type"] == "TestReport":
  388. return TestReport._from_json(data)
  389. elif data["$report_type"] == "CollectReport":
  390. return CollectReport._from_json(data)
  391. assert False, "Unknown report_type unserialize data: {}".format(
  392. data["$report_type"]
  393. )
  394. return None
  395. def _report_to_json(report: BaseReport) -> dict[str, Any]:
  396. """Return the contents of this report as a dict of builtin entries,
  397. suitable for serialization.
  398. This was originally the serialize_report() function from xdist (ca03269).
  399. """
  400. def serialize_repr_entry(
  401. entry: ReprEntry | ReprEntryNative,
  402. ) -> dict[str, Any]:
  403. data = dataclasses.asdict(entry)
  404. for key, value in data.items():
  405. if hasattr(value, "__dict__"):
  406. data[key] = dataclasses.asdict(value)
  407. entry_data = {"type": type(entry).__name__, "data": data}
  408. return entry_data
  409. def serialize_repr_traceback(reprtraceback: ReprTraceback) -> dict[str, Any]:
  410. result = dataclasses.asdict(reprtraceback)
  411. result["reprentries"] = [
  412. serialize_repr_entry(x) for x in reprtraceback.reprentries
  413. ]
  414. return result
  415. def serialize_repr_crash(
  416. reprcrash: ReprFileLocation | None,
  417. ) -> dict[str, Any] | None:
  418. if reprcrash is not None:
  419. return dataclasses.asdict(reprcrash)
  420. else:
  421. return None
  422. def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]:
  423. assert rep.longrepr is not None
  424. # TODO: Investigate whether the duck typing is really necessary here.
  425. longrepr = cast(ExceptionRepr, rep.longrepr)
  426. result: dict[str, Any] = {
  427. "reprcrash": serialize_repr_crash(longrepr.reprcrash),
  428. "reprtraceback": serialize_repr_traceback(longrepr.reprtraceback),
  429. "sections": longrepr.sections,
  430. }
  431. if isinstance(longrepr, ExceptionChainRepr):
  432. result["chain"] = []
  433. for repr_traceback, repr_crash, description in longrepr.chain:
  434. result["chain"].append(
  435. (
  436. serialize_repr_traceback(repr_traceback),
  437. serialize_repr_crash(repr_crash),
  438. description,
  439. )
  440. )
  441. else:
  442. result["chain"] = None
  443. return result
  444. d = report.__dict__.copy()
  445. if hasattr(report.longrepr, "toterminal"):
  446. if hasattr(report.longrepr, "reprtraceback") and hasattr(
  447. report.longrepr, "reprcrash"
  448. ):
  449. d["longrepr"] = serialize_exception_longrepr(report)
  450. else:
  451. d["longrepr"] = str(report.longrepr)
  452. else:
  453. d["longrepr"] = report.longrepr
  454. for name in d:
  455. if isinstance(d[name], os.PathLike):
  456. d[name] = os.fspath(d[name])
  457. elif name == "result":
  458. d[name] = None # for now
  459. return d
  460. def _report_kwargs_from_json(reportdict: dict[str, Any]) -> dict[str, Any]:
  461. """Return **kwargs that can be used to construct a TestReport or
  462. CollectReport instance.
  463. This was originally the serialize_report() function from xdist (ca03269).
  464. """
  465. def deserialize_repr_entry(entry_data):
  466. data = entry_data["data"]
  467. entry_type = entry_data["type"]
  468. if entry_type == "ReprEntry":
  469. reprfuncargs = None
  470. reprfileloc = None
  471. reprlocals = None
  472. if data["reprfuncargs"]:
  473. reprfuncargs = ReprFuncArgs(**data["reprfuncargs"])
  474. if data["reprfileloc"]:
  475. reprfileloc = ReprFileLocation(**data["reprfileloc"])
  476. if data["reprlocals"]:
  477. reprlocals = ReprLocals(data["reprlocals"]["lines"])
  478. reprentry: ReprEntry | ReprEntryNative = ReprEntry(
  479. lines=data["lines"],
  480. reprfuncargs=reprfuncargs,
  481. reprlocals=reprlocals,
  482. reprfileloc=reprfileloc,
  483. style=data["style"],
  484. )
  485. elif entry_type == "ReprEntryNative":
  486. reprentry = ReprEntryNative(data["lines"])
  487. else:
  488. _report_unserialization_failure(entry_type, TestReport, reportdict)
  489. return reprentry
  490. def deserialize_repr_traceback(repr_traceback_dict):
  491. repr_traceback_dict["reprentries"] = [
  492. deserialize_repr_entry(x) for x in repr_traceback_dict["reprentries"]
  493. ]
  494. return ReprTraceback(**repr_traceback_dict)
  495. def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None):
  496. if repr_crash_dict is not None:
  497. return ReprFileLocation(**repr_crash_dict)
  498. else:
  499. return None
  500. if (
  501. reportdict["longrepr"]
  502. and "reprcrash" in reportdict["longrepr"]
  503. and "reprtraceback" in reportdict["longrepr"]
  504. ):
  505. reprtraceback = deserialize_repr_traceback(
  506. reportdict["longrepr"]["reprtraceback"]
  507. )
  508. reprcrash = deserialize_repr_crash(reportdict["longrepr"]["reprcrash"])
  509. if reportdict["longrepr"]["chain"]:
  510. chain = []
  511. for repr_traceback_data, repr_crash_data, description in reportdict[
  512. "longrepr"
  513. ]["chain"]:
  514. chain.append(
  515. (
  516. deserialize_repr_traceback(repr_traceback_data),
  517. deserialize_repr_crash(repr_crash_data),
  518. description,
  519. )
  520. )
  521. exception_info: ExceptionChainRepr | ReprExceptionInfo = ExceptionChainRepr(
  522. chain
  523. )
  524. else:
  525. exception_info = ReprExceptionInfo(
  526. reprtraceback=reprtraceback,
  527. reprcrash=reprcrash,
  528. )
  529. for section in reportdict["longrepr"]["sections"]:
  530. exception_info.addsection(*section)
  531. reportdict["longrepr"] = exception_info
  532. return reportdict