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:
@@ -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),
|
||||||
|
Reference in New Issue
Block a user