Repository: python-attrs/attrs
Pull request #1081 (2023-01-06 19:31:54)

Fix @frozen exceptions to allow __traceback__ to be set.
# Summary

This code previously raised an `attrs.FrozenInstanceError`:

```python
from contextlib import contextmanager
from attrs import frozen

@frozen
class Boom(Exception):
    pass


@contextmanager
def do_nothing():
    yield


with do_nothing() as _:
    raise Boom()
```

on CPython (3.11 specifically, but probably other versions). On PyPy, it works, at least on 3.9 here -- which means I think it seems reasonable to merge the two codepaths here as I've done rather than whitelisting different attributes on different interpreters, but lemme know if you disagree.

(In real life a likely way you're going to encounter this, just to help anyone searching, is that `click` will wrap all exceptions during execution of a program, and does so via a `contextlib.contextmanager`)

Refs #712


# Pull Request Check List

<!--
This is just a friendly reminder about the most common mistakes.
Please make sure that you tick all boxes.
But please read our [contribution guide](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.
If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.
-->

- [x] Added **tests** for changed code.
  Our CI fails if coverage is not 100%.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [ ] ...and used in the stub test file `tests/typing_example.py`.
    - [ ] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`.
- [ ] Updated **documentation** for changed code.
    - [ ] New functions/classes have to be added to `docs/api.rst` by hand.
    - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
          Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file.
- [ ] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).
- [ ] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you.

<!--
If you have *any* questions to *any* of the points above, just **submit and ask**!
This checklist is here to *help* you, not to deter you from contributing!
-->


hynek: Ah nice I'm pretty sure I ran junk this myself when switching to 3.11 but decided to just thaw the class and get on with my life. 

Can you finish it up with a news fragment pls?
Julian: Fixed!

Patch:
diff --git a/changelog.d/1081.change.md b/changelog.d/1081.change.md
new file mode 100644
index 0000000..c271d65
--- /dev/null
+++ b/changelog.d/1081.change.md
@@ -0,0 +1,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 1a06504..48125a9 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -12,7 +12,7 @@ from operator import itemgetter
 # 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):


Test patch:
diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py
index 78fd0e5..546dc4d 100644
--- a/tests/test_next_gen.py
+++ b/tests/test_next_gen.py
@@ -6,6 +6,7 @@ Integration tests for next-generation APIs.
 
 import re
 
+from contextlib import contextmanager
 from functools import partial
 
 import pytest
@@ -312,6 +313,38 @@ class TestNextGen:
         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.