Repository: python-attrs/attrs (python)

Issue:
Python 3.10 deprecation in tests due to distutils 
The function is simple enough to be vendored for tests.

```
tests/test_converters.py:7
  /root/checked_repos/attrs/tests/test_converters.py:7: DeprecationWarning: The distutils package is deprecated and slated for removal in Python 3.12. Use setuptools or check PEP 632 for potential alternatives
    from distutils.util import strtobool

-- Docs: https://docs.pytest.org/en/stable/warnings.html
```

https://github.com/python-attrs/attrs/blob/d0cff8face92478c950ba05a7d9403b1e342407a/tests/test_converters.py#L7

Pull request:
Inline distutils.util.strtobool in tests (closes #813)
`distutils` is deprecated in Python 3.10 and slated for removal in Python 3.12. Fortunately, `attrs` only uses `distutils` once and it's trivial to remove.

As suggested by @sscherfke, add the `to_bool` converter to `converters.py`.

Closes #813

# 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.
- [x] 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!


Patch:
diff --git a/docs/api.rst b/docs/api.rst
index 3fd71d6..8ffd2dc 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -551,6 +551,28 @@ Converters
       C(x='')
 
 
+.. autofunction:: attr.converters.to_bool
+
+   For example:
+
+   .. doctest::
+
+      >>> @attr.s
+      ... class C(object):
+      ...     x = attr.ib(
+      ...         converter=attr.converters.to_bool
+      ...     )
+      >>> C("yes")
+      C(x=True)
+      >>> C(0)
+      C(x=False)
+      >>> C("foo")
+      Traceback (most recent call last):
+         File "<stdin>", line 1, in <module>
+      ValueError: Cannot convert value to bool: foo
+
+
+
 .. _api_setters:
 
 Setters
diff --git a/src/attr/converters.py b/src/attr/converters.py
index 2777db6..366b872 100644
--- a/src/attr/converters.py
+++ b/src/attr/converters.py
@@ -109,3 +109,44 @@ def default_if_none(default=NOTHING, factory=None):
             return default
 
     return default_if_none_converter
+
+
+def to_bool(val):
+    """
+    Convert "boolean" strings (e.g., from env. vars.) to real booleans.
+
+    Values mapping to :code:`True`:
+
+    - :code:`True`
+    - :code:`"true"` / :code:`"t"`
+    - :code:`"yes"` / :code:`"y"`
+    - :code:`"on"`
+    - :code:`"1"`
+    - :code:`1`
+
+    Values mapping to :code:`False`:
+
+    - :code:`False`
+    - :code:`"false"` / :code:`"f"`
+    - :code:`"no"` / :code:`"n"`
+    - :code:`"off"`
+    - :code:`"0"`
+    - :code:`0`
+
+    :raises ValueError: for any other value.
+
+    .. versionadded:: 21.3.0
+    """
+    if isinstance(val, str):
+        val = val.lower()
+    truthy = {True, "true", "t", "yes", "y", "on", "1", 1}
+    falsy = {False, "false", "f", "no", "n", "off", "0", 0}
+    try:
+        if val in truthy:
+            return True
+        if val in falsy:
+            return False
+    except TypeError:
+        # Raised when "val" is not hashable (e.g., lists)
+        pass
+    raise ValueError("Cannot convert value to bool: {}".format(val))
diff --git a/src/attr/converters.pyi b/src/attr/converters.pyi
index d180e46..0f58088 100644
--- a/src/attr/converters.pyi
+++ b/src/attr/converters.pyi
@@ -10,3 +10,4 @@ def optional(converter: _ConverterType) -> _ConverterType: ...
 def default_if_none(default: _T) -> _ConverterType: ...
 @overload
 def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ...
+def to_bool(val: str) -> bool: ...


Test patch:
diff --git a/tests/test_converters.py b/tests/test_converters.py
index f86e07e..82c6200 100644
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -4,14 +4,12 @@ Tests for `attr.converters`.
 
 from __future__ import absolute_import
 
-from distutils.util import strtobool
-
 import pytest
 
 import attr
 
 from attr import Factory, attrib
-from attr.converters import default_if_none, optional, pipe
+from attr.converters import default_if_none, optional, pipe, to_bool
 
 
 class TestOptional(object):
@@ -106,7 +104,7 @@ class TestPipe(object):
         """
         Succeeds if all wrapped converters succeed.
         """
-        c = pipe(str, strtobool, bool)
+        c = pipe(str, to_bool, bool)
 
         assert True is c("True") is c(True)
 
@@ -114,7 +112,7 @@ class TestPipe(object):
         """
         Fails if any wrapped converter fails.
         """
-        c = pipe(str, strtobool)
+        c = pipe(str, to_bool)
 
         # First wrapped converter fails:
         with pytest.raises(ValueError):
@@ -131,8 +129,33 @@ class TestPipe(object):
 
         @attr.s
         class C(object):
-            a1 = attrib(default="True", converter=pipe(str, strtobool, bool))
-            a2 = attrib(default=True, converter=[str, strtobool, bool])
+            a1 = attrib(default="True", converter=pipe(str, to_bool, bool))
+            a2 = attrib(default=True, converter=[str, to_bool, bool])
 
         c = C()
         assert True is c.a1 is c.a2
+
+
+class TestToBool(object):
+    def test_unhashable(self):
+        """
+        Fails if value is unhashable.
+        """
+        with pytest.raises(ValueError, match="Cannot convert value to bool"):
+            to_bool([])
+
+    def test_truthy(self):
+        """
+        Fails if truthy values are incorrectly converted.
+        """
+        assert to_bool("t")
+        assert to_bool("yes")
+        assert to_bool("on")
+
+    def test_falsy(self):
+        """
+        Fails if falsy values are incorrectly converted.
+        """
+        assert not to_bool("f")
+        assert not to_bool("no")
+        assert not to_bool("off")
diff --git a/tests/typing_example.py b/tests/typing_example.py
index 2edbce2..9d33ca3 100644
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -118,6 +118,20 @@ except Error as e:
 # ConvCDefaultIfNone(None)
 
 
+# @attr.s
+# class ConvCToBool:
+#     x: int = attr.ib(converter=attr.converters.to_bool)
+
+
+# ConvCToBool(1)
+# ConvCToBool(True)
+# ConvCToBool("on")
+# ConvCToBool("yes")
+# ConvCToBool(0)
+# ConvCToBool(False)
+# ConvCToBool("n")
+
+
 # Validators
 @attr.s
 class Validated: