diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18f244790..5152fcd7c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: types: [] - repo: https://gitlab.com/pycqa/flake8 - rev: '3.7.3' + rev: 3.7.5 hooks: - id: flake8 language_version: python3.7 diff --git a/changelog.d/500.change.rst b/changelog.d/500.change.rst new file mode 100644 index 000000000..c1a640233 --- /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 3cd01a169..98ea84590 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -18,7 +18,7 @@ What follows is the API explanation, if you'd like a more hands-on introduction, 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 @@ Core >>> 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 ea788ea3c..fcb93b18e 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -167,6 +167,7 @@ def attrs( auto_attribs: bool = ..., kw_only: bool = ..., cache_hash: bool = ..., + auto_exc: bool = ..., ) -> _C: ... @overload def attrs( @@ -184,6 +185,7 @@ def attrs( 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 @@ def make_class( 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 843c76bd7..138d96990 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -453,6 +453,7 @@ class _ClassBuilder(object): "_has_post_init", "_delete_attribs", "_base_attr_map", + "_is_exc", ) def __init__( @@ -465,6 +466,7 @@ def __init__( 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 @@ def __init__( 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 @@ -660,8 +663,15 @@ def add_str(self): "__str__ can only be generated if a __repr__ exists." ) - def __str__(self): - return self.__repr__() + if self._is_exc: + + def __str__(self): + return BaseException.__str__(self) + + else: + + def __str__(self): + return self.__repr__() self._cls_dict["__str__"] = self._add_method_dunders(__str__) return self @@ -688,6 +698,7 @@ def add_init(self): self._slots, self._cache_hash, self._base_attr_map, + self._is_exc, ) ) @@ -738,6 +749,7 @@ def attrs( auto_attribs=False, kw_only=False, cache_hash=False, + auto_exc=False, ): r""" A class decorator that adds `dunder @@ -847,6 +859,21 @@ def attrs( 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 and a ``__str__`` method is added that + returns a str of the ``args`` tuple (this differs from passing + ``str=True`` which returns the same string as ``repr()``). .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* @@ -866,12 +893,16 @@ def attrs( 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 +912,14 @@ def wrap(cls): auto_attribs, kw_only, cache_hash, + is_exc, ) if repr is True: builder.add_repr(repr_ns) - if str is True: + if str is True or is_exc: 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 +934,11 @@ def wrap(cls): " 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 +1277,9 @@ def _add_repr(cls, ns=None, attrs=None): 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 +1288,18 @@ def _make_init(attrs, post_init, frozen, slots, cache_hash, base_attr_map): unique_filename = "".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 +1313,7 @@ def _make_init(attrs, post_init, frozen, slots, cache_hash, base_attr_map): __init__ = locs["__init__"] __init__.__annotations__ = annotations + return __init__ @@ -1287,6 +1328,7 @@ def _add_init(cls, frozen): _is_slot_cls(cls), cache_hash=False, base_attr_map={}, + is_exc=False, ) return cls @@ -1376,7 +1418,7 @@ def _is_slot_attr(a_name, base_attr_map): 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 +1667,19 @@ def fmt_setter_with_converter(attr_name, value_var): init_hash_cache = "self.%s = %s" lines.append(init_hash_cache % (_hash_cache_field, "None")) + # On Python 2, it's necessary to set self.args for exceptions. We do it on + # *all* versions to keep around defaults. + if is_exc: + vals = "".join(("(self.", ", self.".join(a.name for a in attrs), ")")) + + if frozen: + if slots: + lines.append("_setattr('args', %s)" % (vals,)) + else: + lines.append("object.__setattr__(self, 'args', %s)" % (vals,)) + else: + lines.append("self.args = " + 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 e40fe93d4..713249a70 100644 --- a/tests/test_dark_magic.py +++ b/tests/test_dark_magic.py @@ -508,3 +508,51 @@ class C(object): 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, 42, 'foo')" == str(e) + # N.B. the default value 42 is preserved by setting args ourselves. + assert (1, 42, "foo") == e.args + + with pytest.raises(TypeError): + hash(e) diff --git a/tests/test_make.py b/tests/test_make.py index d02c12420..8e358b996 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -1425,7 +1425,9 @@ def test_repr(self): 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 @@ def test_returns_self(self): 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 @@ class C(object): 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 527216f32..b68ce6e9e 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -80,6 +80,20 @@ class HH(DD, EE): 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.