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 10, 2019
1 parent 7b37354 commit a9e552f
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
72 changes: 63 additions & 9 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 @@ -846,7 +858,21 @@ def attrs(
class. If the hash code is cached, then no attributes of this
class which participate in hash code computation may be mutated
after object creation.
: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 +892,16 @@ class which participate in hash code computation may be mutated
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 +911,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 +933,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 +1276,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 +1287,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 +1312,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 +1327,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 +1417,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 +1666,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 a9e552f

Please sign in to comment.