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:
parent
37ac3ef088
commit
7c9b31ea7b
|
@ -0,0 +1 @@
|
|||
Added the `attrs.validators.or_()` validator.
|
23
docs/api.rst
23
docs/api.rst
|
@ -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:
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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]: ...
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue