Serialize/deserialize chained exceptions

Fix #5786
This commit is contained in:
Bruno Oliveira 2019-08-26 13:21:53 -03:00 committed by Bruno Oliveira
parent 7a69365486
commit a511b98da9
3 changed files with 122 additions and 23 deletions

View File

@ -0,0 +1,2 @@
Chained exceptions in test and collection reports are now correctly serialized, allowing plugins like
``pytest-xdist`` to display them properly.

View File

@ -3,6 +3,7 @@ from typing import Optional
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
@ -160,7 +161,7 @@ class BaseReport:
Experimental method.
"""
return _test_report_to_json(self)
return _report_to_json(self)
@classmethod
def _from_json(cls, reportdict):
@ -172,7 +173,7 @@ class BaseReport:
Experimental method.
"""
kwargs = _test_report_kwargs_from_json(reportdict)
kwargs = _report_kwargs_from_json(reportdict)
return cls(**kwargs)
@ -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).
@ -366,22 +367,35 @@ def _test_report_to_json(test_report):
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,
}
d = test_report.__dict__.copy()
if hasattr(test_report.longrepr, "toterminal"):
if hasattr(test_report.longrepr, "reprtraceback") and hasattr(
test_report.longrepr, "reprcrash"
):
d["longrepr"] = serialize_longrepr(test_report)
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:
d["longrepr"] = str(test_report.longrepr)
result["chain"] = None
return result
d = report.__dict__.copy()
if hasattr(report.longrepr, "toterminal"):
if hasattr(report.longrepr, "reprtraceback") and hasattr(
report.longrepr, "reprcrash"
):
d["longrepr"] = serialize_longrepr(report)
else:
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])
@ -390,12 +404,11 @@ def _test_report_to_json(test_report):
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):
@ -439,12 +452,26 @@ def _test_report_kwargs_from_json(reportdict):
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)

View File

@ -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
@ -220,8 +221,8 @@ class TestReportSerialization:
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():
@ -242,6 +243,75 @@ class TestReportSerialization:
):
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"""