Skip to content
30 changes: 27 additions & 3 deletions slash/core/error.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sys
import traceback
from collections.abc import Iterator
from itertools import pairwise

import arrow
import logbook
Expand All @@ -17,17 +19,39 @@
_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:
Comment thread
ps-groscoe marked this conversation as resolved.
seen.add(id(exc_value))
yield exc_value
exc_value = exc_value.__cause__ if exc_value.__suppress_context__ else exc_value.__context__
Comment thread
ps-groscoe marked this conversation as resolved.


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__()
self.time = arrow.utcnow()
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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion slash/core/exclusions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion slash/core/fixtures/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion slash/core/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion slash/core/variation_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions tests/test_error_object.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# pylint: disable=redefined-outer-name,unused-variable
import dataclasses
import json
import os
import types
Expand Down Expand Up @@ -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)) == "<Error: RuntimeError: outer>"


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)) == "<Error: RuntimeError: outer\n\tstatus = UNIMPLEMENTED>"


####


Expand Down
Loading