Repository: python-attrs/attrs
Pull request #425 (2018-08-16 07:00:04)

Some new validators
# Pull Request Check List

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](http://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles!

- [x] Added **tests** for changed code.
~~- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/strategies.py).~~ n/a
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
- [x] Updated **documentation** for changed code.
- [x] Documentation in `.rst` files is written using [semantic newlines](http://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/master/changelog.d).

If you have *any* questions to *any* of the points above, just **submit and ask**!  This checklist is here to *help* you, not to deter you from contributing!


# Description

These are a few validators that I have ended up needing multiple times in my projects and I figured they would be generally useful. If you would prefer I split them up into three PRs, let me know. They're completely independent of each other.

* **Callable Validator** : validates that a value is callable
* **Deep Iterable Validator** : Allows recursion down into an iterable, applying another validator to every member in the iterable as well as applying an optional validator to the iterable itself.
* **Deep Dictionary Validator** : Allows recursion down into the items in a dictionary, applying a key validator and a value validator to the key and value in every item. Also validates that the top level value is a dictionary using the existing `instance_of` validator.

## TBD

Something I couldn't quite decide on an approach for was how to present the lower-level validator exceptions to the user.

For example, if you define the following:
```python
@attr.s
class Example(object):
    special_iterable = attr.ib(validator=attr.validators.deep_iterable(attr.validators.instance_of(int)))

example = Example(["foo"])
```

How should the error message present the validation of `"foo"` in the error message?

I currently just have it allowing the lower level exceptions to bubble up directly. This is the simplest approach, but as implemented it makes the connection between the attribute and the value difficult to discern. This would be especially problematic if you had a more complex definition, such as:

```python
@attr.s
class Example(object):
    complex_attribute = attr.ib(
        validator=attr.validators.deep_dictionary(
            key_validator=attr.validators.instance_of(str),
            value_validator=attr.validators.deep_iterable(
                member_validator=attr.validators.in_([1,2,3]),
                iterable_validator=attr.validators.instance_of(list)
            )
        )
    )
```

Thoughts?

(note: because of the above TBD, there are some hanging unused exception instance variables that the `lint` testenv will complain about for now)

wsanchez: @mattsb42-aws: what do you think about instead of "Deep Dictionary Validator", you change it to "Deep Mapping Validator".  That would mean dropping the `instance_of` check, or adding a `mapping_validator` like you did in the iterable case?

I think that might be more generally useful, and makes the two more consistent.
mattsb42-aws: @wsanchez That sounds reasonable. I had been thinking in terms of builtin primitives, but you make a good point about generalized "things that implement `__getitem__()`".

To that end, I think I'll also change the iteration logic to remove the dependency on `items()` and instead use `__iter__()` and `__getitem__()`.
mattsb42-aws: Updates added to change to generalized `deep_mapping`.

Any thoughts on the error message handling?
hynek: Just a bunch of docs comments (I leave code to @wsanchez – can’t promise your PR will make it into the already very overdue 18.2 – sorry in advance):

Your news fragment renders really funky. I *think* it’s because of the indentation of the second line?

Also news fragment since I’m the only one who cares about this stuff ;):

- You have a conflict in it.
- We use [semantic newlines](http://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- No space before `:`.

And finally: the new APIs need to be added to https://github.com/python-attrs/attrs/blob/master/docs/api.rst – I should add this to the pull request template.
mattsb42-aws: > Your news fragment renders really funky. I think it’s because of the indentation of the second line?

Gah..downside of switching back and forth between Markdown and RST...I always forget that RST wants exactly two (not four) spaces indent to join a multiline list entry.

> You have a conflict in [news fragment file].

I assume that the expected filename is generally `<issue/PR number>.change.rst` but it looks like `425.change.rst` was already taken by the events of PR #426. To what number/name would you prefer I rename it (and will the mismatch affect towncrier at all?)

I'll fix the other issues. Related to fixing docs stuff, if there's no objection to adding it, something I've found very useful on other projects is to have a tox env for hosting the built docs (ex[1], shamelessly copied from flake8). It just makes previewing built docs much simpler: just throw up `tox -e docs,serve-docs` and re-run/refresh as you change files.

[1] https://github.com/aws/aws-encryption-sdk-python/blob/master/tox.ini#L255-L261
mattsb42-aws: Requested docs changes made. I also went ahead and included the `serve-docs` environment, but I can drop that if you like.

The examples in the docs kind of highlight the issue I've been having coming up with a good way of formatting the error messages to identify the attribute that is failing validation.

Maybe rather than passing the original attribute to the sub-validator we could pass a modified copy with a different name?

ex:
```python
class _DeepIterable(object):
    ...

    def __call__(self, inst, attr, value):
        """
        We use a callable class to be able to change the ``__repr__``.
        """
        if self.iterable_validator is not None:
            self.iterable_validator(inst, attr, value)

        member_position = 0
        for member in value:
            member_attr = copy(attr)
            member_attr.name = "{original}[{pos}]".format(original=attr.name, pos=member_position)
            self.member_validator(inst, member_attr, member)
            member_position += 1
```
wsanchez: CI failed for what looked like a transient error in the lint checks, so I restarted that and it failed with actual errors:

```
src/attr/validators.py:193:80: E501 line too long (83 > 79 characters)
src/attr/validators.py:222:80: E501 line too long (99 > 79 characters)
src/attr/validators.py:260:80: E501 line too long (90 > 79 characters)
src/attr/validators.py:271:80: E501 line too long (90 > 79 characters)
tests/test_validators.py:286:80: E501 line too long (96 > 79 characters)
tests/test_validators.py:310:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:321:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:326:80: E501 line too long (85 > 79 characters)
tests/test_validators.py:332:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:337:80: E501 line too long (85 > 79 characters)
tests/test_validators.py:344:80: E501 line too long (90 > 79 characters)
tests/test_validators.py:351:80: E501 line too long (100 > 79 characters)
tests/test_validators.py:358:80: E501 line too long (82 > 79 characters)
tests/test_validators.py:362:80: E501 line too long (106 > 79 characters)
tests/test_validators.py:399:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:411:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:422:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:433:42: F841 local variable 'e' is assigned to but never used
tests/test_validators.py:449:80: E501 line too long (105 > 79 characters)
```
mattsb42-aws: > Code looks good other than linter failures that need fixing.

Any thoughts on the error message contents discussed above?
mattsb42-aws: Linting cleanup and revert of the tox.ini change. I also rebased off the current master (no conflicts) because git was upset.
mattsb42-aws: I went ahead and just with the the error messages as-is because I couldn't figure out a good way to duplicate an attribute with a different name. This will make debugging an error message harder than I would like, but it's a starting point.
wsanchez: > I went ahead and just went with the the error messages as-is because I couldn't figure out a good way to duplicate an attribute with a different name. This will make debugging an error message harder than I would like, but it's a starting point.

Agreed.  This is a good start and we can fix that when we figure out a good way to do it.

Thanks for the contribution!

Patch:
diff --git a/changelog.d/425.change.rst b/changelog.d/425.change.rst
new file mode 100644
index 0000000..c577415
--- /dev/null
+++ b/changelog.d/425.change.rst
@@ -0,0 +1,9 @@
+Add ``is_callable``, ``deep_iterable``, and ``deep_mapping`` validators.
+
+* ``is_callable``: validates that a value is callable
+* ``deep_iterable``: Allows recursion down into an iterable,
+  applying another validator to every member in the iterable
+  as well as applying an optional validator to the iterable itself.
+* ``deep_mapping``: Allows recursion down into the items in a mapping object,
+  applying a key validator and a value validator to the key and value in every item.
+  Also applies an optional validator to the mapping object itself.
diff --git a/docs/api.rst b/docs/api.rst
index 33564b0..3cd01a1 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -369,6 +369,76 @@ Validators
       C(x=None)
 
 
+.. autofunction:: attr.validators.is_callable
+
+    For example:
+
+    .. doctest::
+
+        >>> @attr.s
+        ... class C(object):
+        ...     x = attr.ib(validator=attr.validators.is_callable())
+        >>> C(isinstance)
+        C(x=<built-in function isinstance>)
+        >>> C("not a callable")
+        Traceback (most recent call last):
+            ...
+        TypeError: 'x' must be callable
+
+
+.. autofunction:: attr.validators.deep_iterable
+
+    For example:
+
+    .. doctest::
+
+        >>> @attr.s
+        ... class C(object):
+        ...     x = attr.ib(validator=attr.validators.deep_iterable(
+        ...     member_validator=attr.validators.instance_of(int),
+        ...     iterable_validator=attr.validators.instance_of(list)
+        ...     ))
+        >>> C(x=[1, 2, 3])
+        C(x=[1, 2, 3])
+        >>> C(x=set([1, 2, 3]))
+        Traceback (most recent call last):
+            ...
+        TypeError: ("'x' must be <class 'list'> (got {1, 2, 3} that is a <class 'set'>).", Attribute(name='x', default=NOTHING, validator=<deep_iterable validator for <instance_of validator for type <class 'list'>> iterables of <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'list'>, {1, 2, 3})
+        >>> C(x=[1, 2, "3"])
+        Traceback (most recent call last):
+            ...
+        TypeError: ("'x' must be <class 'int'> (got '3' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=<deep_iterable validator for <instance_of validator for type <class 'list'>> iterables of <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'int'>, '3')
+
+
+.. autofunction:: attr.validators.deep_mapping
+
+    For example:
+
+    .. doctest::
+
+        >>> @attr.s
+        ... class C(object):
+        ...     x = attr.ib(validator=attr.validators.deep_mapping(
+        ...         key_validator=attr.validators.instance_of(str),
+        ...         value_validator=attr.validators.instance_of(int),
+        ...         mapping_validator=attr.validators.instance_of(dict)
+        ...     ))
+        >>> C(x={"a": 1, "b": 2})
+        C(x={'a': 1, 'b': 2})
+        >>> C(x=None)
+        Traceback (most recent call last):
+            ...
+        TypeError: ("'x' must be <class 'dict'> (got None that is a <class 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<deep_mapping validator for objects mapping <instance_of validator for type <class 'str'>> to <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'dict'>, None)
+        >>> C(x={"a": 1.0, "b": 2})
+        Traceback (most recent call last):
+            ...
+        TypeError: ("'x' must be <class 'int'> (got 1.0 that is a <class 'float'>).", Attribute(name='x', default=NOTHING, validator=<deep_mapping validator for objects mapping <instance_of validator for type <class 'str'>> to <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'int'>, 1.0)
+        >>> C(x={"a": 1, 7: 2})
+        Traceback (most recent call last):
+            ...
+        TypeError: ("'x' must be <class 'str'> (got 7 that is a <class 'int'>).", Attribute(name='x', default=NOTHING, validator=<deep_mapping validator for objects mapping <instance_of validator for type <class 'str'>> to <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'str'>, 7)
+
+
 Converters
 ----------
 
diff --git a/src/attr/validators.py b/src/attr/validators.py
index a62e03e..fbd8212 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -168,3 +168,115 @@ def in_(options):
     .. versionadded:: 17.1.0
     """
     return _InValidator(options)
+
+
+@attrs(repr=False, slots=False, hash=True)
+class _IsCallableValidator(object):
+    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))
+
+    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.
+
+    .. versionadded:: 18.3.0
+
+    :raises TypeError: With a human readable error message containing the
+        attribute (of type :class:`attr.Attribute`) name.
+    """
+    return _IsCallableValidator()
+
+
+@attrs(repr=False, slots=True, hash=True)
+class _DeepIterable(object):
+    member_validator = attrib(validator=is_callable())
+    iterable_validator = attrib(
+        default=None, validator=optional(is_callable())
+    )
+
+    def __call__(self, inst, attr, value):
+        """
+        We use a callable class to be able to change the ``__repr__``.
+        """
+        if self.iterable_validator is not None:
+            self.iterable_validator(inst, attr, value)
+
+        for member in value:
+            self.member_validator(inst, attr, member)
+
+    def __repr__(self):
+        iterable_identifier = (
+            ""
+            if self.iterable_validator is None
+            else " {iterable!r}".format(iterable=self.iterable_validator)
+        )
+        return (
+            "<deep_iterable validator for{iterable_identifier}"
+            " iterables of {member!r}>"
+        ).format(
+            iterable_identifier=iterable_identifier,
+            member=self.member_validator,
+        )
+
+
+def deep_iterable(member_validator, iterable_validator=None):
+    """
+    A validator that performs deep validation of an iterable.
+
+    :param member_validator: Validator to apply to iterable members
+    :param iterable_validator: Validator to apply to iterable itself
+        (optional)
+
+    .. versionadded:: 18.3.0
+
+    :raises TypeError: if any sub-validators fail
+    """
+    return _DeepIterable(member_validator, iterable_validator)
+
+
+@attrs(repr=False, slots=True, hash=True)
+class _DeepMapping(object):
+    key_validator = attrib(validator=is_callable())
+    value_validator = attrib(validator=is_callable())
+    mapping_validator = attrib(default=None, validator=optional(is_callable()))
+
+    def __call__(self, inst, attr, value):
+        """
+        We use a callable class to be able to change the ``__repr__``.
+        """
+        if self.mapping_validator is not None:
+            self.mapping_validator(inst, attr, value)
+
+        for key in value:
+            self.key_validator(inst, attr, key)
+            self.value_validator(inst, attr, value[key])
+
+    def __repr__(self):
+        return (
+            "<deep_mapping validator for objects mapping {key!r} to {value!r}>"
+        ).format(key=self.key_validator, value=self.value_validator)
+
+
+def deep_mapping(key_validator, value_validator, mapping_validator=None):
+    """
+    A validator that performs deep validation of a dictionary.
+
+    :param key_validator: Validator to apply to dictionary keys
+    :param value_validator: Validator to apply to dictionary values
+    :param mapping_validator: Validator to apply to top-level mapping
+        attribute (optional)
+
+    .. versionadded:: 18.3.0
+
+    :raises TypeError: if any sub-validators fail
+    """
+    return _DeepMapping(key_validator, value_validator, mapping_validator)
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index abbaedf..01af068 100644
--- a/src/attr/validators.pyi
+++ b/src/attr/validators.pyi
@@ -12,3 +12,13 @@ def optional(
 ) -> _ValidatorType[Optional[_T]]: ...
 def in_(options: Container[_T]) -> _ValidatorType[_T]: ...
 def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ...
+def deep_iterable(
+    member_validator: _ValidatorType[_T],
+    iterable_validator: Optional[_ValidatorType[_T]],
+) -> _ValidatorType[_T]: ...
+def deep_mapping(
+    key_validator: _ValidatorType[_T],
+    value_validator: _ValidatorType[_T],
+    mapping_validator: Optional[_ValidatorType[_T]],
+) -> _ValidatorType[_T]: ...
+def is_callable() -> _ValidatorType[_T]: ...


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index a939c2b..3fe5e1e 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -12,7 +12,16 @@ import attr
 from attr import has
 from attr import validators as validator_module
 from attr._compat import TYPE
-from attr.validators import and_, in_, instance_of, optional, provides
+from attr.validators import (
+    and_,
+    deep_iterable,
+    deep_mapping,
+    in_,
+    instance_of,
+    is_callable,
+    optional,
+    provides,
+)
 
 from .utils import simple_attr
 
@@ -267,6 +276,242 @@ class TestIn_(object):
         assert (("<in_ validator with options [3, 4, 5]>")) == repr(v)
 
 
+class TestDeepIterable(object):
+    """
+    Tests for `deep_iterable`.
+    """
+
+    def test_success_member_only(self):
+        """
+        If the member validator succeeds and the iterable validator is not set,
+        nothing happens.
+        """
+        member_validator = instance_of(int)
+        v = deep_iterable(member_validator)
+        a = simple_attr("test")
+        v(None, a, [42])
+
+    def test_success_member_and_iterable(self):
+        """
+        If both the member and iterable validators succeed, nothing happens.
+        """
+        member_validator = instance_of(int)
+        iterable_validator = instance_of(list)
+        v = deep_iterable(member_validator, iterable_validator)
+        a = simple_attr("test")
+        v(None, a, [42])
+
+    @pytest.mark.parametrize(
+        "member_validator, iterable_validator",
+        (
+            (instance_of(int), 42),
+            (42, instance_of(list)),
+            (42, 42),
+            (42, None),
+        ),
+    )
+    def test_noncallable_validators(
+        self, member_validator, iterable_validator
+    ):
+        """
+        Raise :class:`TypeError` if any validators are not callable.
+        """
+        with pytest.raises(TypeError) as e:
+            deep_iterable(member_validator, iterable_validator)
+
+        e.match(r"\w* must be callable")
+
+    def test_fail_invalid_member(self):
+        """
+        Raise member validator error if an invalid member is found.
+        """
+        member_validator = instance_of(int)
+        v = deep_iterable(member_validator)
+        a = simple_attr("test")
+        with pytest.raises(TypeError):
+            v(None, a, [42, "42"])
+
+    def test_fail_invalid_iterable(self):
+        """
+        Raise iterable validator error if an invalid iterable is found.
+        """
+        member_validator = instance_of(int)
+        iterable_validator = instance_of(tuple)
+        v = deep_iterable(member_validator, iterable_validator)
+        a = simple_attr("test")
+        with pytest.raises(TypeError):
+            v(None, a, [42])
+
+    def test_fail_invalid_member_and_iterable(self):
+        """
+        Raise iterable validator error if both the iterable
+        and a member are invalid.
+        """
+        member_validator = instance_of(int)
+        iterable_validator = instance_of(tuple)
+        v = deep_iterable(member_validator, iterable_validator)
+        a = simple_attr("test")
+        with pytest.raises(TypeError):
+            v(None, a, [42, "42"])
+
+    def test_repr_member_only(self):
+        """
+        Returned validator has a useful `__repr__`
+        when only member validator is set.
+        """
+        member_validator = instance_of(int)
+        member_repr = "<instance_of validator for type <{type} 'int'>>".format(
+            type=TYPE
+        )
+        v = deep_iterable(member_validator)
+        expected_repr = (
+            "<deep_iterable validator for iterables of {member_repr}>"
+        ).format(member_repr=member_repr)
+        assert ((expected_repr)) == repr(v)
+
+    def test_repr_member_and_iterable(self):
+        """
+        Returned validator has a useful `__repr__` when both member
+        and iterable validators are set.
+        """
+        member_validator = instance_of(int)
+        member_repr = "<instance_of validator for type <{type} 'int'>>".format(
+            type=TYPE
+        )
+        iterable_validator = instance_of(list)
+        iterable_repr = (
+            "<instance_of validator for type <{type} 'list'>>"
+        ).format(type=TYPE)
+        v = deep_iterable(member_validator, iterable_validator)
+        expected_repr = (
+            "<deep_iterable validator for"
+            " {iterable_repr} iterables of {member_repr}>"
+        ).format(iterable_repr=iterable_repr, member_repr=member_repr)
+        assert expected_repr == repr(v)
+
+
+class TestDeepMapping(object):
+    """
+    Tests for `deep_mapping`.
+    """
+
+    def test_success(self):
+        """
+        If both the key and value validators succeed, nothing happens.
+        """
+        key_validator = instance_of(str)
+        value_validator = instance_of(int)
+        v = deep_mapping(key_validator, value_validator)
+        a = simple_attr("test")
+        v(None, a, {"a": 6, "b": 7})
+
+    @pytest.mark.parametrize(
+        "key_validator, value_validator, mapping_validator",
+        (
+            (42, instance_of(int), None),
+            (instance_of(str), 42, None),
+            (instance_of(str), instance_of(int), 42),
+            (42, 42, None),
+            (42, 42, 42),
+        ),
+    )
+    def test_noncallable_validators(
+        self, key_validator, value_validator, mapping_validator
+    ):
+        """
+        Raise :class:`TypeError` if any validators are not callable.
+        """
+        with pytest.raises(TypeError) as e:
+            deep_mapping(key_validator, value_validator, mapping_validator)
+
+        e.match(r"\w* must be callable")
+
+    def test_fail_invalid_mapping(self):
+        """
+        Raise :class:`TypeError` if mapping validator fails.
+        """
+        key_validator = instance_of(str)
+        value_validator = instance_of(int)
+        mapping_validator = instance_of(dict)
+        v = deep_mapping(key_validator, value_validator, mapping_validator)
+        a = simple_attr("test")
+        with pytest.raises(TypeError):
+            v(None, a, None)
+
+    def test_fail_invalid_key(self):
+        """
+        Raise key validator error if an invalid key is found.
+        """
+        key_validator = instance_of(str)
+        value_validator = instance_of(int)
+        v = deep_mapping(key_validator, value_validator)
+        a = simple_attr("test")
+        with pytest.raises(TypeError):
+            v(None, a, {"a": 6, 42: 7})
+
+    def test_fail_invalid_member(self):
+        """
+        Raise key validator error if an invalid member value is found.
+        """
+        key_validator = instance_of(str)
+        value_validator = instance_of(int)
+        v = deep_mapping(key_validator, value_validator)
+        a = simple_attr("test")
+        with pytest.raises(TypeError):
+            v(None, a, {"a": "6", "b": 7})
+
+    def test_repr(self):
+        """
+        Returned validator has a useful `__repr__`.
+        """
+        key_validator = instance_of(str)
+        key_repr = "<instance_of validator for type <{type} 'str'>>".format(
+            type=TYPE
+        )
+        value_validator = instance_of(int)
+        value_repr = "<instance_of validator for type <{type} 'int'>>".format(
+            type=TYPE
+        )
+        v = deep_mapping(key_validator, value_validator)
+        expected_repr = (
+            "<deep_mapping validator for objects mapping "
+            "{key_repr} to {value_repr}>"
+        ).format(key_repr=key_repr, value_repr=value_repr)
+        assert expected_repr == repr(v)
+
+
+class TestIsCallable(object):
+    """
+    Tests for `is_callable`.
+    """
+
+    def test_success(self):
+        """
+        If the value is callable, nothing happens.
+        """
+        v = is_callable()
+        a = simple_attr("test")
+        v(None, a, isinstance)
+
+    def test_fail(self):
+        """
+        Raise TypeError if the value is not callable.
+        """
+        v = is_callable()
+        a = simple_attr("test")
+        with pytest.raises(TypeError) as e:
+            v(None, a, None)
+
+        e.match("'test' must be callable")
+
+    def test_repr(self):
+        """
+        Returned validator has a useful `__repr__`.
+        """
+        v = is_callable()
+        assert "<is_callable validator>" == repr(v)
+
+
 def test_hashability():
     """
     Validator classes are hashable.