Skip to content

Commit

Permalink
Serialize/deserialize chained exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
nicoddemus committed Aug 29, 2019
1 parent 7a69365 commit a511b98
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 21 deletions.
2 changes: 2 additions & 0 deletions changelog/5786.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Chained exceptions in test and collection reports are now correctly serialized, allowing plugins like
``pytest-xdist`` to display them properly.
65 changes: 46 additions & 19 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import py

from _pytest._code.code import ExceptionChainRepr
from _pytest._code.code import ExceptionInfo
from _pytest._code.code import ReprEntry
from _pytest._code.code import ReprEntryNative
Expand Down Expand Up @@ -160,7 +161,7 @@ def _to_json(self):
Experimental method.
"""
return _test_report_to_json(self)
return _report_to_json(self)

@classmethod
def _from_json(cls, reportdict):
Expand All @@ -172,7 +173,7 @@ def _from_json(cls, reportdict):
Experimental method.
"""
kwargs = _test_report_kwargs_from_json(reportdict)
kwargs = _report_kwargs_from_json(reportdict)
return cls(**kwargs)


Expand Down Expand Up @@ -340,7 +341,7 @@ def pytest_report_from_serializable(data):
)


def _test_report_to_json(test_report):
def _report_to_json(report):
"""
This was originally the serialize_report() function from xdist (ca03269).
Expand All @@ -366,22 +367,35 @@ def serialize_repr_crash(reprcrash):
return reprcrash.__dict__.copy()

def serialize_longrepr(rep):
return {
result = {
"reprcrash": serialize_repr_crash(rep.longrepr.reprcrash),
"reprtraceback": serialize_repr_traceback(rep.longrepr.reprtraceback),
"sections": rep.longrepr.sections,
}
if isinstance(rep.longrepr, ExceptionChainRepr):
result["chain"] = []
for repr_traceback, repr_crash, description in rep.longrepr.chain:
result["chain"].append(
(
serialize_repr_traceback(repr_traceback),
serialize_repr_crash(repr_crash),
description,
)
)
else:
result["chain"] = None
return result

d = test_report.__dict__.copy()
if hasattr(test_report.longrepr, "toterminal"):
if hasattr(test_report.longrepr, "reprtraceback") and hasattr(
test_report.longrepr, "reprcrash"
d = report.__dict__.copy()
if hasattr(report.longrepr, "toterminal"):
if hasattr(report.longrepr, "reprtraceback") and hasattr(
report.longrepr, "reprcrash"
):
d["longrepr"] = serialize_longrepr(test_report)
d["longrepr"] = serialize_longrepr(report)
else:
d["longrepr"] = str(test_report.longrepr)
d["longrepr"] = str(report.longrepr)
else:
d["longrepr"] = test_report.longrepr
d["longrepr"] = report.longrepr
for name in d:
if isinstance(d[name], (py.path.local, Path)):
d[name] = str(d[name])
Expand All @@ -390,12 +404,11 @@ def serialize_longrepr(rep):
return d


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

def deserialize_repr_entry(entry_data):
Expand Down Expand Up @@ -439,12 +452,26 @@ def deserialize_repr_crash(repr_crash_dict):
and "reprcrash" in reportdict["longrepr"]
and "reprtraceback" in reportdict["longrepr"]
):
exception_info = ReprExceptionInfo(
reprtraceback=deserialize_repr_traceback(
reportdict["longrepr"]["reprtraceback"]
),
reprcrash=deserialize_repr_crash(reportdict["longrepr"]["reprcrash"]),

reprtraceback = deserialize_repr_traceback(
reportdict["longrepr"]["reprtraceback"]
)
reprcrash = deserialize_repr_crash(reportdict["longrepr"]["reprcrash"])
if reportdict["longrepr"]["chain"]:
chain = []
for repr_traceback_data, repr_crash_data, description in reportdict[
"longrepr"
]["chain"]:
chain.append(
(
deserialize_repr_traceback(repr_traceback_data),
deserialize_repr_crash(repr_crash_data),
description,
)
)
exception_info = ExceptionChainRepr(chain)
else:
exception_info = ReprExceptionInfo(reprtraceback, reprcrash)

for section in reportdict["longrepr"]["sections"]:
exception_info.addsection(*section)
Expand Down
74 changes: 72 additions & 2 deletions testing/test_reports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from _pytest._code.code import ExceptionChainRepr
from _pytest.pathlib import Path
from _pytest.reports import CollectReport
from _pytest.reports import TestReport
Expand Down Expand Up @@ -220,8 +221,8 @@ def test_a():
assert data["path1"] == str(testdir.tmpdir)
assert data["path2"] == str(testdir.tmpdir)

def test_unserialization_failure(self, testdir):
"""Check handling of failure during unserialization of report types."""
def test_deserialization_failure(self, testdir):
"""Check handling of failure during deserialization of report types."""
testdir.makepyfile(
"""
def test_a():
Expand All @@ -242,6 +243,75 @@ def test_a():
):
TestReport._from_json(data)

@pytest.mark.parametrize("report_class", [TestReport, CollectReport])
def test_chained_exceptions(self, testdir, tw_mock, report_class):
"""Check serialization/deserialization of report objects containing chained exceptions (#5786)"""
testdir.makepyfile(
"""
def foo():
raise ValueError('value error')
def test_a():
try:
foo()
except ValueError as e:
raise RuntimeError('runtime error') from e
if {error_during_import}:
test_a()
""".format(
error_during_import=report_class is CollectReport
)
)

reprec = testdir.inline_run()
if report_class is TestReport:
reports = reprec.getreports("pytest_runtest_logreport")
# we have 3 reports: setup/call/teardown
assert len(reports) == 3
# get the call report
report = reports[1]
else:
assert report_class is CollectReport
# two collection reports: session and test file
reports = reprec.getreports("pytest_collectreport")
assert len(reports) == 2
report = reports[1]

def check_longrepr(longrepr):
"""Check the attributes of the given longrepr object according to the test file.
We can get away with testing both CollectReport and TestReport with this function because
the longrepr objects are very similar.
"""
assert isinstance(longrepr, ExceptionChainRepr)
assert longrepr.sections == [("title", "contents", "=")]
assert len(longrepr.chain) == 2
entry1, entry2 = longrepr.chain
tb1, fileloc1, desc1 = entry1
tb2, fileloc2, desc2 = entry2

assert "ValueError('value error')" in str(tb1)
assert "RuntimeError('runtime error')" in str(tb2)

assert (
desc1
== "The above exception was the direct cause of the following exception:"
)
assert desc2 is None

assert report.failed
assert len(report.sections) == 0
report.longrepr.addsection("title", "contents", "=")
check_longrepr(report.longrepr)

data = report._to_json()
loaded_report = report_class._from_json(data)
check_longrepr(loaded_report.longrepr)

# make sure we don't blow up on ``toterminal`` call; we don't test the actual output because it is very
# brittle and hard to maintain, but we can assume it is correct because ``toterminal`` is already tested
# elsewhere and we do check the contents of the longrepr object after loading it.
loaded_report.longrepr.toterminal(tw_mock)


class TestHooks:
"""Test that the hooks are working correctly for plugins"""
Expand Down

0 comments on commit a511b98

Please sign in to comment.