Skip to content

Commit

Permalink
Improve the error message informing the value that it got it (#536)
Browse files Browse the repository at this point in the history
* Improve error message of iscallable validator

* Add stubs for NotCallableError

* Add changelog for iscallable validator
  • Loading branch information
williamjamir authored and hynek committed Jul 20, 2019
1 parent 98185bf commit 68af33e
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 9 deletions.
2 changes: 2 additions & 0 deletions changelog.d/536.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
``attr.validators.is_callable()`` validator now raises an exception ``attr.exceptions.NotCallableError``, a subclass of ``TypeError``, informing the received value.
`#536 <https://github.com/python-attrs/attrs/pull/536>`_.
2 changes: 1 addition & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ Validators
>>> C("not a callable")
Traceback (most recent call last):
...
TypeError: 'x' must be callable
attr.exceptions.NotCallableError: 'x' must be callable (got 'not a callable' that is a <class 'str'>).


.. autofunction:: attr.validators.deep_iterable
Expand Down
17 changes: 17 additions & 0 deletions src/attr/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,20 @@ class PythonTooOldError(RuntimeError):
.. versionadded:: 18.2.0
"""


class NotCallableError(TypeError):
"""
A ``attr.ib()`` requiring a callable has been set with a value
that is not callable.
.. versionadded:: 19.2.0
"""

def __init__(self, msg, value):
super(TypeError, self).__init__(msg, value)
self.msg = msg
self.value = value

def __str__(self):
return str(self.msg)
7 changes: 7 additions & 0 deletions src/attr/exceptions.pyi
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from typing import Any

class FrozenInstanceError(AttributeError):
msg: str = ...

class AttrsAttributeNotFoundError(ValueError): ...
class NotAnAttrsClassError(ValueError): ...
class DefaultAlreadySetError(RuntimeError): ...
class UnannotatedAttributeError(RuntimeError): ...

class NotCallableError(TypeError):
msg: str = ...
value: Any = ...
def __init__(self, msg: str, value: Any) -> None: ...
22 changes: 17 additions & 5 deletions src/attr/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import absolute_import, division, print_function

from ._make import _AndValidator, and_, attrib, attrs
from .exceptions import NotCallableError


__all__ = [
Expand Down Expand Up @@ -186,21 +187,32 @@ def __call__(self, inst, attr, value):
We use a callable class to be able to change the ``__repr__``.
"""
if not callable(value):
raise TypeError("'{name}' must be callable".format(name=attr.name))
message = (
"'{name}' must be callable "
"(got {value!r} that is a {actual!r})."
)
raise NotCallableError(
msg=message.format(
name=attr.name, value=value, actual=value.__class__
),
value=value,
)

def __repr__(self):
return "<is_callable validator>"


def is_callable():
"""
A validator that raises a :class:`TypeError` if the initializer is called
with a value for this particular attribute that is not callable.
A validator that raises a :class:`attr.exceptions.NotCallableError`
if the initializer is called with a value for this particular attribute
that is not callable.
.. versionadded:: 19.1.0
:raises TypeError: With a human readable error message containing the
attribute (of type :class:`attr.Attribute`) name.
:raises `attr.exceptions.NotCallableError`: With a human readable error
message containing the attribute (:class:`attr.Attribute`) name,
and the value it got.
"""
return _IsCallableValidator()

Expand Down
37 changes: 34 additions & 3 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,15 @@ def test_noncallable_validators(
"""
with pytest.raises(TypeError) as e:
deep_iterable(member_validator, iterable_validator)
value = 42
message = "must be callable (got {value} that is a {type_}).".format(
value=value, type_=value.__class__
)

e.match(r"\w* must be callable")
assert message in e.value.args[0]
assert value == e.value.args[1]
assert message in e.value.msg
assert value == e.value.value

def test_fail_invalid_member(self):
"""
Expand Down Expand Up @@ -466,7 +473,15 @@ def test_noncallable_validators(
with pytest.raises(TypeError) as e:
deep_mapping(key_validator, value_validator, mapping_validator)

e.match(r"\w* must be callable")
value = 42
message = "must be callable (got {value} that is a {type_}).".format(
value=value, type_=value.__class__
)

assert message in e.value.args[0]
assert value == e.value.args[1]
assert message in e.value.msg
assert value == e.value.value

def test_fail_invalid_mapping(self):
"""
Expand Down Expand Up @@ -550,7 +565,14 @@ def test_fail(self):
with pytest.raises(TypeError) as e:
v(None, a, None)

e.match("'test' must be callable")
value = None
message = "'test' must be callable (got {value} that is a {type_})."
expected_message = message.format(value=value, type_=value.__class__)

assert expected_message == e.value.args[0]
assert value == e.value.args[1]
assert expected_message == e.value.msg
assert value == e.value.value

def test_repr(self):
"""
Expand All @@ -559,6 +581,15 @@ def test_repr(self):
v = is_callable()
assert "<is_callable validator>" == repr(v)

def test_exception_repr(self):
"""
Verify that NotCallableError exception has a useful `__str__`.
"""
from attr.exceptions import NotCallableError

instance = NotCallableError(msg="Some Message", value=42)
assert "Some Message" == str(instance)


def test_hashability():
"""
Expand Down

0 comments on commit 68af33e

Please sign in to comment.