Repository: python-attrs/attrs

Problem statement:
Frozen Exceptions cannot be thrown from Exception Handler in PyPy 
Consider the following code, which generates some random exception, which is then rethrown as a custom attrs exception class (in this case suppressing the context using [PEP 409](https://www.python.org/dev/peps/pep-0409/)'s `from None`):
```python
import attr

@attr.s(frozen=True, slots=True, auto_exc=True)
class MyException(BaseException):
    msg:str = attr.ib()

try:
    next(iter([]))
except StopIteration:
    raise MyException("test") from None
```
In CPython, this works as expected:
```
Traceback (most recent call last):
  File "scratch.py", line 10, in <module>
    raise MyException("test") from None
__main__.MyException: test
```
On PyPy, this yields a very weird, different exception:
```
Traceback (most recent call last):
  File "scratch.py", line 8, in <module>
    next(iter([]))
StopIteration

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "scratch.py", line 10, in <module>
    raise MyException("test") from None
  File ".venv-pypy3/site-packages/attr/_make.py", line 528, in _frozen_setattrs
    raise FrozenInstanceError()
attr.exceptions.FrozenInstanceError
```
I assume this is related to how `__context__` and `__cause__` are set when (re-)raising exceptions (see also the linked PEP for some info on that). The PEP says "To support raise Exception from None, `__context__` will stay as it is, but `__cause__` will start out as Ellipsis and will change to None when the raise Exception from None method is used.", so it's weird that only PyPy seems to be wrong here (which is also the reason I'm reporting this against attrs and not PyPy). Maybe CPython doesn't completely adhere to that and doesn't change the value in this case - or changes it bypassing the high-level `__setattr__` API. Note that leaving the `from None` out doesn't change the weird behaviour. Maybe the `__setattr__` frozen check should be skipped for `__context__` and `__cause__` of exceptions?

The versions of the interpreters I'm using are:
```
$ pypy3 -V
Python 3.6.9 (78d4c48fa091, Apr 30 2020, 07:55:31)
[PyPy 7.3.1 with GCC 10.0.1 20200328 (Red Hat 10.0.1-0.11)]
$ python3.9 -VV
Python 3.9.0 (default, Oct  6 2020, 00:00:00) 
[GCC 10.2.1 20200723 (Red Hat 10.2.1-1)]
```


Hints:

Patch:
diff --git a/src/attr/_compat.py b/src/attr/_compat.py
--- a/src/attr/_compat.py
+++ b/src/attr/_compat.py
@@ -91,7 +91,7 @@ def metadata_proxy(d):
         res.data.update(d)  # We blocked update, so we have to do it like this.
         return res
 
-    def just_warn(*args, **kw):  # pragma: nocover
+    def just_warn(*args, **kw):  # pragma: no cover
         """
         We only warn on Python 3 because we are not aware of any concrete
         consequences of not setting the cell on Python 2.
@@ -132,7 +132,7 @@ def make_set_closure_cell():
     """
     # pypy makes this easy. (It also supports the logic below, but
     # why not do the easy/fast thing?)
-    if PYPY:  # pragma: no cover
+    if PYPY:
 
         def set_closure_cell(cell, value):
             cell.__setstate__((value,))
diff --git a/src/attr/_make.py b/src/attr/_make.py
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -12,6 +12,7 @@
 from . import _config, setters
 from ._compat import (
     PY2,
+    PYPY,
     isclass,
     iteritems,
     metadata_proxy,
@@ -527,11 +528,29 @@ def _transform_attrs(
     return _Attributes((attrs, base_attrs, base_attr_map))
 
 
-def _frozen_setattrs(self, name, value):
-    """
-    Attached to frozen classes as __setattr__.
-    """
-    raise FrozenInstanceError()
+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__.
+        """
+        raise FrozenInstanceError()
 
 
 def _frozen_delattrs(self, name):


Test patch:
diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py
--- a/tests/test_next_gen.py
+++ b/tests/test_next_gen.py
@@ -4,6 +4,8 @@
 
 import re
 
+from functools import partial
+
 import pytest
 
 import attr
@@ -238,3 +240,32 @@ class B:
             @attr.define(on_setattr=attr.setters.validate)
             class C(A):
                 pass
+
+    @pytest.mark.parametrize(
+        "decorator",
+        [
+            partial(attr.s, frozen=True, slots=True, auto_exc=True),
+            attr.frozen,
+            attr.define,
+            attr.mutable,
+        ],
+    )
+    def test_discard_context(self, decorator):
+        """
+        raise from None works.
+
+        Regression test for #703.
+        """
+
+        @decorator
+        class MyException(Exception):
+            x: str = attr.ib()
+
+        with pytest.raises(MyException) as ei:
+            try:
+                raise ValueError()
+            except ValueError:
+                raise MyException("foo") from None
+
+        assert "foo" == ei.value.x
+        assert ei.value.__cause__ is None