From f870beb84a56daaf6b638301f8afb530ed903727 Mon Sep 17 00:00:00 2001 From: Nick Timkovich Date: Sun, 4 Sep 2022 06:55:02 -0500 Subject: [PATCH] Add a not_ validator (#1010) --- changelog.d/1010.change.rst | 1 + docs/api.rst | 26 ++++ src/attr/validators.py | 120 ++++++++++++++++ src/attr/validators.pyi | 9 ++ tests/test_validators.py | 277 ++++++++++++++++++++++++++++++++++++ tests/typing_example.py | 20 +++ 6 files changed, 453 insertions(+) create mode 100644 changelog.d/1010.change.rst diff --git a/changelog.d/1010.change.rst b/changelog.d/1010.change.rst new file mode 100644 index 000000000..ac398e24d --- /dev/null +++ b/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* rejects the value with a ``ValueError`` or ``TypeError`` (by default, exception types configurable). diff --git a/docs/api.rst b/docs/api.rst index f7298118b..9dff7a54a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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", Attribute(name='tags', default=NOTHING, validator=, capturing (, )>, type=None, kw_only=False), , {'source_': 'universe'}, (, )) + >>> Measurement(tags={"source_": "universe"}) + Measurement(tags={'source_': 'universe'}) + + .. autofunction:: attrs.validators.optional For example: diff --git a/src/attr/validators.py b/src/attr/validators.py index f27049b3d..f1f6f2475 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -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 @@ -37,6 +38,7 @@ "matches_re", "max_len", "min_len", + "not_", "optional", "provides", "set_disabled", @@ -592,3 +594,121 @@ 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}).".format( + name=attr.name, + type=self.type, + value=value, + ), + attr, + self.type, + value, + ) + + def __repr__(self): + return "".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, + ), + attr, + self.validator, + value, + self.exc_types, + ) + + def __repr__(self): + return ( + "" + ).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: With a human readable error message, + the attribute (of type `attrs.Attribute`), + the validator that failed to raise an exception, + the value it got, + and the expected exception types. + + .. versionadded:: 22.2.0 + """ + try: + exc_types = tuple(exc_types) + except TypeError: + exc_types = (exc_types,) + return _NotValidator(validator, msg, exc_types) diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi index 54b9dba24..ae6c6f40e 100644 --- a/src/attr/validators.pyi +++ b/src/attr/validators.pyi @@ -78,3 +78,12 @@ 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[Exception], Iterable[Type[Exception]]] = ( + ValueError, + TypeError, + ) +) -> _ValidatorType[_T]: ... diff --git a/tests/test_validators.py b/tests/test_validators.py index 51fe2f41e..d486ca47f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -14,6 +14,7 @@ from attr import _config, fields, has from attr import validators as validator_module from attr.validators import ( + _subclass_of, and_, deep_iterable, deep_mapping, @@ -27,6 +28,7 @@ matches_re, max_len, min_len, + not_, optional, provides, ) @@ -1064,3 +1066,278 @@ def test_repr(self): __repr__ is meaningful. """ assert repr(min_len(23)) == "" + + +class TestSubclassOf: + """ + Tests for `_subclass_of`. + """ + + def test_success(self): + """ + Nothing happens if classes match. + """ + v = _subclass_of(int) + v(None, simple_attr("test"), int) + + def test_subclass(self): + """ + Subclasses are accepted too. + """ + v = _subclass_of(int) + # yep, bools are a subclass of int :( + v(None, simple_attr("test"), bool) + + def test_fail(self): + """ + Raises `TypeError` on wrong types. + """ + v = _subclass_of(int) + a = simple_attr("test") + with pytest.raises(TypeError) as e: + v(None, a, str) + assert ( + "'test' must be a subclass of (got ).", + a, + int, + str, + ) == e.value.args + + def test_repr(self): + """ + Returned validator has a useful `__repr__`. + """ + v = _subclass_of(int) + assert (">") == repr(v) + + +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 ( + ( + "" + ).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") + input_value = 3 + + v(1, a, input_value) + + 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") + input_value = 3 + + with pytest.raises(ValueError) as e: + v(1, a, input_value) + + assert ( + ( + "not_ validator child '{!r}' did not raise a captured error" + ).format(always_passes), + a, + always_passes, + input_value, + 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") + input_value = "d" + + v(None, a, input_value) + + 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") + input_value = "b" + + with pytest.raises(ValueError) as e: + v(None, a, input_value) + + assert ( + ( + "not_ validator child '{!r}' did not raise a captured error" + ).format(in_("abc")), + a, + wrapped, + input_value, + 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") + input_value = "spam" + + v(None, a, input_value) + + 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") + input_value = "egg" + + with pytest.raises(ValueError) as e: + v(None, a, input_value) + + assert ( + ( + "not_ validator child '{!r}' did not raise a captured error" + ).format(wrapped), + a, + wrapped, + input_value, + 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") + input_value = 2.718281828 + + with pytest.raises(ValueError) as e: + v(None, a, input_value) + + assert ( + ( + "not_ validator child '{!r}' did not raise a captured error" + ).format(instance_of((int, float))), + a, + wrapped, + input_value, + 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") + input_value = "d" + + with pytest.raises(ValueError) as e: + v(None, a, input_value) + + # get the underlying exception to compare + with pytest.raises(Exception) as e_from_wrapped: + wrapped(None, a, input_value) + 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") + input_value = "a" + + with pytest.raises(ValueError) as e: + v(None, a, input_value) + + assert ( + custom_msg, + a, + wrapped, + input_value, + 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 " + "(got )." + ) == e.value.args[0] diff --git a/tests/typing_example.py b/tests/typing_example.py index a8228e596..012a190cc 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -206,6 +206,26 @@ class Validated: validator=attrs.validators.instance_of((int, C, str)) ) + l: Any = attr.ib( + validator=attr.validators.not_(attr.validators.in_("abc")) + ) + m: Any = attr.ib( + validator=attr.validators.not_( + attr.validators.in_("abc"), exc_types=ValueError + ) + ) + n: Any = attr.ib( + validator=attr.validators.not_( + attr.validators.in_("abc"), exc_types=(ValueError,) + ) + ) + o: Any = attr.ib( + validator=attr.validators.not_(attr.validators.in_("abc"), msg="spam") + ) + p: Any = attr.ib( + validator=attr.validators.not_(attr.validators.in_("abc"), msg=None) + ) + @attr.define class Validated2: