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 29, 2022
1 parent c860e9d commit 076bed4
Show file tree
Hide file tree
Showing 6 changed files with 379 additions and 0 deletions.
1 change: 1 addition & 0 deletions changelog.d/1010.change.rst
@@ -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).
26 changes: 26 additions & 0 deletions docs/api.rst
Expand Up @@ -596,6 +596,32 @@ 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_

For example:

.. doctest::

>>> reserved_names = {"id", "time", "source"}
>>> @attrs.define
... class Measurement:
... tags = attrs.field(
... validator=attrs.validators.deep_mapping(
... key_validator=attrs.validators.not_(
... attrs.validators.in_(reserved_names),
... msg="reserved tag key",
... ),
... value_validator=attrs.validators.instance_of((str, int)),
... )
... )
>>> Measurement(tags={"source": "universe"})
Traceback (most recent call last):
...
ValueError: ("reserved tag key", <in_ validator with options {'id', 'time', 'source'}>, (<class 'ValueError'>, <class 'TypeError'>))
>>> Measurement(tags={"source_": "universe"})
Measurement(tags={'source_': 'universe'})


.. autofunction:: attrs.validators.optional

For example:
Expand Down
116 changes: 116 additions & 0 deletions src/attr/validators.py
Expand Up @@ -12,6 +12,7 @@

from ._config import get_run_validators, set_run_validators
from ._make import _AndValidator, and_, attrib, attrs
from .converters import default_if_none
from .exceptions import NotCallableError


Expand All @@ -37,6 +38,7 @@
"matches_re",
"max_len",
"min_len",
"not_",
"optional",
"provides",
"set_disabled",
Expand Down Expand Up @@ -592,3 +594,117 @@ 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()
msg = attrib(
converter=default_if_none(
"not_ validator child '{validator!r}' "
"did not raise a captured error"
)
)
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(
self.msg.format(
validator=self.validator,
exc_types=self.exc_types,
),
self.validator,
self.exc_types,
)

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, *, msg=None, 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 msg: Message to raise if validator fails.
Formatted with keys ``exc_types`` and ``validator``.
:type msg: str
:param exc_types: Exception type(s) to capture.
Other types raised by child validators will not be intercepted and
pass through.
: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, msg, exc_types)
6 changes: 6 additions & 0 deletions src/attr/validators.pyi
Expand Up @@ -78,3 +78,9 @@ 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],
*,
msg: Optional[str] = None,
exc_types: Union[Type[type], Iterable[type]] = (ValueError, TypeError)
) -> _ValidatorType[_T]: ...
210 changes: 210 additions & 0 deletions tests/test_validators.py
Expand Up @@ -27,6 +27,7 @@
matches_re,
max_len,
min_len,
not_,
optional,
provides,
)
Expand Down Expand Up @@ -1064,3 +1065,212 @@ def test_repr(self):
__repr__ is meaningful.
"""
assert repr(min_len(23)) == "<min_len validator for 23>"


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

DEFAULT_EXC_TYPES = (ValueError, TypeError)

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

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)

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),
always_passes,
self.DEFAULT_EXC_TYPES,
) == 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.
"""
wrapped = in_("abc")
v = not_(wrapped)
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")),
wrapped,
self.DEFAULT_EXC_TYPES,
) == 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.
"""
wrapped = matches_re("[a-z]{3}")
v = not_(wrapped)
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(wrapped),
wrapped,
self.DEFAULT_EXC_TYPES,
) == 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.
"""
wrapped = instance_of((int, float))
v = not_(wrapped)
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))),
wrapped,
self.DEFAULT_EXC_TYPES,
) == e.value.args

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

v(None, a, "d")

def test_custom_capture_miss(self):
"""
If the exception doesn't match, the underlying raise comes through
"""

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

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

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

# get the underlying exception to compare
with pytest.raises(Exception) as e_from_wrapped:
wrapped(None, a, "d")
assert e_from_wrapped.value.args == e.value.args

def test_custom_msg(self):
"""
If provided, use the custom message in the raised error
"""
custom_msg = "custom message!"
wrapped = in_("abc")
v = not_(wrapped, msg=custom_msg)
a = simple_attr("test")

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

assert (custom_msg, wrapped, self.DEFAULT_EXC_TYPES) == e.value.args

def test_bad_exception_args(self):
"""
Malformed exception arguments
"""
wrapped = in_("abc")

with pytest.raises(TypeError) as e:
not_(wrapped, exc_types=(str, int))

assert (
"'exc_types' must be a subclass of <class 'Exception'> "
"(got <class 'str'> that is a <class 'type'>)."
) == e.value.args[0]

0 comments on commit 076bed4

Please sign in to comment.