Skip to content

Commit

Permalink
Add attrs.validators.or_ validator (#1303)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

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

* Update docs/api.rst

---------

Co-authored-by: Hynek Schlawack <[email protected]>
Co-authored-by: Libor <[email protected]>
  • Loading branch information
3 people authored Jul 17, 2024
1 parent 37ac3ef commit 7c9b31e
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 0 deletions.
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.
23 changes: 23 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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__

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)

0 comments on commit 7c9b31e

Please sign in to comment.