Add `attrs.validators.or_` validator (#1303)

* Implement attrs.validators.or_ validator

* Add tests/test_validators.py::TestOr test cases

* Add test for _OrValidator.__repr__ method

* Add description of attrs.validators.or_ to docs/api.rst

* Add changelog entry

* Swap double quotes for single because doctests don't like it

* Rename changelog fragment pointing to incorrect number

* Silence ruff linter warnings

Although good warning in general, in this particular code, they
do not fit here.

* BLE001 Do not catch blind exception: `Exception`
    `validators` usually raise `ValueError` upon their violation.
    However, it's not the only `Exception` they can raise - cf.
    `attrs.validators.not_` - therefore, it is desirable to catch
    them all!
* PERF203 `try`-`except` within a loop incurs performance overhead
    Fair point, but the loop is written in a way to short-circuit,
    ie. it will finish when a first validator is satisfied.
* S112 `try`-`except`-`continue` detected, consider logging the exception
    Not applicable here, we care only if **all** validators raise
    an exception, which is already accomodated for after the `for`
    loop.

* Apply suggestions from code review

Co-authored-by: Hynek Schlawack <hs@ox.cx>

* Rework example of or_ validator in api/docs.rst

* Update docs/api.rst

---------

Co-authored-by: Hynek Schlawack <hs@ox.cx>
Co-authored-by: Libor <libas.martinek@protonmail.com>
This commit is contained in:
Libor Martínek 2024-07-17 12:01:41 +00:00 committed by GitHub
parent 37ac3ef088
commit 7c9b31ea7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 105 additions and 0 deletions

View File

@ -0,0 +1 @@
Added the `attrs.validators.or_()` validator.

View File

@ -460,6 +460,29 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
x = field(validator=attrs.validators.and_(v1, v2, v3))
x = field(validator=[v1, v2, v3])
.. autofunction:: attrs.validators.or_
For example:
.. doctest::
>>> @define
... class C:
... val: int | list[int] = field(
... validator=attrs.validators.or_(
... attrs.validators.instance_of(int),
... attrs.validators.deep_iterable(attrs.validators.instance_of(int)),
... )
... )
>>> C(42)
C(val=42)
>>> C([1, 2, 3])
C(val=[1, 2, 3])
>>> C(val='42')
Traceback (most recent call last):
...
ValueError: None of (<instance_of validator for type <class 'int'>>, <deep_iterable validator for iterables of <instance_of validator for type <class 'int'>>>) satisfied for value '42'
.. autofunction:: attrs.validators.not_
For example:

View File

@ -35,6 +35,7 @@ __all__ = [
"min_len",
"not_",
"optional",
"or_",
"set_disabled",
]
@ -614,3 +615,46 @@ def not_(validator, *, msg=None, exc_types=(ValueError, TypeError)):
except TypeError:
exc_types = (exc_types,)
return _NotValidator(validator, msg, exc_types)
@attrs(repr=False, slots=True, hash=True)
class _OrValidator:
validators = attrib()
def __call__(self, inst, attr, value):
for v in self.validators:
try:
v(inst, attr, value)
except Exception: # noqa: BLE001, PERF203, S112
continue
else:
return
msg = f"None of {self.validators!r} satisfied for value {value!r}"
raise ValueError(msg)
def __repr__(self):
return f"<or validator wrapping {self.validators!r}>"
def or_(*validators):
"""
A validator that composes multiple validators into one.
When called on a value, it runs all wrapped validators until one of them
is satisfied.
:param ~collections.abc.Iterable[typing.Callable] validators: Arbitrary
number of validators.
:raises ValueError: If no validator is satisfied. Raised with a
human-readable error message listing all the wrapped validators and
the value that failed all of them.
.. versionadded:: 24.1.0
"""
vals = []
for v in validators:
vals.extend(v.validators if isinstance(v, _OrValidator) else [v])
return _OrValidator(tuple(vals))

View File

@ -80,3 +80,4 @@ def not_(
msg: str | None = None,
exc_types: type[Exception] | Iterable[type[Exception]] = ...,
) -> _ValidatorType[_T]: ...
def or_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ...

View File

@ -30,6 +30,7 @@ from attr.validators import (
min_len,
not_,
optional,
or_,
)
from .utils import simple_attr
@ -1261,3 +1262,38 @@ class TestNot_:
"'exc_types' must be a subclass of <class 'Exception'> "
"(got <class 'str'>)."
) == e.value.args[0]
class TestOr:
def test_in_all(self):
"""
Verify that this validator is in ``__all__``.
"""
assert or_.__name__ in validator_module.__all__
def test_success(self):
"""
Succeeds if at least one of wrapped validators succeed.
"""
v = or_(instance_of(str), always_pass)
v(None, simple_attr("test"), 42)
def test_fail(self):
"""
Fails if all wrapped validators fail.
"""
v = or_(instance_of(str), always_fail)
with pytest.raises(ValueError):
v(None, simple_attr("test"), 42)
def test_repr(self):
"""
Returned validator has a useful `__repr__`.
"""
v = or_(instance_of(int), instance_of(str))
assert (
"<or validator wrapping (<instance_of validator for type "
"<class 'int'>>, <instance_of validator for type <class 'str'>>)>"
) == repr(v)