Repository: python-attrs/attrs
Pull request #859 (2021-11-02 18:28:21)

Add "no_run_validators()" context manager
# 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://www.attrs.org/en/latest/contributing.html) 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] Added **tests** for changed code.
- [xx] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [x] ...and used in the stub test file `tests/typing_example.py`.
- [x] Updated **documentation** for changed code.
    - [x] New functions/classes have to be added to `docs/api.rst` by hand.
    - [x] 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).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file.
- [x] Documentation in `.rst` 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).

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!


sscherfke: My first idea was to name the function `out_validators()`:
```python
with out_validators():
    ...
```
🤪
hynek: hmmm the problem here is that it's extremely not thread-safe? 😅

But I guess @glyph asked for it already in #188 (2017!) so I guess if we document it properly…?
hynek: I think I like his `attr.validators.disabled()` naming btw!
sscherfke: > hmmm the problem here is that it's extremely not thread-safe? 😅

It’s not more thread-(un)safe than the underlying function `set_run_validators()` 🙃 

> I think I like his attr.validators.disabled() naming btw!

I’d have no problem with that, but `set_run_validators()` and `get_run_validators()` are defined in `_config.py` and are made available directly under the `attr` namespace.  So putting `no_run_validators()` to `validators.disable()` would be a bit inconsistent, wouldn’t it?
hynek: > > hmmm the problem here is that it's extremely not thread-safe? 😅
> It’s not more thread-(un)safe than the underlying function `set_run_validators()` 🙃

100% correct, it's just that a context manager _looks_ like it might be so we should nail it home in the doc strings – ideally with a warning block.

> > I think I like his attr.validators.disabled() naming btw!
>
> I’d have no problem with that, but `set_run_validators()` and `get_run_validators()` are defined in `_config.py` and are made available directly under the `attr` namespace. So putting `no_run_validators()` to `validators.disable()` would be a bit inconsistent, wouldn’t it?

It would. But OTOH I don't expect anyone to use `set_run_validators()` and `get_run_validators()` after `disable()` landed. :D I wouldn't even mind to create better named aliases in the validators namespace and deprecate the old ones (without ever removing them – but not taking them with us into the attrs namespace).
sscherfke: It has been done.
sscherfke: Is there anything left to be done?
sscherfke: Done.  Not sure, what kind of example you think of for the typing examples, though.
sscherfke: "Just a few docs and typing changes.  What could go wrong", he thought.


Gonna fix it tomorrow.
hynek: > Not sure, what kind of example you think of for the typing examples, though.

Just use the API and see if MyPy complains. :)

sscherfke: Okay, fixed it.

Patch:
diff --git a/changelog.d/859.change.rst b/changelog.d/859.change.rst
new file mode 100644
index 0000000..a79bd98
--- /dev/null
+++ b/changelog.d/859.change.rst
@@ -0,0 +1,4 @@
+Added new context manager ``attr.validators.disabled()`` and functions ``attr.validators.(set|get)_disabled()``.
+They deprecate ``attr.(set|get)_run_validators()``.
+All functions are interoperable and modify the same internal state.
+They are not – and never were – thread-safe, though.
diff --git a/docs/api.rst b/docs/api.rst
index d712e4d..2306b1a 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -590,6 +590,14 @@ Validators
             ...
         TypeError: ("'x' must be <class 'str'> (got 7 that is a <class 'int'>).", Attribute(name='x', default=NOTHING, validator=<deep_mapping validator for objects mapping <instance_of validator for type <class 'str'>> to <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'str'>, 7)
 
+Validators can be both globally and locally disabled:
+
+.. autofunction:: attr.validators.set_disabled
+
+.. autofunction:: attr.validators.get_disabled
+
+.. autofunction:: attr.validators.disabled
+
 
 Converters
 ----------
diff --git a/docs/init.rst b/docs/init.rst
index fdbf0e1..bda39f1 100644
--- a/docs/init.rst
+++ b/docs/init.rst
@@ -242,10 +242,20 @@ If you define validators both ways for an attribute, they are both ran:
 
 And finally you can disable validators globally:
 
-   >>> attr.set_run_validators(False)
+   >>> attr.validators.set_disabled(True)
    >>> C("128")
    C(x='128')
-   >>> attr.set_run_validators(True)
+   >>> attr.validators.set_disabled(False)
+   >>> C("128")
+   Traceback (most recent call last):
+      ...
+   TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), <class 'int'>, '128')
+
+You can achieve the same by using the context manager:
+
+   >>> with attr.validators.disabled():
+   ...     C("128")
+   C(x='128')
    >>> C("128")
    Traceback (most recent call last):
       ...
diff --git a/src/attr/_config.py b/src/attr/_config.py
index 8ec9209..6503f6f 100644
--- a/src/attr/_config.py
+++ b/src/attr/_config.py
@@ -9,6 +9,10 @@ _run_validators = True
 def set_run_validators(run):
     """
     Set whether or not validators are run.  By default, they are run.
+
+    .. deprecated:: 21.3.0 It will not be removed, but it also will not be
+        moved to new ``attrs`` namespace.  Use `attr.validators.set_disabled()`
+        instead.
     """
     if not isinstance(run, bool):
         raise TypeError("'run' must be bool.")
@@ -19,5 +23,9 @@ def set_run_validators(run):
 def get_run_validators():
     """
     Return whether or not validators are run.
+
+    .. deprecated:: 21.3.0 It will not be removed, but it also will not be
+        moved to new ``attrs`` namespace.  Use `attr.validators.get_disabled()`
+        instead.
     """
     return _run_validators
diff --git a/src/attr/validators.py b/src/attr/validators.py
index 8c28fb0..1eb2572 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -7,6 +7,9 @@ from __future__ import absolute_import, division, print_function
 import operator
 import re
 
+from contextlib import contextmanager
+
+from ._config import get_run_validators, set_run_validators
 from ._make import _AndValidator, and_, attrib, attrs
 from .exceptions import NotCallableError
 
@@ -15,7 +18,9 @@ __all__ = [
     "and_",
     "deep_iterable",
     "deep_mapping",
+    "disabled",
     "ge",
+    "get_disabled",
     "gt",
     "in_",
     "instance_of",
@@ -26,9 +31,58 @@ __all__ = [
     "max_len",
     "optional",
     "provides",
+    "set_disabled",
 ]
 
 
+def set_disabled(disabled):
+    """
+    Globally disable or enable running validators.
+
+    By default, they are run.
+
+    :param disabled: If ``True``, disable running all validators.
+    :type disabled: bool
+
+    .. warning::
+
+        This function is not thread-safe!
+
+    .. versionadded:: 21.3.0
+    """
+    set_run_validators(not disabled)
+
+
+def get_disabled():
+    """
+    Return a bool indicating whether validators are currently disabled or not.
+
+    :return: ``True`` if validators are currently disabled.
+    :rtype: bool
+
+    .. versionadded:: 21.3.0
+    """
+    return not get_run_validators()
+
+
+@contextmanager
+def disabled():
+    """
+    Context manager that disables running validators within its context.
+
+    .. warning::
+
+        This context manager is not thread-safe!
+
+    .. versionadded:: 21.3.0
+    """
+    set_run_validators(False)
+    try:
+        yield
+    finally:
+        set_run_validators(True)
+
+
 @attrs(repr=False, slots=True, hash=True)
 class _InstanceOfValidator(object):
     type = attrib()
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index 0f7d093..a439332 100644
--- a/src/attr/validators.pyi
+++ b/src/attr/validators.pyi
@@ -3,6 +3,7 @@ from typing import (
     AnyStr,
     Callable,
     Container,
+    ContextManager,
     Iterable,
     List,
     Mapping,
@@ -26,6 +27,10 @@ _K = TypeVar("_K")
 _V = TypeVar("_V")
 _M = TypeVar("_M", bound=Mapping)
 
+def set_disabled(run: bool) -> None: ...
+def get_disabled() -> bool: ...
+def disabled() -> ContextManager[None]: ...
+
 # To be more precise on instance_of use some overloads.
 # If there are more than 3 items in the tuple then we fall back to Any
 @overload


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index bee4d70..e1b83ed 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -10,7 +10,7 @@ import pytest
 
 import attr
 
-from attr import fields, has
+from attr import _config, fields, has
 from attr import validators as validator_module
 from attr._compat import PY2, TYPE
 from attr.validators import (
@@ -46,6 +46,66 @@ def zope_interface():
     return zope.interface
 
 
+class TestDisableValidators(object):
+    @pytest.fixture(autouse=True)
+    def reset_default(self):
+        """
+        Make sure validators are always enabled after a test.
+        """
+        yield
+        _config._run_validators = True
+
+    def test_default(self):
+        """
+        Run validators by default.
+        """
+        assert _config._run_validators is True
+
+    @pytest.mark.parametrize("value, expected", [(True, False), (False, True)])
+    def test_set_validators_diabled(self, value, expected):
+        """
+        Sets `_run_validators`.
+        """
+        validator_module.set_disabled(value)
+
+        assert _config._run_validators is expected
+
+    @pytest.mark.parametrize("value, expected", [(True, False), (False, True)])
+    def test_disabled(self, value, expected):
+        """
+        Returns `_run_validators`.
+        """
+        _config._run_validators = value
+
+        assert validator_module.get_disabled() is expected
+
+    def test_disabled_ctx(self):
+        """
+        The `disabled` context manager disables running validators,
+        but only within its context.
+        """
+        assert _config._run_validators is True
+
+        with validator_module.disabled():
+            assert _config._run_validators is False
+
+        assert _config._run_validators is True
+
+    def test_disabled_ctx_with_errors(self):
+        """
+        Running validators is re-enabled even if an error is raised.
+        """
+        assert _config._run_validators is True
+
+        with pytest.raises(ValueError):
+            with validator_module.disabled():
+                assert _config._run_validators is False
+
+                raise ValueError("haha!")
+
+        assert _config._run_validators is True
+
+
 class TestInstanceOf(object):
     """
     Tests for `instance_of`.
diff --git a/tests/typing_example.py b/tests/typing_example.py
index 9d33ca3..af12466 100644
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -183,6 +183,21 @@ class Validated:
     )
 
 
+@attr.define
+class Validated2:
+    num: int = attr.field(validator=attr.validators.ge(0))
+
+
+with attr.validators.disabled():
+    Validated2(num=-1)
+
+try:
+    attr.validators.set_disabled(True)
+    Validated2(num=-1)
+finally:
+    attr.validators.set_disabled(False)
+
+
 # Custom repr()
 @attr.s
 class WithCustomRepr: