Implement first class exception support (#500)

* Implement first class exception support

Fixes #368

* Ensure single-attrib classes work too

cf https://github.com/python-attrs/attrs/pull/500#pullrequestreview-201913569

* Call into BaseException to initialiaze self.args

* Leave __str__ alone since we upcall

Based on Python pizza hallway feedback by @ambv.

* remove stray stage

* nope
diff --git a/changelog.d/500.change.rst b/changelog.d/500.change.rst
new file mode 100644
index 0000000..c1a6402
--- /dev/null
+++ b/changelog.d/500.change.rst
@@ -0,0 +1,3 @@
+``attrs`` now has first class support for defining exception classes.
+
+If you define a class using ``@attr.s(auto_exc=True)`` and subclass an exception, the class will behave like a well-behaved exception class including an appropriate ``__str__`` method, and all attributes additionally available in an ``args`` attribute.
diff --git a/docs/api.rst b/docs/api.rst
index 3cd01a1..98ea845 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -18,7 +18,7 @@
 Core
 ----
 
-.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=True, hash=None, init=True, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False)
+.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=True, hash=None, init=True, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False)
 
    .. note::
 
@@ -42,6 +42,20 @@
       >>> D = attr.s(these={"x": attr.ib()}, init=False)(D)
       >>> D(1)
       D(x=1)
+      >>> @attr.s(auto_exc=True)
+      ... class Error(Exception):
+      ...     x = attr.ib()
+      ...     y = attr.ib(default=42, init=False)
+      >>> Error("foo")
+      Error(x='foo', y=42)
+      >>> raise Error("foo")
+      Traceback (most recent call last):
+         ...
+      Error: ('foo', 42)
+      >>> raise ValueError("foo", 42)   # for comparison
+      Traceback (most recent call last):
+         ...
+      ValueError: ('foo', 42)
 
 
 .. autofunction:: attr.ib
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index ea788ea..fcb93b1 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -167,6 +167,7 @@
     auto_attribs: bool = ...,
     kw_only: bool = ...,
     cache_hash: bool = ...,
+    auto_exc: bool = ...,
 ) -> _C: ...
 @overload
 def attrs(
@@ -184,6 +185,7 @@
     auto_attribs: bool = ...,
     kw_only: bool = ...,
     cache_hash: bool = ...,
+    auto_exc: bool = ...,
 ) -> Callable[[_C], _C]: ...
 
 # TODO: add support for returning NamedTuple from the mypy plugin
@@ -212,6 +214,7 @@
     auto_attribs: bool = ...,
     kw_only: bool = ...,
     cache_hash: bool = ...,
+    auto_exc: bool = ...,
 ) -> type: ...
 
 # _funcs --
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 843c76b..043edef 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -453,6 +453,7 @@
         "_has_post_init",
         "_delete_attribs",
         "_base_attr_map",
+        "_is_exc",
     )
 
     def __init__(
@@ -465,6 +466,7 @@
         auto_attribs,
         kw_only,
         cache_hash,
+        is_exc,
     ):
         attrs, base_attrs, base_map = _transform_attrs(
             cls, these, auto_attribs, kw_only
@@ -482,6 +484,7 @@
         self._cache_hash = cache_hash
         self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False))
         self._delete_attribs = not bool(these)
+        self._is_exc = is_exc
 
         self._cls_dict["__attrs_attrs__"] = self._attrs
 
@@ -688,6 +691,7 @@
                 self._slots,
                 self._cache_hash,
                 self._base_attr_map,
+                self._is_exc,
             )
         )
 
@@ -738,6 +742,7 @@
     auto_attribs=False,
     kw_only=False,
     cache_hash=False,
+    auto_exc=False,
 ):
     r"""
     A class decorator that adds `dunder
@@ -847,6 +852,19 @@
         fields involved in hash code computation or mutations of the objects
         those fields point to after object creation.  If such changes occur,
         the behavior of the object's hash code is undefined.
+    :param bool auto_exc: If the class subclasses :class:`BaseException`
+        (which implicitly includes any subclass of any exception), the
+        following happens to behave like a well-behaved Python exceptions
+        class:
+
+        - the values for *cmp* and *hash* are ignored and the instances compare
+          and hash by the instance's ids (N.B. ``attrs`` will *not* remove
+          existing implementations of ``__hash__`` or the equality methods. It
+          just won't add own ones.),
+        - all attributes that are either passed into ``__init__`` or have a
+          default value are additionally available as a tuple in the ``args``
+          attribute,
+        - the value of *str* is ignored leaving ``__str__`` to base classes.
 
     .. versionadded:: 16.0.0 *slots*
     .. versionadded:: 16.1.0 *frozen*
@@ -866,12 +884,16 @@
        to each other.
     .. versionadded:: 18.2.0 *kw_only*
     .. versionadded:: 18.2.0 *cache_hash*
+    .. versionadded:: 19.1.0 *auto_exc*
     """
 
     def wrap(cls):
+
         if getattr(cls, "__class__", None) is None:
             raise TypeError("attrs only works with new-style classes.")
 
+        is_exc = auto_exc is True and issubclass(cls, BaseException)
+
         builder = _ClassBuilder(
             cls,
             these,
@@ -881,13 +903,14 @@
             auto_attribs,
             kw_only,
             cache_hash,
+            is_exc,
         )
 
         if repr is True:
             builder.add_repr(repr_ns)
         if str is True:
             builder.add_str()
-        if cmp is True:
+        if cmp is True and not is_exc:
             builder.add_cmp()
 
         if hash is not True and hash is not False and hash is not None:
@@ -902,7 +925,11 @@
                     " hashing must be either explicitly or implicitly "
                     "enabled."
                 )
-        elif hash is True or (hash is None and cmp is True and frozen is True):
+        elif (
+            hash is True
+            or (hash is None and cmp is True and frozen is True)
+            and is_exc is False
+        ):
             builder.add_hash()
         else:
             if cache_hash:
@@ -1241,7 +1268,9 @@
     return cls
 
 
-def _make_init(attrs, post_init, frozen, slots, cache_hash, base_attr_map):
+def _make_init(
+    attrs, post_init, frozen, slots, cache_hash, base_attr_map, is_exc
+):
     attrs = [a for a in attrs if a.init or a.default is not NOTHING]
 
     # We cache the generated init methods for the same kinds of attributes.
@@ -1250,16 +1279,18 @@
     unique_filename = "<attrs generated init {0}>".format(sha1.hexdigest())
 
     script, globs, annotations = _attrs_to_init_script(
-        attrs, frozen, slots, post_init, cache_hash, base_attr_map
+        attrs, frozen, slots, post_init, cache_hash, base_attr_map, is_exc
     )
     locs = {}
     bytecode = compile(script, unique_filename, "exec")
     attr_dict = dict((a.name, a) for a in attrs)
     globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict})
+
     if frozen is True:
         # Save the lookup overhead in __init__ if we need to circumvent
         # immutability.
         globs["_cached_setattr"] = _obj_setattr
+
     eval(bytecode, globs, locs)
 
     # In order of debuggers like PDB being able to step through the code,
@@ -1273,6 +1304,7 @@
 
     __init__ = locs["__init__"]
     __init__.__annotations__ = annotations
+
     return __init__
 
 
@@ -1287,6 +1319,7 @@
         _is_slot_cls(cls),
         cache_hash=False,
         base_attr_map={},
+        is_exc=False,
     )
     return cls
 
@@ -1376,7 +1409,7 @@
 
 
 def _attrs_to_init_script(
-    attrs, frozen, slots, post_init, cache_hash, base_attr_map
+    attrs, frozen, slots, post_init, cache_hash, base_attr_map, is_exc
 ):
     """
     Return a script of an initializer for *attrs* and a dict of globals.
@@ -1625,6 +1658,13 @@
             init_hash_cache = "self.%s = %s"
         lines.append(init_hash_cache % (_hash_cache_field, "None"))
 
+    # For exceptions we rely on BaseException.__init__ for proper
+    # initialization.
+    if is_exc:
+        vals = ",".join("self." + a.name for a in attrs if a.init)
+
+        lines.append("BaseException.__init__(self, %s)" % (vals,))
+
     args = ", ".join(args)
     if kw_only_args:
         if PY2:
diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py
index e40fe93..bac2a01 100644
--- a/tests/test_dark_magic.py
+++ b/tests/test_dark_magic.py
@@ -6,6 +6,8 @@
 
 import pickle
 
+from copy import deepcopy
+
 import pytest
 import six
 
@@ -508,3 +510,68 @@
         assert "property" == attr.fields(C).property.name
         assert "itemgetter" == attr.fields(C).itemgetter.name
         assert "x" == attr.fields(C).x.name
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_auto_exc(self, slots, frozen):
+        """
+        Classes with auto_exc=True have a Exception-style __str__, are neither
+        comparable nor hashable, and store the fields additionally in
+        self.args.
+        """
+
+        @attr.s(auto_exc=True, slots=slots, frozen=frozen)
+        class FooError(Exception):
+            x = attr.ib()
+            y = attr.ib(init=False, default=42)
+            z = attr.ib(init=False)
+            a = attr.ib()
+
+        FooErrorMade = attr.make_class(
+            "FooErrorMade",
+            bases=(Exception,),
+            attrs={
+                "x": attr.ib(),
+                "y": attr.ib(init=False, default=42),
+                "z": attr.ib(init=False),
+                "a": attr.ib(),
+            },
+            auto_exc=True,
+            slots=slots,
+            frozen=frozen,
+        )
+
+        assert FooError(1, "foo") != FooError(1, "foo")
+        assert FooErrorMade(1, "foo") != FooErrorMade(1, "foo")
+
+        for cls in (FooError, FooErrorMade):
+            with pytest.raises(cls) as ei:
+                raise cls(1, "foo")
+
+            e = ei.value
+
+            assert e is e
+            assert e == e
+            assert "(1, 'foo')" == str(e)
+            assert (1, "foo") == e.args
+
+            with pytest.raises(TypeError):
+                hash(e)
+
+            if not frozen:
+                deepcopy(e)
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_auto_exc_one_attrib(self, slots, frozen):
+        """
+        Having one attribute works with auto_exc=True.
+
+        Easy to get wrong with tuple literals.
+        """
+
+        @attr.s(auto_exc=True, slots=slots, frozen=frozen)
+        class FooError(Exception):
+            x = attr.ib()
+
+        FooError(1)
diff --git a/tests/test_make.py b/tests/test_make.py
index a0597f1..61b4f63 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -1425,7 +1425,9 @@
         class C(object):
             pass
 
-        b = _ClassBuilder(C, None, True, True, False, False, False, False)
+        b = _ClassBuilder(
+            C, None, True, True, False, False, False, False, False
+        )
 
         assert "<_ClassBuilder(cls=C)>" == repr(b)
 
@@ -1437,7 +1439,9 @@
         class C(object):
             x = attr.ib()
 
-        b = _ClassBuilder(C, None, True, True, False, False, False, False)
+        b = _ClassBuilder(
+            C, None, True, True, False, False, False, False, False
+        )
 
         cls = (
             b.add_cmp()
@@ -1500,6 +1504,7 @@
             frozen=False,
             weakref_slot=True,
             auto_attribs=False,
+            is_exc=False,
             kw_only=False,
             cache_hash=False,
         )
diff --git a/tests/typing_example.py b/tests/typing_example.py
index 527216f..b68ce6e 100644
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -80,6 +80,20 @@
 c == cc
 
 
+# Exceptions
+@attr.s(auto_exc=True)
+class Error(Exception):
+    x = attr.ib()
+
+
+try:
+    raise Error(1)
+except Error as e:
+    e.x
+    e.args
+    str(e)
+
+
 # Converters
 # XXX: Currently converters can only be functions so none of this works
 # although the stubs should be correct.