diff --git a/slash/core/error.py b/slash/core/error.py index 1b3f0204..de831f57 100644 --- a/slash/core/error.py +++ b/slash/core/error.py @@ -1,5 +1,7 @@ import sys import traceback +from collections.abc import Iterator +from itertools import pairwise import arrow import logbook @@ -17,9 +19,29 @@ _logger = logbook.Logger(__name__) _CAPTURED_ERROR_MARKER = "__slash_captured_error__" + +def _iter_exception_chain(exc_value: BaseException | None) -> Iterator[BaseException]: + seen: set[int] = set() + while exc_value is not None and id(exc_value) not in seen: + seen.add(id(exc_value)) + yield exc_value + exc_value = exc_value.__cause__ if exc_value.__suppress_context__ else exc_value.__context__ + + +def _format_exception_chain(exc_type: type[BaseException] | None, exc_value: BaseException | None) -> str: + lines = [_format_exception(exc_type, exc_value)] + for outer, inner in pairwise(_iter_exception_chain(exc_value)): + label = "Caused by" if inner is outer.__cause__ else "While handling" + lines.append(f"{label}: {_format_exception(type(inner), inner)}") + return "\n".join(lines) + +def _format_exception(exc_type: type[BaseException] | None, exc_value: BaseException | None) -> str: + return traceback.format_exception_only(exc_type, exc_value)[0].strip() + + class Error(object): - traceback = exception_type = arg = _cached_detailed_traceback_str = None + traceback = exception_type = arg = _cached_detailed_traceback_str = _summary = None def __init__(self, msg=None, exc_info=None, frame_correction=0): super(Error, self).__init__() @@ -27,7 +49,9 @@ def __init__(self, msg=None, exc_info=None, frame_correction=0): self._fatal = False self._has_custom_message = (msg is not None) if msg is None and exc_info is not None: - msg = traceback.format_exception_only(exc_info[0], exc_info[1])[0].strip() + exc_type, exc_value, _ = exc_info + self._summary = _format_exception(exc_type, exc_value) + msg = _format_exception_chain(exc_type, exc_value) if not isinstance(msg, str): self.arg = msg msg = repr(msg) @@ -126,7 +150,7 @@ def func_name(self): return self.traceback.cause.func_name def __repr__(self): - return '<{0.__class__.__name__}: {0.message}>'.format(self) + return '<{}: {}>'.format(self.__class__.__name__, self._summary or self.message) def get_detailed_traceback_str(self): """Returns a formatted traceback string for the exception caught diff --git a/slash/core/exclusions.py b/slash/core/exclusions.py index d64db833..9e4f8b60 100644 --- a/slash/core/exclusions.py +++ b/slash/core/exclusions.py @@ -49,7 +49,7 @@ def is_excluded(test): try: param_index = test.__slash__.variation.param_value_indices[param.info.id] #pylint: disable=no-member except LookupError: - raise UnknownFixtures('{!r} cannot be excluded for {!r}'.format(parameter_name, test)) + raise UnknownFixtures('{!r} cannot be excluded for {!r}'.format(parameter_name, test)) from None value = param.get_value_by_index(param_index) values.append(value) diff --git a/slash/core/fixtures/fixture.py b/slash/core/fixtures/fixture.py index dc7d7413..9cac35ea 100644 --- a/slash/core/fixtures/fixture.py +++ b/slash/core/fixtures/fixture.py @@ -106,6 +106,6 @@ def _resolve(self, store): self.info.name)) keyword_arguments[param_name] = needed_fixture except LookupError: - raise UnknownFixtures(param_name) + raise UnknownFixtures(param_name) from None return keyword_arguments diff --git a/slash/core/result.py b/slash/core/result.py index 856631a4..0a8bf771 100644 --- a/slash/core/result.py +++ b/slash/core/result.py @@ -455,5 +455,5 @@ def __getitem__(self, test): try: return next(itertools.islice(self._results_dict.values(), test, test + 1)) except StopIteration: - raise IndexError() + raise IndexError() from None return self.get_result(test) diff --git a/slash/core/variation_factory.py b/slash/core/variation_factory.py index 918bd6da..c7fa351f 100644 --- a/slash/core/variation_factory.py +++ b/slash/core/variation_factory.py @@ -62,7 +62,7 @@ def _add_needed_fixtures_from_function(self, func): try: fixture = self._store.get_fixture_by_argument(argument) except FixtureException as e: - raise type(e)('Loading {0.__code__.co_filename}:{0.__name__}: {1}'.format(func, e)) + raise type(e)('Loading {0.__code__.co_filename}:{0.__name__}: {1}'.format(func, e)) from None self._needed_fixtures.append(fixture) diff --git a/tests/test_error_object.py b/tests/test_error_object.py index 93079644..233f729e 100644 --- a/tests/test_error_object.py +++ b/tests/test_error_object.py @@ -1,4 +1,5 @@ # pylint: disable=redefined-outer-name,unused-variable +import dataclasses import json import os import types @@ -156,6 +157,87 @@ def h(): assert err.traceback.frames[-1].func_name == 'f' # pylint: disable=used-before-assignment +def _error_from(excinfo: pytest.ExceptionInfo[BaseException]) -> Error: + return Error(exc_info=(excinfo.type, excinfo.value, excinfo.tb)) + + +def test_error_message_includes_explicit_cause(): + with pytest.raises(RuntimeError) as excinfo: + raise RuntimeError('outer') from KeyError('inner') + + err = _error_from(excinfo) + assert err.exception_type is RuntimeError + assert err.message == "RuntimeError: outer\nCaused by: KeyError: 'inner'" + + +def test_error_message_includes_implicit_context(): + with pytest.raises(RuntimeError) as excinfo: + try: + raise KeyError('inner') + except KeyError: + raise RuntimeError('outer') # pylint: disable=raise-missing-from + + assert _error_from(excinfo).message == "RuntimeError: outer\nWhile handling: KeyError: 'inner'" + + +def test_error_message_includes_whole_chain(): + middle = KeyError('middle') + middle.__cause__ = ValueError('root') + with pytest.raises(RuntimeError) as excinfo: + raise RuntimeError('outer') from middle + + assert _error_from(excinfo).message == ( + "RuntimeError: outer\nCaused by: KeyError: 'middle'\nCaused by: ValueError: root") + + +def test_error_message_omits_suppressed_context(): + with pytest.raises(RuntimeError) as excinfo: + try: + raise KeyError('inner') + except KeyError: + raise RuntimeError('outer') from None + + assert _error_from(excinfo).message == "RuntimeError: outer" + + +def test_error_message_stops_at_cause_cycle(): + outer = RuntimeError('outer') + inner = KeyError('inner') + inner.__cause__ = outer + with pytest.raises(RuntimeError) as excinfo: + raise outer from inner + + assert _error_from(excinfo).message == "RuntimeError: outer\nCaused by: KeyError: 'inner'" + + +def test_error_message_includes_unhashable_cause(): + + @dataclasses.dataclass + class UnhashableError(Exception): + code: int + + with pytest.raises(RuntimeError) as excinfo: + raise RuntimeError('outer') from UnhashableError(1) + + outer_line, cause_line = _error_from(excinfo).message.splitlines() + assert outer_line == "RuntimeError: outer" + assert cause_line.startswith("Caused by: ") and cause_line.endswith("UnhashableError: 1") + + +def test_error_repr_omits_chain(): + with pytest.raises(RuntimeError) as excinfo: + raise RuntimeError('outer') from KeyError('inner') + + assert repr(_error_from(excinfo)) == "" + + +def test_error_repr_keeps_multiline_exception_text(): + with pytest.raises(RuntimeError) as excinfo: + raise RuntimeError('outer\n\tstatus = UNIMPLEMENTED') from KeyError('inner') + + assert repr(_error_from(excinfo)) == "" + + ####