Skip to content

Commit a511b98

Browse files
committed
Serialize/deserialize chained exceptions
Fix #5786
1 parent 7a69365 commit a511b98

File tree

3 files changed

+120
-21
lines changed

3 files changed

+120
-21
lines changed

changelog/5786.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Chained exceptions in test and collection reports are now correctly serialized, allowing plugins like
2+
``pytest-xdist`` to display them properly.

src/_pytest/reports.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import py
55

6+
from _pytest._code.code import ExceptionChainRepr
67
from _pytest._code.code import ExceptionInfo
78
from _pytest._code.code import ReprEntry
89
from _pytest._code.code import ReprEntryNative
@@ -160,7 +161,7 @@ def _to_json(self):
160161
161162
Experimental method.
162163
"""
163-
return _test_report_to_json(self)
164+
return _report_to_json(self)
164165

165166
@classmethod
166167
def _from_json(cls, reportdict):
@@ -172,7 +173,7 @@ def _from_json(cls, reportdict):
172173
173174
Experimental method.
174175
"""
175-
kwargs = _test_report_kwargs_from_json(reportdict)
176+
kwargs = _report_kwargs_from_json(reportdict)
176177
return cls(**kwargs)
177178

178179

@@ -340,7 +341,7 @@ def pytest_report_from_serializable(data):
340341
)
341342

342343

343-
def _test_report_to_json(test_report):
344+
def _report_to_json(report):
344345
"""
345346
This was originally the serialize_report() function from xdist (ca03269).
346347
@@ -366,22 +367,35 @@ def serialize_repr_crash(reprcrash):
366367
return reprcrash.__dict__.copy()
367368

368369
def serialize_longrepr(rep):
369-
return {
370+
result = {
370371
"reprcrash": serialize_repr_crash(rep.longrepr.reprcrash),
371372
"reprtraceback": serialize_repr_traceback(rep.longrepr.reprtraceback),
372373
"sections": rep.longrepr.sections,
373374
}
375+
if isinstance(rep.longrepr, ExceptionChainRepr):
376+
result["chain"] = []
377+
for repr_traceback, repr_crash, description in rep.longrepr.chain:
378+
result["chain"].append(
379+
(
380+
serialize_repr_traceback(repr_traceback),
381+
serialize_repr_crash(repr_crash),
382+
description,
383+
)
384+
)
385+
else:
386+
result["chain"] = None
387+
return result
374388

375-
d = test_report.__dict__.copy()
376-
if hasattr(test_report.longrepr, "toterminal"):
377-
if hasattr(test_report.longrepr, "reprtraceback") and hasattr(
378-
test_report.longrepr, "reprcrash"
389+
d = report.__dict__.copy()
390+
if hasattr(report.longrepr, "toterminal"):
391+
if hasattr(report.longrepr, "reprtraceback") and hasattr(
392+
report.longrepr, "reprcrash"
379393
):
380-
d["longrepr"] = serialize_longrepr(test_report)
394+
d["longrepr"] = serialize_longrepr(report)
381395
else:
382-
d["longrepr"] = str(test_report.longrepr)
396+
d["longrepr"] = str(report.longrepr)
383397
else:
384-
d["longrepr"] = test_report.longrepr
398+
d["longrepr"] = report.longrepr
385399
for name in d:
386400
if isinstance(d[name], (py.path.local, Path)):
387401
d[name] = str(d[name])
@@ -390,12 +404,11 @@ def serialize_longrepr(rep):
390404
return d
391405

392406

393-
def _test_report_kwargs_from_json(reportdict):
407+
def _report_kwargs_from_json(reportdict):
394408
"""
395409
This was originally the serialize_report() function from xdist (ca03269).
396410
397-
Factory method that returns either a TestReport or CollectReport, depending on the calling
398-
class. It's the callers responsibility to know which class to pass here.
411+
Returns **kwargs that can be used to construct a TestReport or CollectReport instance.
399412
"""
400413

401414
def deserialize_repr_entry(entry_data):
@@ -439,12 +452,26 @@ def deserialize_repr_crash(repr_crash_dict):
439452
and "reprcrash" in reportdict["longrepr"]
440453
and "reprtraceback" in reportdict["longrepr"]
441454
):
442-
exception_info = ReprExceptionInfo(
443-
reprtraceback=deserialize_repr_traceback(
444-
reportdict["longrepr"]["reprtraceback"]
445-
),
446-
reprcrash=deserialize_repr_crash(reportdict["longrepr"]["reprcrash"]),
455+
456+
reprtraceback = deserialize_repr_traceback(
457+
reportdict["longrepr"]["reprtraceback"]
447458
)
459+
reprcrash = deserialize_repr_crash(reportdict["longrepr"]["reprcrash"])
460+
if reportdict["longrepr"]["chain"]:
461+
chain = []
462+
for repr_traceback_data, repr_crash_data, description in reportdict[
463+
"longrepr"
464+
]["chain"]:
465+
chain.append(
466+
(
467+
deserialize_repr_traceback(repr_traceback_data),
468+
deserialize_repr_crash(repr_crash_data),
469+
description,
470+
)
471+
)
472+
exception_info = ExceptionChainRepr(chain)
473+
else:
474+
exception_info = ReprExceptionInfo(reprtraceback, reprcrash)
448475

449476
for section in reportdict["longrepr"]["sections"]:
450477
exception_info.addsection(*section)

testing/test_reports.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from _pytest._code.code import ExceptionChainRepr
23
from _pytest.pathlib import Path
34
from _pytest.reports import CollectReport
45
from _pytest.reports import TestReport
@@ -220,8 +221,8 @@ def test_a():
220221
assert data["path1"] == str(testdir.tmpdir)
221222
assert data["path2"] == str(testdir.tmpdir)
222223

223-
def test_unserialization_failure(self, testdir):
224-
"""Check handling of failure during unserialization of report types."""
224+
def test_deserialization_failure(self, testdir):
225+
"""Check handling of failure during deserialization of report types."""
225226
testdir.makepyfile(
226227
"""
227228
def test_a():
@@ -242,6 +243,75 @@ def test_a():
242243
):
243244
TestReport._from_json(data)
244245

246+
@pytest.mark.parametrize("report_class", [TestReport, CollectReport])
247+
def test_chained_exceptions(self, testdir, tw_mock, report_class):
248+
"""Check serialization/deserialization of report objects containing chained exceptions (#5786)"""
249+
testdir.makepyfile(
250+
"""
251+
def foo():
252+
raise ValueError('value error')
253+
def test_a():
254+
try:
255+
foo()
256+
except ValueError as e:
257+
raise RuntimeError('runtime error') from e
258+
if {error_during_import}:
259+
test_a()
260+
""".format(
261+
error_during_import=report_class is CollectReport
262+
)
263+
)
264+
265+
reprec = testdir.inline_run()
266+
if report_class is TestReport:
267+
reports = reprec.getreports("pytest_runtest_logreport")
268+
# we have 3 reports: setup/call/teardown
269+
assert len(reports) == 3
270+
# get the call report
271+
report = reports[1]
272+
else:
273+
assert report_class is CollectReport
274+
# two collection reports: session and test file
275+
reports = reprec.getreports("pytest_collectreport")
276+
assert len(reports) == 2
277+
report = reports[1]
278+
279+
def check_longrepr(longrepr):
280+
"""Check the attributes of the given longrepr object according to the test file.
281+
282+
We can get away with testing both CollectReport and TestReport with this function because
283+
the longrepr objects are very similar.
284+
"""
285+
assert isinstance(longrepr, ExceptionChainRepr)
286+
assert longrepr.sections == [("title", "contents", "=")]
287+
assert len(longrepr.chain) == 2
288+
entry1, entry2 = longrepr.chain
289+
tb1, fileloc1, desc1 = entry1
290+
tb2, fileloc2, desc2 = entry2
291+
292+
assert "ValueError('value error')" in str(tb1)
293+
assert "RuntimeError('runtime error')" in str(tb2)
294+
295+
assert (
296+
desc1
297+
== "The above exception was the direct cause of the following exception:"
298+
)
299+
assert desc2 is None
300+
301+
assert report.failed
302+
assert len(report.sections) == 0
303+
report.longrepr.addsection("title", "contents", "=")
304+
check_longrepr(report.longrepr)
305+
306+
data = report._to_json()
307+
loaded_report = report_class._from_json(data)
308+
check_longrepr(loaded_report.longrepr)
309+
310+
# make sure we don't blow up on ``toterminal`` call; we don't test the actual output because it is very
311+
# brittle and hard to maintain, but we can assume it is correct because ``toterminal`` is already tested
312+
# elsewhere and we do check the contents of the longrepr object after loading it.
313+
loaded_report.longrepr.toterminal(tw_mock)
314+
245315

246316
class TestHooks:
247317
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)