Skip to content

Commit

Permalink
Add a not_ validator
Browse files Browse the repository at this point in the history
  • Loading branch information
Nick Timkovich committed Aug 22, 2022
1 parent fab5a92 commit 5e7d6be
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 0 deletions.
1 change: 1 addition & 0 deletions changelog.d/1010.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``attrs.validators.not_(wrapped_validator)`` to logically invert *wrapped_validator* by accepting only values where *wrapped_validator* raises a ``ValueError`` or a ``TypeError`` (by default, exception types configurable).
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,8 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
x = attrs.field(validator=attrs.validators.and_(v1, v2, v3))
x = attrs.field(validator=[v1, v2, v3])

.. autofunction:: attrs.validators.not_

.. autofunction:: attrs.validators.optional

For example:
Expand Down
101 changes: 101 additions & 0 deletions src/attr/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"matches_re",
"max_len",
"min_len",
"not_",
"optional",
"provides",
"set_disabled",
Expand Down Expand Up @@ -592,3 +593,103 @@ def min_len(length):
.. versionadded:: 22.1.0
"""
return _MinLengthValidator(length)


@attrs(repr=False, slots=True, hash=True)
class _SubclassOfValidator:
type = attrib()

def __call__(self, inst, attr, value):
"""
We use a callable class to be able to change the ``__repr__``.
"""
if not issubclass(value, self.type):
raise TypeError(
"'{name}' must be a subclass of {type!r} (got {value!r} "
"that is a {actual!r}).".format(
name=attr.name,
type=self.type,
actual=value.__class__,
value=value,
),
attr,
self.type,
value,
)

def __repr__(self):
return "<subclass_of validator for type {type!r}>".format(
type=self.type
)


def _subclass_of(type):
"""
A validator that raises a `TypeError` if the initializer is called
with a wrong type for this particular attribute (checks are performed using
`issubclass` therefore it's also valid to pass a tuple of types).
:param type: The type to check for.
:type type: type or tuple of types
:raises TypeError: With a human readable error message, the attribute
(of type `attrs.Attribute`), the expected type, and the value it
got.
"""
return _SubclassOfValidator(type)


@attrs(repr=False, slots=True, hash=True)
class _NotValidator:
validator = attrib()
exc_types = attrib(
validator=deep_iterable(
member_validator=_subclass_of(Exception),
iterable_validator=instance_of(tuple),
),
)

def __call__(self, inst, attr_, value):
try:
self.validator(inst, attr_, value)
except self.exc_types:
pass # suppress error to invert validity
else:
raise ValueError(
"not_ validator child '{validator}' did not raise "
"a captured error".format(validator=repr(self.validator))
)

def __repr__(self):
return (
"<not_ validator wrapping {what!r}, " "capturing {exc_types!r}>"
).format(
what=self.validator,
exc_types=self.exc_types,
)


def not_(validator, exc_types=(ValueError, TypeError)):
"""
A validator that wraps and logically 'inverts' the validator passed to it.
It will raise a `ValueError` if the provided validator *doesn't* raise a
`ValueError` or `TypeError` (by default), and will suppress the exception
if the provided validator *does*.
Intended to be used with existing validators to compose logic without
needing to create inverted variants, for example, ``not_(in_(...))``.
:param validator: A validator to be logically inverted.
:param exc_types: Exception types to capture.
:type exc_types: a single Exception type or a tuple of Exception types.
:raises ValueError: If the wrapped validator does not raise an exception
of the specified types, this validator will raise a `ValueError`.
.. versionadded:: 22.2.0
"""
try:
exc_types = tuple(exc_types)
except TypeError:
exc_types = (exc_types,)
return _NotValidator(validator, exc_types)
3 changes: 3 additions & 0 deletions src/attr/validators.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,6 @@ def ge(val: _T) -> _ValidatorType[_T]: ...
def gt(val: _T) -> _ValidatorType[_T]: ...
def max_len(length: int) -> _ValidatorType[_T]: ...
def min_len(length: int) -> _ValidatorType[_T]: ...
def not_(
validator: _ValidatorType[_T], exc_types: Union[Type[_T], Tuple[type, ...]]
) -> _ValidatorType[_T]: ...
170 changes: 170 additions & 0 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
matches_re,
max_len,
min_len,
not_,
optional,
provides,
)
Expand Down Expand Up @@ -1064,3 +1065,172 @@ def test_repr(self):
__repr__ is meaningful.
"""
assert repr(min_len(23)) == "<min_len validator for 23>"


class TestNot_:
"""
Tests for `not_`.
"""

def test_not_all(self):
"""
The validator is in ``__all__``.
"""
assert not_.__name__ in validator_module.__all__

def test_success_because_fails(self):
"""
If the wrapped validator fails, we're happy.
"""

def always_fails(inst, attr, value):
raise ValueError("always fails")

v = not_(always_fails)
a = simple_attr("test")

v(1, a, 3)

def test_fails_because_success(self):
"""
If the wrapped validator doesn't fail, not_ should fail.
"""

def always_passes(inst, attr, value):
pass

v = not_(always_passes)
a = simple_attr("test")

with pytest.raises(ValueError) as e:
v(1, a, 3)

assert (
(
"not_ validator child '{!r}' did not raise a captured error"
).format(always_passes),
) == e.value.args

def test_composable_with_in_pass(self):
"""
Check something is ``not in`` something else.
"""
v = not_(in_("abc"))
a = simple_attr("test")

v(None, a, "d")

def test_composable_with_in_fail(self):
"""
Check something is ``not in`` something else, but it is, so fail.
"""
v = not_(in_("abc"))
a = simple_attr("test")

with pytest.raises(ValueError) as e:
v(None, a, "b")

assert (
(
"not_ validator child '{!r}' did not raise a captured error"
).format(in_("abc")),
) == e.value.args

def test_composable_with_matches_re_pass(self):
"""
Check something does not match a regex.
"""
v = not_(matches_re("[a-z]{3}"))
a = simple_attr("test")

v(None, a, "spam")

def test_composable_with_matches_re_fail(self):
"""
Check something does not match a regex, but it does, so fail.
"""
v = not_(matches_re("[a-z]{3}"))
a = simple_attr("test")

with pytest.raises(ValueError) as e:
v(None, a, "egg")

assert (
(
"not_ validator child '{!r}' did not raise a captured error"
).format(matches_re("[a-z]{3}")),
) == e.value.args

def test_composable_with_instance_of_pass(self):
"""
Check something is not a type. This validator raises a TypeError,
rather than a ValueError like the others.
"""
v = not_(instance_of((int, float)))
a = simple_attr("test")

v(None, a, "spam")

def test_composable_with_instance_of_fail(self):
"""
Check something is not a type, but it is, so fail.
"""
v = not_(instance_of((int, float)))
a = simple_attr("test")

with pytest.raises(ValueError) as e:
v(None, a, 2.718281828)

assert (
(
"not_ validator child '{!r}' did not raise a captured error"
).format(instance_of((int, float))),
) == e.value.args

def test_custom_capture_match(self):
"""
Match a custom exception provided to `not_`
"""
v = not_(in_("abc"), ValueError)
a = simple_attr("test")

v(None, a, "d")

def test_custom_capture_miss(self):
"""
Don't match a custom exception provided to `not_`
"""

class MyError(Exception):
""":("""

wrapped = in_("abc")
v = not_(wrapped, MyError)
a = simple_attr("test")

with pytest.raises(ValueError) as e:
v(None, a, "d")

# not_ didn't capture anything, because it only expected MyError,
# so the error should bubbles up from whatever was wrapped.
with pytest.raises(Exception) as e_from_wrapped:
wrapped(None, a, "d")
assert e_from_wrapped.value.args == e.value.args

def test_repr(self):
"""
Returned validator has a useful `__repr__`.
"""
wrapped = in_([3, 4, 5])

v = not_(wrapped)

assert (
(
"<not_ validator wrapping {wrapped!r}, "
"capturing {exc_types!r}>"
).format(
wrapped=wrapped,
exc_types=v.exc_types,
)
) == repr(v)

0 comments on commit 5e7d6be

Please sign in to comment.