From 0c63f9901665ea0713cd5bc281db853001b31e78 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 20 Mar 2019 20:24:51 -0300 Subject: [PATCH 01/10] Add experimental _to_json and _from_json to TestReport and CollectReport This methods were moved from xdist (ca03269). Our intention is to keep this code closer to the core, given that it might break easily due to refactorings. Having it in the core might also allow to improve the code by moving some responsibility to the "code" objects (ReprEntry, etc) which are often found in the reports. Finally pytest-xdist and pytest-subtests can use those functions instead of coding it themselves. --- src/_pytest/reports.py | 132 +++++++++++++++++++++++++++++ testing/test_reports.py | 183 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 testing/test_reports.py diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 23c7cbdd9..5f0777375 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,6 +1,14 @@ import py +import six from _pytest._code.code import ExceptionInfo +from _pytest._code.code import ReprEntry +from _pytest._code.code import ReprEntryNative +from _pytest._code.code import ReprExceptionInfo +from _pytest._code.code import ReprFileLocation +from _pytest._code.code import ReprFuncArgs +from _pytest._code.code import ReprLocals +from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest.outcomes import skip @@ -137,6 +145,130 @@ class BaseReport(object): fspath, lineno, domain = self.location return domain + def _to_json(self): + """ + This was originally the serialize_report() function from xdist (ca03269). + + Returns the contents of this report as a dict of builtin entries, suitable for + serialization. + + Experimental method. + """ + + def disassembled_report(rep): + reprtraceback = rep.longrepr.reprtraceback.__dict__.copy() + reprcrash = rep.longrepr.reprcrash.__dict__.copy() + + new_entries = [] + for entry in reprtraceback["reprentries"]: + entry_data = { + "type": type(entry).__name__, + "data": entry.__dict__.copy(), + } + for key, value in entry_data["data"].items(): + if hasattr(value, "__dict__"): + entry_data["data"][key] = value.__dict__.copy() + new_entries.append(entry_data) + + reprtraceback["reprentries"] = new_entries + + return { + "reprcrash": reprcrash, + "reprtraceback": reprtraceback, + "sections": rep.longrepr.sections, + } + + d = self.__dict__.copy() + if hasattr(self.longrepr, "toterminal"): + if hasattr(self.longrepr, "reprtraceback") and hasattr( + self.longrepr, "reprcrash" + ): + d["longrepr"] = disassembled_report(self) + else: + d["longrepr"] = six.text_type(self.longrepr) + else: + d["longrepr"] = self.longrepr + for name in d: + if isinstance(d[name], py.path.local): + d[name] = str(d[name]) + elif name == "result": + d[name] = None # for now + return d + + @classmethod + def _from_json(cls, 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. + + Experimental method. + """ + if reportdict["longrepr"]: + if ( + "reprcrash" in reportdict["longrepr"] + and "reprtraceback" in reportdict["longrepr"] + ): + + reprtraceback = reportdict["longrepr"]["reprtraceback"] + reprcrash = reportdict["longrepr"]["reprcrash"] + + unserialized_entries = [] + reprentry = None + for entry_data in reprtraceback["reprentries"]: + data = entry_data["data"] + entry_type = entry_data["type"] + if entry_type == "ReprEntry": + reprfuncargs = None + reprfileloc = None + reprlocals = None + if data["reprfuncargs"]: + reprfuncargs = ReprFuncArgs(**data["reprfuncargs"]) + if data["reprfileloc"]: + reprfileloc = ReprFileLocation(**data["reprfileloc"]) + if data["reprlocals"]: + reprlocals = ReprLocals(data["reprlocals"]["lines"]) + + reprentry = ReprEntry( + lines=data["lines"], + reprfuncargs=reprfuncargs, + reprlocals=reprlocals, + filelocrepr=reprfileloc, + style=data["style"], + ) + elif entry_type == "ReprEntryNative": + reprentry = ReprEntryNative(data["lines"]) + else: + _report_unserialization_failure(entry_type, cls, reportdict) + unserialized_entries.append(reprentry) + reprtraceback["reprentries"] = unserialized_entries + + exception_info = ReprExceptionInfo( + reprtraceback=ReprTraceback(**reprtraceback), + reprcrash=ReprFileLocation(**reprcrash), + ) + + for section in reportdict["longrepr"]["sections"]: + exception_info.addsection(*section) + reportdict["longrepr"] = exception_info + + return cls(**reportdict) + + +def _report_unserialization_failure(type_name, report_class, reportdict): + from pprint import pprint + + url = "https://github.com/pytest-dev/pytest/issues" + stream = py.io.TextIO() + pprint("-" * 100, stream=stream) + pprint("INTERNALERROR: Unknown entry type returned: %s" % type_name, stream=stream) + pprint("report_name: %s" % report_class, stream=stream) + pprint(reportdict, stream=stream) + pprint("Please report this bug at %s" % url, stream=stream) + pprint("-" * 100, stream=stream) + assert 0, stream.getvalue() + class TestReport(BaseReport): """ Basic test report object (also used for setup and teardown calls if diff --git a/testing/test_reports.py b/testing/test_reports.py new file mode 100644 index 000000000..322995326 --- /dev/null +++ b/testing/test_reports.py @@ -0,0 +1,183 @@ +from _pytest.reports import CollectReport +from _pytest.reports import TestReport + + +class TestReportSerialization(object): + """ + All the tests in this class came originally from test_remote.py in xdist (ca03269). + """ + + def test_xdist_longrepr_to_str_issue_241(self, testdir): + testdir.makepyfile( + """ + import os + def test_a(): assert False + def test_b(): pass + """ + ) + reprec = testdir.inline_run() + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 6 + test_a_call = reports[1] + assert test_a_call.when == "call" + assert test_a_call.outcome == "failed" + assert test_a_call._to_json()["longrepr"]["reprtraceback"]["style"] == "long" + test_b_call = reports[4] + assert test_b_call.when == "call" + assert test_b_call.outcome == "passed" + assert test_b_call._to_json()["longrepr"] is None + + def test_xdist_report_longrepr_reprcrash_130(self, testdir): + reprec = testdir.inline_runsource( + """ + import py + def test_fail(): assert False, 'Expected Message' + """ + ) + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 3 + rep = reports[1] + added_section = ("Failure Metadata", str("metadata metadata"), "*") + rep.longrepr.sections.append(added_section) + d = rep._to_json() + a = TestReport._from_json(d) + # Check assembled == rep + assert a.__dict__.keys() == rep.__dict__.keys() + for key in rep.__dict__.keys(): + if key != "longrepr": + assert getattr(a, key) == getattr(rep, key) + assert rep.longrepr.reprcrash.lineno == a.longrepr.reprcrash.lineno + assert rep.longrepr.reprcrash.message == a.longrepr.reprcrash.message + assert rep.longrepr.reprcrash.path == a.longrepr.reprcrash.path + assert rep.longrepr.reprtraceback.entrysep == a.longrepr.reprtraceback.entrysep + assert ( + rep.longrepr.reprtraceback.extraline == a.longrepr.reprtraceback.extraline + ) + assert rep.longrepr.reprtraceback.style == a.longrepr.reprtraceback.style + assert rep.longrepr.sections == a.longrepr.sections + # Missing section attribute PR171 + assert added_section in a.longrepr.sections + + def test_reprentries_serialization_170(self, testdir): + from _pytest._code.code import ReprEntry + + reprec = testdir.inline_runsource( + """ + def test_repr_entry(): + x = 0 + assert x + """, + "--showlocals", + ) + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 3 + rep = reports[1] + d = rep._to_json() + a = TestReport._from_json(d) + + rep_entries = rep.longrepr.reprtraceback.reprentries + a_entries = a.longrepr.reprtraceback.reprentries + for i in range(len(a_entries)): + assert isinstance(rep_entries[i], ReprEntry) + assert rep_entries[i].lines == a_entries[i].lines + assert rep_entries[i].reprfileloc.lineno == a_entries[i].reprfileloc.lineno + assert ( + rep_entries[i].reprfileloc.message == a_entries[i].reprfileloc.message + ) + assert rep_entries[i].reprfileloc.path == a_entries[i].reprfileloc.path + assert rep_entries[i].reprfuncargs.args == a_entries[i].reprfuncargs.args + assert rep_entries[i].reprlocals.lines == a_entries[i].reprlocals.lines + assert rep_entries[i].style == a_entries[i].style + + def test_reprentries_serialization_196(self, testdir): + from _pytest._code.code import ReprEntryNative + + reprec = testdir.inline_runsource( + """ + def test_repr_entry_native(): + x = 0 + assert x + """, + "--tb=native", + ) + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 3 + rep = reports[1] + d = rep._to_json() + a = TestReport._from_json(d) + + rep_entries = rep.longrepr.reprtraceback.reprentries + a_entries = a.longrepr.reprtraceback.reprentries + for i in range(len(a_entries)): + assert isinstance(rep_entries[i], ReprEntryNative) + assert rep_entries[i].lines == a_entries[i].lines + + def test_itemreport_outcomes(self, testdir): + reprec = testdir.inline_runsource( + """ + import py + def test_pass(): pass + def test_fail(): 0/0 + @py.test.mark.skipif("True") + def test_skip(): pass + def test_skip_imperative(): + py.test.skip("hello") + @py.test.mark.xfail("True") + def test_xfail(): 0/0 + def test_xfail_imperative(): + py.test.xfail("hello") + """ + ) + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 17 # with setup/teardown "passed" reports + for rep in reports: + d = rep._to_json() + newrep = TestReport._from_json(d) + assert newrep.passed == rep.passed + assert newrep.failed == rep.failed + assert newrep.skipped == rep.skipped + if newrep.skipped and not hasattr(newrep, "wasxfail"): + assert len(newrep.longrepr) == 3 + assert newrep.outcome == rep.outcome + assert newrep.when == rep.when + assert newrep.keywords == rep.keywords + if rep.failed: + assert newrep.longreprtext == rep.longreprtext + + def test_collectreport_passed(self, testdir): + reprec = testdir.inline_runsource("def test_func(): pass") + reports = reprec.getreports("pytest_collectreport") + for rep in reports: + d = rep._to_json() + newrep = CollectReport._from_json(d) + assert newrep.passed == rep.passed + assert newrep.failed == rep.failed + assert newrep.skipped == rep.skipped + + def test_collectreport_fail(self, testdir): + reprec = testdir.inline_runsource("qwe abc") + reports = reprec.getreports("pytest_collectreport") + assert reports + for rep in reports: + d = rep._to_json() + newrep = CollectReport._from_json(d) + assert newrep.passed == rep.passed + assert newrep.failed == rep.failed + assert newrep.skipped == rep.skipped + if rep.failed: + assert newrep.longrepr == str(rep.longrepr) + + def test_extended_report_deserialization(self, testdir): + reprec = testdir.inline_runsource("qwe abc") + reports = reprec.getreports("pytest_collectreport") + assert reports + for rep in reports: + rep.extra = True + d = rep._to_json() + newrep = CollectReport._from_json(d) + assert newrep.extra + assert newrep.passed == rep.passed + assert newrep.failed == rep.failed + assert newrep.skipped == rep.skipped + if rep.failed: + assert newrep.longrepr == str(rep.longrepr) From 7b9a41452476132edac9d142a9165dfd4f75a762 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 20 Mar 2019 20:45:15 -0300 Subject: [PATCH 02/10] Add pytest_report_serialize and pytest_report_unserialize hooks These hooks will be used by pytest-xdist and pytest-subtests to serialize and customize reports. --- src/_pytest/config/__init__.py | 1 + src/_pytest/hookspec.py | 35 ++++++++++++++++++++++++ src/_pytest/reports.py | 16 +++++++++++ testing/test_reports.py | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index ca2bebabc..4ed9deac4 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -140,6 +140,7 @@ default_plugins = ( "stepwise", "warnings", "logging", + "reports", ) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 0641e3bc5..a465923f9 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -375,6 +375,41 @@ def pytest_runtest_logreport(report): the respective phase of executing a test. """ +@hookspec(firstresult=True) +def pytest_report_serialize(config, report): + """ + .. warning:: + This hook is experimental and subject to change between pytest releases, even + bug fixes. + + The intent is for this to be used by plugins maintained by the core-devs, such + as ``pytest-xdist``, ``pytest-subtests``, and as a replacement for the internal + 'resultlog' plugin. + + In the future it might become part of the public hook API. + + Serializes the given report object into a data structure suitable for sending + over the wire, or converted to JSON. + """ + + +@hookspec(firstresult=True) +def pytest_report_unserialize(config, data): + """ + .. warning:: + This hook is experimental and subject to change between pytest releases, even + bug fixes. + + The intent is for this to be used by plugins maintained by the core-devs, such + as ``pytest-xdist``, ``pytest-subtests``, and as a replacement for the internal + 'resultlog' plugin. + + In the future it might become part of the public hook API. + + Restores a report object previously serialized with pytest_report_serialize().; + """ + + # ------------------------------------------------------------------------- # Fixture related hooks # ------------------------------------------------------------------------- diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 5f0777375..b4160eb96 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -404,3 +404,19 @@ class CollectErrorRepr(TerminalRepr): def toterminal(self, out): out.line(self.longrepr, red=True) + + +def pytest_report_serialize(report): + if isinstance(report, (TestReport, CollectReport)): + data = report._to_json() + data["_report_type"] = report.__class__.__name__ + return data + + +def pytest_report_unserialize(data): + if "_report_type" in data: + if data["_report_type"] == "TestReport": + return TestReport._from_json(data) + elif data["_report_type"] == "CollectReport": + return CollectReport._from_json(data) + assert "Unknown report_type unserialize data: {}".format(data["_report_type"]) diff --git a/testing/test_reports.py b/testing/test_reports.py index 322995326..3413a805b 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -181,3 +181,53 @@ class TestReportSerialization(object): assert newrep.skipped == rep.skipped if rep.failed: assert newrep.longrepr == str(rep.longrepr) + + +class TestHooks: + """Test that the hooks are working correctly for plugins""" + + def test_test_report(self, testdir, pytestconfig): + testdir.makepyfile( + """ + import os + def test_a(): assert False + def test_b(): pass + """ + ) + reprec = testdir.inline_run() + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 6 + for rep in reports: + data = pytestconfig.hook.pytest_report_serialize( + config=pytestconfig, report=rep + ) + assert data["_report_type"] == "TestReport" + new_rep = pytestconfig.hook.pytest_report_unserialize( + config=pytestconfig, data=data + ) + assert new_rep.nodeid == rep.nodeid + assert new_rep.when == rep.when + assert new_rep.outcome == rep.outcome + + def test_collect_report(self, testdir, pytestconfig): + testdir.makepyfile( + """ + import os + def test_a(): assert False + def test_b(): pass + """ + ) + reprec = testdir.inline_run() + reports = reprec.getreports("pytest_collectreport") + assert len(reports) == 2 + for rep in reports: + data = pytestconfig.hook.pytest_report_serialize( + config=pytestconfig, report=rep + ) + assert data["_report_type"] == "CollectReport" + new_rep = pytestconfig.hook.pytest_report_unserialize( + config=pytestconfig, data=data + ) + assert new_rep.nodeid == rep.nodeid + assert new_rep.when == "collect" + assert new_rep.outcome == rep.outcome From d856f4e51fac6be6989659f34702a533becc1a91 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 20 Mar 2019 21:05:12 -0300 Subject: [PATCH 03/10] Make sure TestReports are not collected as test classes --- src/_pytest/reports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index b4160eb96..04cc52691 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -275,6 +275,8 @@ class TestReport(BaseReport): they fail). """ + __test__ = False + def __init__( self, nodeid, From f2e0c740d39259b6eff3fa58971d3509378b1366 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 21 Mar 2019 18:39:12 -0300 Subject: [PATCH 04/10] Code review suggestions --- src/_pytest/hookspec.py | 4 ++-- src/_pytest/reports.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index a465923f9..afcee5397 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -389,7 +389,7 @@ def pytest_report_serialize(config, report): In the future it might become part of the public hook API. Serializes the given report object into a data structure suitable for sending - over the wire, or converted to JSON. + over the wire, e.g. converted to JSON. """ @@ -406,7 +406,7 @@ def pytest_report_unserialize(config, data): In the future it might become part of the public hook API. - Restores a report object previously serialized with pytest_report_serialize().; + Restores a report object previously serialized with pytest_report_serialize(). """ diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 04cc52691..74faa3f8c 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -267,7 +267,7 @@ def _report_unserialization_failure(type_name, report_class, reportdict): pprint(reportdict, stream=stream) pprint("Please report this bug at %s" % url, stream=stream) pprint("-" * 100, stream=stream) - assert 0, stream.getvalue() + raise RuntimeError(stream.getvalue()) class TestReport(BaseReport): From 645774295fcd913c15cfa6d37c31ee56cd58509f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 21 Mar 2019 18:44:08 -0300 Subject: [PATCH 05/10] Add CHANGELOG --- changelog/4965.trivial.rst | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog/4965.trivial.rst diff --git a/changelog/4965.trivial.rst b/changelog/4965.trivial.rst new file mode 100644 index 000000000..487a22c7a --- /dev/null +++ b/changelog/4965.trivial.rst @@ -0,0 +1,9 @@ +New ``pytest_report_serialize`` and ``pytest_report_unserialize`` **experimental** hooks. + +These hooks will be used by ``pytest-xdist``, ``pytest-subtests``, and the replacement for +resultlog to serialize and customize reports. + +They are experimental, meaning that their details might change or even be removed +completely in future patch releases without warning. + +Feedback is welcome from plugin authors and users alike. From e4eec3416ad382f8b23acf75435a89a594bcefa6 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 25 Mar 2019 19:12:02 -0300 Subject: [PATCH 06/10] Note that tests from xdist reference the correct xdist issues --- testing/test_reports.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/testing/test_reports.py b/testing/test_reports.py index 3413a805b..f37ead893 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -3,11 +3,12 @@ from _pytest.reports import TestReport class TestReportSerialization(object): - """ - All the tests in this class came originally from test_remote.py in xdist (ca03269). - """ - def test_xdist_longrepr_to_str_issue_241(self, testdir): + """ + Regarding issue pytest-xdist#241 + + This test came originally from test_remote.py in xdist (ca03269). + """ testdir.makepyfile( """ import os @@ -28,6 +29,10 @@ class TestReportSerialization(object): assert test_b_call._to_json()["longrepr"] is None def test_xdist_report_longrepr_reprcrash_130(self, testdir): + """Regarding issue pytest-xdist#130 + + This test came originally from test_remote.py in xdist (ca03269). + """ reprec = testdir.inline_runsource( """ import py @@ -59,6 +64,10 @@ class TestReportSerialization(object): assert added_section in a.longrepr.sections def test_reprentries_serialization_170(self, testdir): + """Regarding issue pytest-xdist#170 + + This test came originally from test_remote.py in xdist (ca03269). + """ from _pytest._code.code import ReprEntry reprec = testdir.inline_runsource( @@ -90,6 +99,10 @@ class TestReportSerialization(object): assert rep_entries[i].style == a_entries[i].style def test_reprentries_serialization_196(self, testdir): + """Regarding issue pytest-xdist#196 + + This test came originally from test_remote.py in xdist (ca03269). + """ from _pytest._code.code import ReprEntryNative reprec = testdir.inline_runsource( @@ -113,6 +126,9 @@ class TestReportSerialization(object): assert rep_entries[i].lines == a_entries[i].lines def test_itemreport_outcomes(self, testdir): + """ + This test came originally from test_remote.py in xdist (ca03269). + """ reprec = testdir.inline_runsource( """ import py @@ -145,6 +161,7 @@ class TestReportSerialization(object): assert newrep.longreprtext == rep.longreprtext def test_collectreport_passed(self, testdir): + """This test came originally from test_remote.py in xdist (ca03269).""" reprec = testdir.inline_runsource("def test_func(): pass") reports = reprec.getreports("pytest_collectreport") for rep in reports: @@ -155,6 +172,7 @@ class TestReportSerialization(object): assert newrep.skipped == rep.skipped def test_collectreport_fail(self, testdir): + """This test came originally from test_remote.py in xdist (ca03269).""" reprec = testdir.inline_runsource("qwe abc") reports = reprec.getreports("pytest_collectreport") assert reports @@ -168,6 +186,7 @@ class TestReportSerialization(object): assert newrep.longrepr == str(rep.longrepr) def test_extended_report_deserialization(self, testdir): + """This test came originally from test_remote.py in xdist (ca03269).""" reprec = testdir.inline_runsource("qwe abc") reports = reprec.getreports("pytest_collectreport") assert reports From ceef0af1aea4c8db3b8670a2ff4f127a56028bb4 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 25 Mar 2019 19:19:59 -0300 Subject: [PATCH 07/10] Improve coverage for to_json() with paths in reports --- src/_pytest/reports.py | 3 ++- testing/test_reports.py | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 74faa3f8c..8ab42478f 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -11,6 +11,7 @@ from _pytest._code.code import ReprLocals from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest.outcomes import skip +from _pytest.pathlib import Path def getslaveinfoline(node): @@ -189,7 +190,7 @@ class BaseReport(object): else: d["longrepr"] = self.longrepr for name in d: - if isinstance(d[name], py.path.local): + if isinstance(d[name], (py.path.local, Path)): d[name] = str(d[name]) elif name == "result": d[name] = None # for now diff --git a/testing/test_reports.py b/testing/test_reports.py index f37ead893..2f9162e10 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,3 +1,4 @@ +from _pytest.pathlib import Path from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -11,7 +12,6 @@ class TestReportSerialization(object): """ testdir.makepyfile( """ - import os def test_a(): assert False def test_b(): pass """ @@ -35,8 +35,8 @@ class TestReportSerialization(object): """ reprec = testdir.inline_runsource( """ - import py - def test_fail(): assert False, 'Expected Message' + def test_fail(): + assert False, 'Expected Message' """ ) reports = reprec.getreports("pytest_runtest_logreport") @@ -201,6 +201,24 @@ class TestReportSerialization(object): if rep.failed: assert newrep.longrepr == str(rep.longrepr) + def test_paths_support(self, testdir): + """Report attributes which are py.path or pathlib objects should become strings.""" + testdir.makepyfile( + """ + def test_a(): + assert False + """ + ) + reprec = testdir.inline_run() + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 3 + test_a_call = reports[1] + test_a_call.path1 = testdir.tmpdir + test_a_call.path2 = Path(testdir.tmpdir) + data = test_a_call._to_json() + assert data["path1"] == str(testdir.tmpdir) + assert data["path2"] == str(testdir.tmpdir) + class TestHooks: """Test that the hooks are working correctly for plugins""" From 2d77018d1b0b3c1c291bacf48885321c8c87048f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 25 Mar 2019 19:26:40 -0300 Subject: [PATCH 08/10] Improve coverage for _report_unserialization_failure --- src/_pytest/reports.py | 4 ++-- testing/test_reports.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 8ab42478f..cd7a0b57f 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,3 +1,5 @@ +from pprint import pprint + import py import six @@ -258,8 +260,6 @@ class BaseReport(object): def _report_unserialization_failure(type_name, report_class, reportdict): - from pprint import pprint - url = "https://github.com/pytest-dev/pytest/issues" stream = py.io.TextIO() pprint("-" * 100, stream=stream) diff --git a/testing/test_reports.py b/testing/test_reports.py index 2f9162e10..879a9098d 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,3 +1,4 @@ +import pytest from _pytest.pathlib import Path from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -219,6 +220,28 @@ class TestReportSerialization(object): 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.""" + testdir.makepyfile( + """ + def test_a(): + assert False + """ + ) + reprec = testdir.inline_run() + reports = reprec.getreports("pytest_runtest_logreport") + assert len(reports) == 3 + test_a_call = reports[1] + data = test_a_call._to_json() + entry = data["longrepr"]["reprtraceback"]["reprentries"][0] + assert entry["type"] == "ReprEntry" + + entry["type"] = "Unknown" + with pytest.raises( + RuntimeError, match="INTERNALERROR: Unknown entry type returned: Unknown" + ): + TestReport._from_json(data) + class TestHooks: """Test that the hooks are working correctly for plugins""" From 9311d822c7718eadd6167f1dcd67d00ec47151bd Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 26 Mar 2019 12:47:31 -0300 Subject: [PATCH 09/10] Fix assertion in pytest_report_unserialize --- src/_pytest/reports.py | 4 +++- testing/test_reports.py | 24 ++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index cd7a0b57f..679d72c9d 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -422,4 +422,6 @@ def pytest_report_unserialize(data): return TestReport._from_json(data) elif data["_report_type"] == "CollectReport": return CollectReport._from_json(data) - assert "Unknown report_type unserialize data: {}".format(data["_report_type"]) + assert False, "Unknown report_type unserialize data: {}".format( + data["_report_type"] + ) diff --git a/testing/test_reports.py b/testing/test_reports.py index 879a9098d..22d5fce34 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -249,7 +249,6 @@ class TestHooks: def test_test_report(self, testdir, pytestconfig): testdir.makepyfile( """ - import os def test_a(): assert False def test_b(): pass """ @@ -272,7 +271,6 @@ class TestHooks: def test_collect_report(self, testdir, pytestconfig): testdir.makepyfile( """ - import os def test_a(): assert False def test_b(): pass """ @@ -291,3 +289,25 @@ class TestHooks: assert new_rep.nodeid == rep.nodeid assert new_rep.when == "collect" assert new_rep.outcome == rep.outcome + + @pytest.mark.parametrize( + "hook_name", ["pytest_runtest_logreport", "pytest_collectreport"] + ) + def test_invalid_report_types(self, testdir, pytestconfig, hook_name): + testdir.makepyfile( + """ + def test_a(): pass + """ + ) + reprec = testdir.inline_run() + reports = reprec.getreports(hook_name) + assert reports + rep = reports[0] + data = pytestconfig.hook.pytest_report_serialize( + config=pytestconfig, report=rep + ) + data["_report_type"] = "Unknown" + with pytest.raises(AssertionError): + _ = pytestconfig.hook.pytest_report_unserialize( + config=pytestconfig, data=data + ) From 65c8e8a09e56acbb992a4cad462f01cfd82617d0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 28 Mar 2019 13:41:56 -0300 Subject: [PATCH 10/10] Rename hooks: to/from_serializable --- changelog/4965.trivial.rst | 2 +- src/_pytest/hookspec.py | 6 +++--- src/_pytest/reports.py | 4 ++-- testing/test_reports.py | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/changelog/4965.trivial.rst b/changelog/4965.trivial.rst index 487a22c7a..36db733f9 100644 --- a/changelog/4965.trivial.rst +++ b/changelog/4965.trivial.rst @@ -1,4 +1,4 @@ -New ``pytest_report_serialize`` and ``pytest_report_unserialize`` **experimental** hooks. +New ``pytest_report_to_serializable`` and ``pytest_report_from_serializable`` **experimental** hooks. These hooks will be used by ``pytest-xdist``, ``pytest-subtests``, and the replacement for resultlog to serialize and customize reports. diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index afcee5397..65a7f43ed 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -376,7 +376,7 @@ def pytest_runtest_logreport(report): @hookspec(firstresult=True) -def pytest_report_serialize(config, report): +def pytest_report_to_serializable(config, report): """ .. warning:: This hook is experimental and subject to change between pytest releases, even @@ -394,7 +394,7 @@ def pytest_report_serialize(config, report): @hookspec(firstresult=True) -def pytest_report_unserialize(config, data): +def pytest_report_from_serializable(config, data): """ .. warning:: This hook is experimental and subject to change between pytest releases, even @@ -406,7 +406,7 @@ def pytest_report_unserialize(config, data): In the future it might become part of the public hook API. - Restores a report object previously serialized with pytest_report_serialize(). + Restores a report object previously serialized with pytest_report_to_serializable(). """ diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 679d72c9d..d2df4d21f 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -409,14 +409,14 @@ class CollectErrorRepr(TerminalRepr): out.line(self.longrepr, red=True) -def pytest_report_serialize(report): +def pytest_report_to_serializable(report): if isinstance(report, (TestReport, CollectReport)): data = report._to_json() data["_report_type"] = report.__class__.__name__ return data -def pytest_report_unserialize(data): +def pytest_report_from_serializable(data): if "_report_type" in data: if data["_report_type"] == "TestReport": return TestReport._from_json(data) diff --git a/testing/test_reports.py b/testing/test_reports.py index 22d5fce34..6d2b167f8 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -257,11 +257,11 @@ class TestHooks: reports = reprec.getreports("pytest_runtest_logreport") assert len(reports) == 6 for rep in reports: - data = pytestconfig.hook.pytest_report_serialize( + data = pytestconfig.hook.pytest_report_to_serializable( config=pytestconfig, report=rep ) assert data["_report_type"] == "TestReport" - new_rep = pytestconfig.hook.pytest_report_unserialize( + new_rep = pytestconfig.hook.pytest_report_from_serializable( config=pytestconfig, data=data ) assert new_rep.nodeid == rep.nodeid @@ -279,11 +279,11 @@ class TestHooks: reports = reprec.getreports("pytest_collectreport") assert len(reports) == 2 for rep in reports: - data = pytestconfig.hook.pytest_report_serialize( + data = pytestconfig.hook.pytest_report_to_serializable( config=pytestconfig, report=rep ) assert data["_report_type"] == "CollectReport" - new_rep = pytestconfig.hook.pytest_report_unserialize( + new_rep = pytestconfig.hook.pytest_report_from_serializable( config=pytestconfig, data=data ) assert new_rep.nodeid == rep.nodeid @@ -303,11 +303,11 @@ class TestHooks: reports = reprec.getreports(hook_name) assert reports rep = reports[0] - data = pytestconfig.hook.pytest_report_serialize( + data = pytestconfig.hook.pytest_report_to_serializable( config=pytestconfig, report=rep ) data["_report_type"] = "Unknown" with pytest.raises(AssertionError): - _ = pytestconfig.hook.pytest_report_unserialize( + _ = pytestconfig.hook.pytest_report_from_serializable( config=pytestconfig, data=data )