From 2eba314177ac97e29f7a4cdf2e4c712842e650fa Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Wed, 7 Jul 2021 15:31:27 -0400 Subject: [PATCH 1/4] Inline distutils.util.strtobool in tests (#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 Co-authored-by: Stefan Scherfke --- docs/api.rst | 22 +++++++++++++++++++++ src/attr/converters.py | 41 ++++++++++++++++++++++++++++++++++++++++ src/attr/converters.pyi | 1 + tests/test_converters.py | 12 +++++------- tests/typing_example.py | 14 ++++++++++++++ 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 3fd71d651..8ffd2dc95 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 "", line 1, in + ValueError: Cannot convert value to bool: foo + + + .. _api_setters: Setters diff --git a/src/attr/converters.py b/src/attr/converters.py index 2777db6d0..013642ca0 100644 --- 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` + + Raise :exc:`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(f"Cannot convert value to bool: {val}") diff --git a/src/attr/converters.pyi b/src/attr/converters.pyi index d180e4646..0f58088a3 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: ... diff --git a/tests/test_converters.py b/tests/test_converters.py index f86e07e29..e6e5a3e95 100644 --- 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,8 @@ 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 diff --git a/tests/typing_example.py b/tests/typing_example.py index 2edbce216..9d33ca3f2 100644 --- 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: From bc540b5f1a5b2e84ef6fe40dd68c47613d64d628 Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Wed, 7 Jul 2021 16:17:46 -0400 Subject: [PATCH 2/4] Use :raises: directive in docstring --- src/attr/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/converters.py b/src/attr/converters.py index 013642ca0..464177aa4 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -133,7 +133,7 @@ def to_bool(val): - :code:`"0"` - :code:`0` - Raise :exc:`ValueError` for any other value. + :raises ValueError: for any other value. .. versionadded:: 21.3.0 """ From a85ab29daa5ebf7cf1f82e66ce3503d778f8c72c Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Mon, 12 Jul 2021 16:42:07 -0400 Subject: [PATCH 3/4] Remove f-strings for Py2.7 and 3.5 support --- src/attr/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/converters.py b/src/attr/converters.py index 464177aa4..366b8728a 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -149,4 +149,4 @@ def to_bool(val): except TypeError: # Raised when "val" is not hashable (e.g., lists) pass - raise ValueError(f"Cannot convert value to bool: {val}") + raise ValueError("Cannot convert value to bool: {}".format(val)) From a7b86a1c29a6877d99f17fba5c515905ed1e458e Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Fri, 6 Aug 2021 09:45:06 -0400 Subject: [PATCH 4/4] Add to_bool tests --- tests/test_converters.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_converters.py b/tests/test_converters.py index e6e5a3e95..82c62005a 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -134,3 +134,28 @@ class C(object): 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")