From 7c9b31ea7b5565f2ef4a582999dfa6afd7e0f16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Libor=20Mart=C3=ADnek?= Date: Wed, 17 Jul 2024 12:01:41 +0000 Subject: [PATCH] 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 * Rework example of or_ validator in api/docs.rst * Update docs/api.rst --------- Co-authored-by: Hynek Schlawack Co-authored-by: Libor --- changelog.d/1303.change.md | 1 + docs/api.rst | 23 ++++++++++++++++++++ src/attr/validators.py | 44 ++++++++++++++++++++++++++++++++++++++ src/attr/validators.pyi | 1 + tests/test_validators.py | 36 +++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+) create mode 100644 changelog.d/1303.change.md diff --git a/changelog.d/1303.change.md b/changelog.d/1303.change.md new file mode 100644 index 00000000..10e79c14 --- /dev/null +++ b/changelog.d/1303.change.md @@ -0,0 +1 @@ +Added the `attrs.validators.or_()` validator. diff --git a/docs/api.rst b/docs/api.rst index d0ad6b54..c7cea64d 100644 --- a/docs/api.rst +++ b/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 (>, >>) satisfied for value '42' + .. autofunction:: attrs.validators.not_ For example: diff --git a/src/attr/validators.py b/src/attr/validators.py index 0695cd2f..47f234de 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -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"" + + +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)) diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi index 8b2617ad..a314110e 100644 --- a/src/attr/validators.pyi +++ b/src/attr/validators.pyi @@ -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]: ... diff --git a/tests/test_validators.py b/tests/test_validators.py index e0ee3131..de91f320 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -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 " "(got )." ) == 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 ( + ">, >)>" + ) == repr(v)