Repository: python-attrs/attrs (python)

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)
```


Pull request:
Add `attrs.validators.or_` validator
Closes #1214 

# Summary

Add `attrs.validators.or_` validator, which is satisfied if at least one of the validators it is wrapping is satisfied, thus complementing the `and_` and `not_` "logical connectives" validators.

# Pull Request Check List

<!--
This is just a friendly reminder about the most common mistakes.
Please make sure that you tick all boxes.
But please read our [contribution guide](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.
If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.
-->

- [x] Do **not** open pull requests from your `main` branch – **use a separate branch**!
  - There's a ton of footguns waiting if you don't heed this warning. You can still go back to your project, create a branch from your main branch, push it, and open the pull request from the new branch.
  - This is not a pre-requisite for your your pull request to be accepted, but **you have been warned**.
- [x] Added **tests** for changed code.
  Our CI fails if coverage is not 100%.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [ ] ...and used in the stub test file `tests/typing_example.py`.
    - [ ] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`.
- [x] Updated **documentation** for changed code.
    - [x] New functions/classes have to be added to `docs/api.rst` by hand.
    - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
          The next version is the second number in the current release + 1.
          The first number represents the current year.
          So if the current version on PyPI is 22.2.0, the next version is gonna be 22.3.0.
          If the next version is the first in the new year, it'll be 23.1.0.
- [x] Documentation in `.rst` and `.md` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).
- [x] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you.

<!--
If you have *any* questions to *any* of the points above, just **submit and ask**!
This checklist is here to *help* you, not to deter you from contributing!
-->

# TODO (done!)

- [x] Need to add entry into the `changes` dir, but that is dependent on the PR number of this PR, so needs to be post hoc - done in 69966273e3b3af26fe44104dfa1408d47ac6a121
- [x] Reflect the addition in `docs/api.rst` - done in 2ad53b30622446cfd2dce7fcef27b0f5ed542fca

Patch:
diff --git a/changelog.d/1303.change.md b/changelog.d/1303.change.md
new file mode 100644
index 0000000..10e79c1
--- /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 d0ad6b5..c7cea64 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 0695cd2..47f234d 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"<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 8b2617a..a314110 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 e0ee313..de91f32 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 <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)