reports.py 23 KB

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