Skip to content

Commit

Permalink
Support for pytest-xdist
Browse files Browse the repository at this point in the history
  • Loading branch information
christiansandberg committed Aug 16, 2020
1 parent 090670b commit 282208d
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 22 deletions.
4 changes: 3 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,16 @@ The standard context available for all templates include the following:
* ``started``: Unix timestamp when session started
* ``ended``: Unix timestamp when session was finished
* ``warnings[]``: List of warnings.WarningMessage
* ``items``: Dictionary of collected items with nodeid as keys
* ``tests[]``: List of each test run as dictionaries with the following keys:

* ``item``: `Item <https://docs.pytest.org/en/latest/reference.html#item>`_
* ``phases[]``: List of each test phase (setup, call, teardown) as dictionaries
with the following keys:

* ``call``: `CallInfo <https://docs.pytest.org/en/latest/reference.html#callinfo>`_
* ``call`` (optional): `CallInfo <https://docs.pytest.org/en/latest/reference.html#callinfo>`_ if available.
* ``report``: `TestReport <https://docs.pytest.org/en/latest/reference.html#testreport>`_
* ``sections``: Same as ``report.sections`` but only the sections captured for this phase
* ``log_records[]``: List of `logging.LogRecord <https://docs.python.org/3/library/logging.html#logging.LogRecord>`_
recorded during the test phase
* ``status``: Dictionary with the following keys:
Expand Down
86 changes: 65 additions & 21 deletions pytest_reporter/plugin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import collections
from pathlib import Path
import logging
import time
Expand Down Expand Up @@ -47,15 +48,37 @@ def pytest_configure(config):
config.pluginmanager.register(config._reporter)


@pytest.hookimpl(hookwrapper=True)
def pytest_collection(session):
yield
if not hasattr(session, "items") and hasattr(session.config, "_reporter"):
# Collection was skipped (probably due to xdist)
session.perform_collect()


@pytest.hookimpl(tryfirst=True)
def pytest_reporter_template_dirs(config):
return config.getoption("--template-dir")


def pytest_reporter_context(context, config):
"""Add status to test runs and phases."""
nof_sections_per_node = {}
for test in context["tests"]:
for phase in test["phases"]:
# The sections attribute in the TestReport object contains not only the
# sections captured in this specific call, but also any previous
# sections captured for this node, including e.g. setup or reruns.
# Therefore we create a new sections key with only captures that are
# new for this phase for convenience to the templates.
nodeid = phase["report"].nodeid
nof_sections = nof_sections_per_node.get(nodeid, 0)
phase["sections"] = phase["report"].sections[nof_sections:]
nof_sections_per_node[nodeid] = nof_sections + len(phase["sections"])

# Get test status (e.g. passed, failed, error, skipped et.c.) for
# this report. These will be empty strings except for the phase which
# determines the status for the whole test.
category, letter, word = config.hook.pytest_report_teststatus(
report=phase["report"], config=config
)
Expand All @@ -69,6 +92,7 @@ def pytest_reporter_context(context, config):
"word": word,
"style": style,
}
# Set whole test status if this phase determined the outcome
if letter or word:
test["status"] = phase["status"]

Expand All @@ -92,43 +116,63 @@ class ReportGenerator:
def __init__(self, config):
self.config = config
self.context = config.template_context
self._items = {}
self._active_tests = {}
self._loaders = []
self._active_item = None
self._active_test = None
self._log_handler = LogHandler()
self._reports = set()

def _get_testrun(self, nodeid):
testrun = self._active_tests.get(nodeid)
if testrun is None:
testrun = {
"item": self._items.get(nodeid),
"phases": [],
}
self._active_tests[nodeid] = testrun
return testrun

def pytest_sessionstart(self, session):
self.context["session"] = session
self.context["started"] = time.time()
logging.getLogger().addHandler(self._log_handler)

def pytest_runtest_protocol(self, item):
self._active_item = item

def pytest_runtest_logstart(self):
self._active_test = {
"item": self._active_item,
"phases": [],
"status": {
"category": "unknown",
"letter": "?",
"word": "UNKNOWN",
"style": {},
},
}
def pytest_report_collectionfinish(self, config, items):
self._items = {item.nodeid: item for item in items}
self.context["items"] = self._items

def pytest_runtest_logfinish(self):
self.context["tests"].append(self._active_test)
def pytest_runtest_logstart(self, nodeid):
testrun = self._get_testrun(nodeid)
testrun["started"] = time.time()

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(self, item, call):
phase = {"call": call}
testrun = self._get_testrun(item.nodeid)
phase = {}
phase["call"] = call
outcome = yield
report = outcome.get_result()
# rerunfailures doesn't always call pytest_runtest_logreport so we collect
# the report here as well just to be sure
phase["report"] = outcome.get_result()
testrun["phases"].append(phase)

def pytest_runtest_logreport(self, report):
testrun = self._get_testrun(report.nodeid)
# Check if there already is an existing phase from makereport
for phase in testrun["phases"]:
if phase["report"].when == report.when:
break
else:
phase = {}
testrun["phases"].append(phase)
phase["report"] = report
phase["log_records"] = self._log_handler.pop_records()
self._active_test["phases"].append(phase)

def pytest_runtest_logfinish(self, nodeid):
testrun = self._get_testrun(nodeid)
testrun["ended"] = time.time()
self.context["tests"].append(testrun)
del self._active_tests[nodeid]

def pytest_warning_captured(self, warning_message):
self.context["warnings"].append(warning_message)
Expand Down

0 comments on commit 282208d

Please sign in to comment.