diff --git a/changelog.d/1081.change.md b/changelog.d/1081.change.md new file mode 100644 index 000000000..c271d6520 --- /dev/null +++ b/changelog.d/1081.change.md @@ -0,0 +1 @@ +Fix frozen exception classes when raised within e.g. `contextlib.contextmanager`, which mutates their `__traceback__` attributes. diff --git a/src/attr/_make.py b/src/attr/_make.py index 1a06504e2..48125a9db 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -12,7 +12,7 @@ # We need to import _compat itself in addition to the _compat members to avoid # having the thread-local in the globals here. from . import _compat, _config, setters -from ._compat import PY310, PYPY, _AnnotationExtractor, set_closure_cell +from ._compat import PY310, _AnnotationExtractor, set_closure_cell from .exceptions import ( DefaultAlreadySetError, FrozenInstanceError, @@ -582,28 +582,19 @@ def _transform_attrs( return _Attributes((AttrsClass(attrs), base_attrs, base_attr_map)) -if PYPY: - - def _frozen_setattrs(self, name, value): - """ - Attached to frozen classes as __setattr__. - """ - if isinstance(self, BaseException) and name in ( - "__cause__", - "__context__", - ): - BaseException.__setattr__(self, name, value) - return - - raise FrozenInstanceError() - -else: +def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + if isinstance(self, BaseException) and name in ( + "__cause__", + "__context__", + "__traceback__", + ): + BaseException.__setattr__(self, name, value) + return - def _frozen_setattrs(self, name, value): - """ - Attached to frozen classes as __setattr__. - """ - raise FrozenInstanceError() + raise FrozenInstanceError() def _frozen_delattrs(self, name): diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py index 78fd0e52d..546dc4d06 100644 --- a/tests/test_next_gen.py +++ b/tests/test_next_gen.py @@ -6,6 +6,7 @@ import re +from contextlib import contextmanager from functools import partial import pytest @@ -312,6 +313,38 @@ class MyException(Exception): assert "foo" == ei.value.x assert ei.value.__cause__ is None + @pytest.mark.parametrize( + "decorator", + [ + partial(_attr.s, frozen=True, slots=True, auto_exc=True), + attrs.frozen, + attrs.define, + attrs.mutable, + ], + ) + def test_setting_traceback_on_exception(self, decorator): + """ + contextlib.contextlib (re-)sets __traceback__ on raised exceptions. + + Ensure that works, as well as if done explicitly + """ + + @decorator + class MyException(Exception): + pass + + @contextmanager + def do_nothing(): + yield + + with do_nothing(), pytest.raises(MyException) as ei: + raise MyException() + + assert isinstance(ei.value, MyException) + + # this should not raise an exception either + ei.value.__traceback__ = ei.value.__traceback__ + def test_converts_and_validates_by_default(self): """ If no on_setattr is set, assume setters.convert, setters.validate.