Repository: python-attrs/attrs

Issue:
Multiple alternatives validator
I'd like to validate that a field can be of several types, one of which is an iterator.

```py
from attrs.validator import instance_of

@define
class C:
  x: A | B | tuple[int, ...] = field(validator=instance_of((A, B, tuple)))
```

I can use `instance_of` to check the outer type `tuple`, but I can't use `deep_iterable` to validate that they are indeed the element type `int`.

Since we already have validators `and_` and `not_`, it may make sense to also add `or_`, so that I can represent this with

```py
from attrs.validator import instance_of, deep_iterable, or_

@define
class C:
  x: A | B | tuple[int, ...] = field(
    validator=or_(
      instance_of((A, B)),
      deep_iterable(instance_of(int))))
```

For comparison, this is how I'm writing a custom validator


```py
from attrs.validator import instance_of, deep_iterable

@define
class C:
  x: A | B | tuple[int, ...] = field()

  @x.validator
  def _validate_x(self, attr, value):
    v1 = instance_of((A, B))
    v2 = deep_iterable(instance_of(int))
    try:
      v1(self, attr, value)
    except TypeError:
      v2(self, attr, value)
```



Discussion:
FTR, it's already possible to do that using only `and_` and `not_`, thanks to [De Morgan's laws](https://en.wikipedia.org/wiki/De_Morgan%27s_laws):

```py
from attrs.validator import instance_of, deep_iterable, and_, not_

@define
class C:
  x: A | B | tuple[int, ...] = field(
    validator= not_(and_(
      not_(instance_of((A, B))),
      not_(deep_iterable(instance_of(int))))))
```

But the error message is not very informative:

```py
>>> @define
... class C:
...   x = field(validator=not_( and_( not_( instance_of((A, B)) ), not_( deep_iterable(instance_of(int))  ) ) ))
... 
>>> C(A())
C(x=A())
>>> C(B())
C(x=B())
>>> C(())
C(x=())
>>> C((1, 2, 3))
C(x=(1, 2, 3))
>>> C((1, 2, 3, '4'))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<attrs generated init __main__.C>", line 5, in __init__
  File "<REDACTED>/lib/python3.12/site-packages/attr/validators.py", line 670, in __call__
    raise ValueError(
ValueError: ("not_ validator child '_AndValidator(_validators=(<not_ validator wrapping <instance_of validator for type (<class '__main__.A'>, <class '__main__.B'>)>, capturing (<class 'ValueError'>, <class 'TypeError'>)>, <not_ validator wrapping <deep_iterable validator for iterables of <instance_of validator for type <class 'int'>>>, capturing (<class 'ValueError'>, <class 'TypeError'>)>))' did not raise a captured error", Attribute(name='x', default=NOTHING, validator=<not_ validator wrapping _AndValidator(_validators=(<not_ validator wrapping <instance_of validator for type (<class '__main__.A'>, <class '__main__.B'>)>, capturing (<class 'ValueError'>, <class 'TypeError'>)>, <not_ validator wrapping <deep_iterable validator for iterables of <instance_of validator for type <class 'int'>>>, capturing (<class 'ValueError'>, <class 'TypeError'>)>)), capturing (<class 'ValueError'>, <class 'TypeError'>)>, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='x'), _AndValidator(_validators=(<not_ validator wrapping <instance_of validator for type (<class '__main__.A'>, <class '__main__.B'>)>, capturing (<class 'ValueError'>, <class 'TypeError'>)>, <not_ validator wrapping <deep_iterable validator for iterables of <instance_of validator for type <class 'int'>>>, capturing (<class 'ValueError'>, <class 'TypeError'>)>)), (1, 2, 3, '4'), (<class 'ValueError'>, <class 'TypeError'>))
```



Patch:
diff --git a/changelog.d/1303.change.md b/changelog.d/1303.change.md
new file mode 100644
index 000000000..10e79c14e
--- /dev/null
+++ b/changelog.d/1303.change.md
@@ -0,0 +1,1 @@
+Added the `attrs.validators.or_()` validator.
diff --git a/docs/api.rst b/docs/api.rst
index d0ad6b541..c7cea64d2 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 (<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:
diff --git a/src/attr/validators.py b/src/attr/validators.py
index 0695cd2fc..47f234de1 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -35,6 +35,7 @@
     "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))
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index 8b2617adb..a314110e6 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]: ...


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index e0ee3131d..de91f3206 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -30,6 +30,7 @@
     min_len,
     not_,
     optional,
+    or_,
 )
 
 from .utils import simple_attr
@@ -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)