From 388ecab0d3b9483232b1b04434956b3ccae65d47 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Fri, 6 Jan 2023 14:22:08 -0500 Subject: [PATCH] Fix @frozen exceptions to allow __traceback__ to be set. E.g. contextlib.contextmanager does so whenever an exception is raised in its body, and does so even on CPython, so merging the two code paths now seems reasonable. --- changelog.d/1081.change.md | 1 + src/attr/_make.py | 35 +++++++++++++---------------------- tests/test_next_gen.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 changelog.d/1081.change.md 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.