Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add attrs.validators.or_ validator #1303

Merged
merged 12 commits into from
Jul 17, 2024
1 change: 1 addition & 0 deletions changelog.d/1303.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the `attrs.validators.or_()` validator.
24 changes: 24 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,30 @@ 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::

>>> from typing import List, Union
>>> @define
... class C:
... val: Union[int, List[int]] = field(
... validator=attrs.validators.or_(
... attrs.validators.instance_of(int),
... attrs.validators.deep_iterable(attrs.validators.instance_of(int)),
... )
... )
hynek marked this conversation as resolved.
Show resolved Hide resolved
>>> 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:
Expand Down
44 changes: 44 additions & 0 deletions src/attr/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"min_len",
"not_",
"optional",
"or_",
"set_disabled",
]

Expand Down Expand Up @@ -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))
1 change: 1 addition & 0 deletions src/attr/validators.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...
36 changes: 36 additions & 0 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
min_len,
not_,
optional,
or_,
)

from .utils import simple_attr
Expand Down Expand Up @@ -1261,3 +1262,38 @@ def test_bad_exception_args(self):
"'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__
bibajz marked this conversation as resolved.
Show resolved Hide resolved

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)