Skip to content

Commit a9fe1e1

Browse files
authored
Merge pull request #4965 from nicoddemus/serialization-hooks
Serialization hooks
2 parents c92021f + 65c8e8a commit a9fe1e1

File tree

5 files changed

+511
-0
lines changed

5 files changed

+511
-0
lines changed

changelog/4965.trivial.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
New ``pytest_report_to_serializable`` and ``pytest_report_from_serializable`` **experimental** hooks.
2+
3+
These hooks will be used by ``pytest-xdist``, ``pytest-subtests``, and the replacement for
4+
resultlog to serialize and customize reports.
5+
6+
They are experimental, meaning that their details might change or even be removed
7+
completely in future patch releases without warning.
8+
9+
Feedback is welcome from plugin authors and users alike.

src/_pytest/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ def directory_arg(path, optname):
140140
"stepwise",
141141
"warnings",
142142
"logging",
143+
"reports",
143144
)
144145

145146

src/_pytest/hookspec.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,41 @@ def pytest_runtest_logreport(report):
375375
the respective phase of executing a test. """
376376

377377

378+
@hookspec(firstresult=True)
379+
def pytest_report_to_serializable(config, report):
380+
"""
381+
.. warning::
382+
This hook is experimental and subject to change between pytest releases, even
383+
bug fixes.
384+
385+
The intent is for this to be used by plugins maintained by the core-devs, such
386+
as ``pytest-xdist``, ``pytest-subtests``, and as a replacement for the internal
387+
'resultlog' plugin.
388+
389+
In the future it might become part of the public hook API.
390+
391+
Serializes the given report object into a data structure suitable for sending
392+
over the wire, e.g. converted to JSON.
393+
"""
394+
395+
396+
@hookspec(firstresult=True)
397+
def pytest_report_from_serializable(config, data):
398+
"""
399+
.. warning::
400+
This hook is experimental and subject to change between pytest releases, even
401+
bug fixes.
402+
403+
The intent is for this to be used by plugins maintained by the core-devs, such
404+
as ``pytest-xdist``, ``pytest-subtests``, and as a replacement for the internal
405+
'resultlog' plugin.
406+
407+
In the future it might become part of the public hook API.
408+
409+
Restores a report object previously serialized with pytest_report_to_serializable().
410+
"""
411+
412+
378413
# -------------------------------------------------------------------------
379414
# Fixture related hooks
380415
# -------------------------------------------------------------------------

src/_pytest/reports.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
from pprint import pprint
2+
13
import py
4+
import six
25

36
from _pytest._code.code import ExceptionInfo
7+
from _pytest._code.code import ReprEntry
8+
from _pytest._code.code import ReprEntryNative
9+
from _pytest._code.code import ReprExceptionInfo
10+
from _pytest._code.code import ReprFileLocation
11+
from _pytest._code.code import ReprFuncArgs
12+
from _pytest._code.code import ReprLocals
13+
from _pytest._code.code import ReprTraceback
414
from _pytest._code.code import TerminalRepr
515
from _pytest.outcomes import skip
16+
from _pytest.pathlib import Path
617

718

819
def getslaveinfoline(node):
@@ -137,12 +148,136 @@ def head_line(self):
137148
fspath, lineno, domain = self.location
138149
return domain
139150

151+
def _to_json(self):
152+
"""
153+
This was originally the serialize_report() function from xdist (ca03269).
154+
155+
Returns the contents of this report as a dict of builtin entries, suitable for
156+
serialization.
157+
158+
Experimental method.
159+
"""
160+
161+
def disassembled_report(rep):
162+
reprtraceback = rep.longrepr.reprtraceback.__dict__.copy()
163+
reprcrash = rep.longrepr.reprcrash.__dict__.copy()
164+
165+
new_entries = []
166+
for entry in reprtraceback["reprentries"]:
167+
entry_data = {
168+
"type": type(entry).__name__,
169+
"data": entry.__dict__.copy(),
170+
}
171+
for key, value in entry_data["data"].items():
172+
if hasattr(value, "__dict__"):
173+
entry_data["data"][key] = value.__dict__.copy()
174+
new_entries.append(entry_data)
175+
176+
reprtraceback["reprentries"] = new_entries
177+
178+
return {
179+
"reprcrash": reprcrash,
180+
"reprtraceback": reprtraceback,
181+
"sections": rep.longrepr.sections,
182+
}
183+
184+
d = self.__dict__.copy()
185+
if hasattr(self.longrepr, "toterminal"):
186+
if hasattr(self.longrepr, "reprtraceback") and hasattr(
187+
self.longrepr, "reprcrash"
188+
):
189+
d["longrepr"] = disassembled_report(self)
190+
else:
191+
d["longrepr"] = six.text_type(self.longrepr)
192+
else:
193+
d["longrepr"] = self.longrepr
194+
for name in d:
195+
if isinstance(d[name], (py.path.local, Path)):
196+
d[name] = str(d[name])
197+
elif name == "result":
198+
d[name] = None # for now
199+
return d
200+
201+
@classmethod
202+
def _from_json(cls, reportdict):
203+
"""
204+
This was originally the serialize_report() function from xdist (ca03269).
205+
206+
Factory method that returns either a TestReport or CollectReport, depending on the calling
207+
class. It's the callers responsibility to know which class to pass here.
208+
209+
Experimental method.
210+
"""
211+
if reportdict["longrepr"]:
212+
if (
213+
"reprcrash" in reportdict["longrepr"]
214+
and "reprtraceback" in reportdict["longrepr"]
215+
):
216+
217+
reprtraceback = reportdict["longrepr"]["reprtraceback"]
218+
reprcrash = reportdict["longrepr"]["reprcrash"]
219+
220+
unserialized_entries = []
221+
reprentry = None
222+
for entry_data in reprtraceback["reprentries"]:
223+
data = entry_data["data"]
224+
entry_type = entry_data["type"]
225+
if entry_type == "ReprEntry":
226+
reprfuncargs = None
227+
reprfileloc = None
228+
reprlocals = None
229+
if data["reprfuncargs"]:
230+
reprfuncargs = ReprFuncArgs(**data["reprfuncargs"])
231+
if data["reprfileloc"]:
232+
reprfileloc = ReprFileLocation(**data["reprfileloc"])
233+
if data["reprlocals"]:
234+
reprlocals = ReprLocals(data["reprlocals"]["lines"])
235+
236+
reprentry = ReprEntry(
237+
lines=data["lines"],
238+
reprfuncargs=reprfuncargs,
239+
reprlocals=reprlocals,
240+
filelocrepr=reprfileloc,
241+
style=data["style"],
242+
)
243+
elif entry_type == "ReprEntryNative":
244+
reprentry = ReprEntryNative(data["lines"])
245+
else:
246+
_report_unserialization_failure(entry_type, cls, reportdict)
247+
unserialized_entries.append(reprentry)
248+
reprtraceback["reprentries"] = unserialized_entries
249+
250+
exception_info = ReprExceptionInfo(
251+
reprtraceback=ReprTraceback(**reprtraceback),
252+
reprcrash=ReprFileLocation(**reprcrash),
253+
)
254+
255+
for section in reportdict["longrepr"]["sections"]:
256+
exception_info.addsection(*section)
257+
reportdict["longrepr"] = exception_info
258+
259+
return cls(**reportdict)
260+
261+
262+
def _report_unserialization_failure(type_name, report_class, reportdict):
263+
url = "https://github.com/pytest-dev/pytest/issues"
264+
stream = py.io.TextIO()
265+
pprint("-" * 100, stream=stream)
266+
pprint("INTERNALERROR: Unknown entry type returned: %s" % type_name, stream=stream)
267+
pprint("report_name: %s" % report_class, stream=stream)
268+
pprint(reportdict, stream=stream)
269+
pprint("Please report this bug at %s" % url, stream=stream)
270+
pprint("-" * 100, stream=stream)
271+
raise RuntimeError(stream.getvalue())
272+
140273

141274
class TestReport(BaseReport):
142275
""" Basic test report object (also used for setup and teardown calls if
143276
they fail).
144277
"""
145278

279+
__test__ = False
280+
146281
def __init__(
147282
self,
148283
nodeid,
@@ -272,3 +407,21 @@ def __init__(self, msg):
272407

273408
def toterminal(self, out):
274409
out.line(self.longrepr, red=True)
410+
411+
412+
def pytest_report_to_serializable(report):
413+
if isinstance(report, (TestReport, CollectReport)):
414+
data = report._to_json()
415+
data["_report_type"] = report.__class__.__name__
416+
return data
417+
418+
419+
def pytest_report_from_serializable(data):
420+
if "_report_type" in data:
421+
if data["_report_type"] == "TestReport":
422+
return TestReport._from_json(data)
423+
elif data["_report_type"] == "CollectReport":
424+
return CollectReport._from_json(data)
425+
assert False, "Unknown report_type unserialize data: {}".format(
426+
data["_report_type"]
427+
)

0 commit comments

Comments
 (0)