Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Serialization hooks #4965

Merged
merged 10 commits into from
Mar 28, 2019
9 changes: 9 additions & 0 deletions changelog/4965.trivial.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def directory_arg(path, optname):
"stepwise",
"warnings",
"logging",
"reports",
blueyed marked this conversation as resolved.
Show resolved Hide resolved
)


Expand Down
35 changes: 35 additions & 0 deletions src/_pytest/hookspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, e.g. 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
# -------------------------------------------------------------------------
Expand Down
151 changes: 151 additions & 0 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
from pprint import pprint

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
from _pytest.pathlib import Path


def getslaveinfoline(node):
Expand Down Expand Up @@ -137,12 +148,136 @@ def head_line(self):
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, Path)):
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):
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)
raise RuntimeError(stream.getvalue())


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

__test__ = False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heh neat, testify used the same approach: https://github.com/Yelp/Testify/blob/335f04a7de583026a1e0d04d006f4c40524fd89d/testify/test_discovery.py#L57-L59

I think this oddity could really be purged if tests were required to be defined in the module they appear in, and then importing names wouldn't add them to discovery -- always was a weird thing about unittest in my mind -- fwiw I ensured that in testify with this code


def __init__(
self,
nodeid,
Expand Down Expand Up @@ -272,3 +407,19 @@ def __init__(self, msg):

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: these implementations don't actually serialize/unserialize the objects: they convert them to/from native Python types which can be serialised/deserialised as JSON. Serialisation, to my mind, means converting something to bytes (or at least str).

That's a fine API if that's what you want, but maybe the names should reflect it. Sorry for bikeshedding. ;-)

Copy link
Member Author

@nicoddemus nicoddemus Mar 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at all, thanks for chipping in. I agree with you, they are not actually serializing anything.

I'm fine with changing those names now, easier to do this before merging. Do you have a suggestion?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe pytest_report_to_json and pytest_report_from_json? It's not strictly accurate either, because they're producing dicts suitable for JSON, not actual JSON, but I think that inaccuracy is widely accepted.

Otherwise:

  • pytest_report_to_dict - accurate but less specific
  • pytest_report_to_jsonable - kind of ugly, IMO

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to avoid restricting this to "json", because we might change the actual representation to something else.

I like pytest_report_to_dict and pytest_report_from_dict; I agree it is less accurate, but I believe it is enough to mention that it only supports built-in types in the docs.

Thanks a lot for the input! I will change to pytest_report_to_dict and pytest_report_from_dict later then. 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at $previous_job we called this def __primitive__(self):

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON-compatible is a stricter standard than built-in types (e.g. no sets in JSON), but so long as the requirements are documented, I think the dict naming is a good option.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about …_to_python and …_from_python? But it does not clearly indicate the overall direction (as with …serialize and …unserialize).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to/from_serializable just to keep the door open for msgpack and yaml as well

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like @RonnyPfannschmidt's, we describe the intent rather what the hook returns in detail. 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, pytest_report_from_serializable/pytest_report_to_serializable. 👍

If nobody objects I will merge this later then.



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"])
blueyed marked this conversation as resolved.
Show resolved Hide resolved
Loading