Skip to content

Commit

Permalink
Implement first class exception support
Browse files Browse the repository at this point in the history
Fixes #368
  • Loading branch information
hynek committed Feb 25, 2019
1 parent a35d8fb commit 078fcaf
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 11 deletions.
3 changes: 3 additions & 0 deletions changelog.d/500.change.rst
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/attr/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def attrs(
auto_attribs: bool = ...,
kw_only: bool = ...,
cache_hash: bool = ...,
auto_exc: bool = ...,
) -> _C: ...
@overload
def attrs(
Expand All @@ -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
Expand Down Expand Up @@ -212,6 +214,7 @@ def make_class(
auto_attribs: bool = ...,
kw_only: bool = ...,
cache_hash: bool = ...,
auto_exc: bool = ...,
) -> type: ...

# _funcs --
Expand Down
71 changes: 63 additions & 8 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ class _ClassBuilder(object):
"_has_post_init",
"_delete_attribs",
"_base_attr_map",
"_is_exc",
)

def __init__(
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -688,6 +698,7 @@ def add_init(self):
self._slots,
self._cache_hash,
self._base_attr_map,
self._is_exc,
)
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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*
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -1250,16 +1288,18 @@ def _make_init(attrs, post_init, frozen, slots, cache_hash, base_attr_map):
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,
Expand All @@ -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__


Expand All @@ -1287,6 +1328,7 @@ def _add_init(cls, frozen):
_is_slot_cls(cls),
cache_hash=False,
base_attr_map={},
is_exc=False,
)
return cls

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions tests/test_dark_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 7 additions & 2 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -1500,6 +1504,7 @@ class C(object):
frozen=False,
weakref_slot=True,
auto_attribs=False,
is_exc=False,
kw_only=False,
cache_hash=False,
)
Expand Down
14 changes: 14 additions & 0 deletions tests/typing_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 078fcaf

Please sign in to comment.