feat(pytest): use pytest_runtest_makereport for consistent exception handling (#29)

* include type of unhandled pytest exc_repr in error message

This makes triage of such issues a little simpler.

* pytest: expect the more general ExceptionRepr class

This is the (abstract) supertype of the currently-used
ExceptionChainRepr, and defines all of the attributes currently used by
the code.

(This changes results in sensible output for `ReprExceptionInfo`
instances, which is what my pytest invocations were generating.)

* pytest: use pytest_runtest_makereport for consistent exception handling

The `report` passed to `pytest_runtest_logreport` has a different
internal exception representation depending on the `--tb` option with
which `pytest` is configured: some of these representations do not
include the traceback frames to allow us to calculate line numbers.

`pytest_runtest_makereport`, however, has access to the original
`ExceptionInfo` object when an exception is raised: this commit switches
to using a `pytest_runtest_makereport` hookwrapper, so we can access the
pytest-generated report as before, but get exception handling
independent of `--tb` setting.

Fixes: #28
This commit is contained in:
Daniel Watkins
2022-11-08 04:23:58 -05:00
committed by GitHub
parent 276881193d
commit c85a02089d

View File

@@ -4,7 +4,10 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union
from .base import NeotestAdapter, NeotestError, NeotestResult, NeotestResultStatus from .base import NeotestAdapter, NeotestError, NeotestResult, NeotestResultStatus
import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
from pytest import CallInfo, Item
from _pytest.config import Config from _pytest.config import Config
from _pytest.reports import TestReport from _pytest.reports import TestReport
@@ -83,13 +86,21 @@ class NeotestResultCollector:
def pytest_cmdline_main(self, config: "Config"): def pytest_cmdline_main(self, config: "Config"):
self.pytest_config = config self.pytest_config = config
def pytest_runtest_logreport(self, report: "TestReport"): @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(self, item: "Item", call: "CallInfo") -> None:
# pytest generates the report.outcome field in its internal
# pytest_runtest_makereport implementation, so call it first. (We don't
# implement pytest_runtest_logreport because it doesn't have access to
# call.excinfo.)
outcome = yield
report = outcome.get_result()
if report.when != "call" and not ( if report.when != "call" and not (
report.outcome == "skipped" and report.when == "setup" report.outcome == "skipped" and report.when == "setup"
): ):
return return
file_path, *name_path = report.nodeid.split("::") file_path, *name_path = item.nodeid.split("::")
abs_path = str(Path(self.pytest_config.rootdir, file_path)) abs_path = str(Path(self.pytest_config.rootdir, file_path))
*namespaces, test_name = name_path *namespaces, test_name = name_path
valid_test_name, *params = test_name.split("[") # ] valid_test_name, *params = test_name.split("[") # ]
@@ -99,28 +110,25 @@ class NeotestResultCollector:
short = self._get_short_output(self.pytest_config, report) short = self._get_short_output(self.pytest_config, report)
if report.outcome == "failed": if report.outcome == "failed":
from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr
exc_repr = report.longrepr exc_repr = report.longrepr
# Test fails due to condition outside of test e.g. xfail # Test fails due to condition outside of test e.g. xfail
if isinstance(exc_repr, str): if isinstance(exc_repr, str):
errors.append({"message": exc_repr, "line": None}) errors.append({"message": exc_repr, "line": None})
# Test failed internally # Test failed internally
elif isinstance(exc_repr, ExceptionChainRepr): elif isinstance(exc_repr, ExceptionRepr):
reprtraceback = exc_repr.reprtraceback
error_message = exc_repr.reprcrash.message # type: ignore error_message = exc_repr.reprcrash.message # type: ignore
error_line = None error_line = None
for repr in reversed(reprtraceback.reprentries): for traceback_entry in reversed(call.excinfo.traceback):
if ( if str(traceback_entry.path) == abs_path:
hasattr(repr, "reprfileloc") error_line = traceback_entry.lineno
and repr.reprfileloc.path == file_path
):
error_line = repr.reprfileloc.lineno - 1
errors.append({"message": error_message, "line": error_line}) errors.append({"message": error_message, "line": error_line})
else: else:
# TODO: Figure out how these are returned and how to represent # TODO: Figure out how these are returned and how to represent
raise Exception( raise Exception(
"Unhandled error type, please report to neotest-python repo" f"Unhandled error type ({type(exc_repr)}), please report to"
" neotest-python repo"
) )
result: NeotestResult = self.adapter.update_result( result: NeotestResult = self.adapter.update_result(
self.results.get(pos_id), self.results.get(pos_id),