Repository: python-attrs/attrs

Problem statement:
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
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


Hints:
I‘d add this as
```python
def to_bool(val: Any) -> bool:
    """
    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`

    Raise :exc:`ValueError` for any other value.
    """
    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(f"Cannot convert value to bool: {val}")
```
to `converters.py`.  It could then also be used as field converter. 

This function is already part of typed-settings and I intended to eventually move it to attrs.

@hynek, what do you think?
SGTM but remember to put the annotations into the stub file. :)
I‘d add this as
```python
def to_bool(val: Any) -> bool:
    """
    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`

    Raise :exc:`ValueError` for any other value.
    """
    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(f"Cannot convert value to bool: {val}")
```
to `converters.py`.  It could then also be used as field converter. 

This function is already part of typed-settings and I intended to eventually move it to attrs.

@hynek, what do you think?
SGTM but remember to put the annotations into the stub file. :)

Patch:
diff --git a/src/attr/converters.py b/src/attr/converters.py
--- a/src/attr/converters.py
+++ b/src/attr/converters.py
@@ -109,3 +109,44 @@ def default_if_none_converter(val):
             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))


Test patch:
diff --git a/tests/test_converters.py b/tests/test_converters.py
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -4,14 +4,12 @@
 
 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 @@ def test_success(self):
         """
         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 @@ def test_fail(self):
         """
         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 @@ def test_sugar(self):
 
         @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
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -118,6 +118,20 @@ class Error(Exception):
 # 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: