Skip to content

Commit e84b57e

Browse files
9999yearssscherfkehynek
authored
Inline distutils.util.strtobool in tests (closes #813) (#830)
* 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 <[email protected]> * Use :raises: directive in docstring * Remove f-strings for Py2.7 and 3.5 support * Add to_bool tests Co-authored-by: Stefan Scherfke <[email protected]> Co-authored-by: Hynek Schlawack <[email protected]>
1 parent 2ca7aad commit e84b57e

File tree

5 files changed

+108
-7
lines changed

5 files changed

+108
-7
lines changed

docs/api.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,28 @@ Converters
551551
C(x='')
552552

553553

554+
.. autofunction:: attr.converters.to_bool
555+
556+
For example:
557+
558+
.. doctest::
559+
560+
>>> @attr.s
561+
... class C(object):
562+
... x = attr.ib(
563+
... converter=attr.converters.to_bool
564+
... )
565+
>>> C("yes")
566+
C(x=True)
567+
>>> C(0)
568+
C(x=False)
569+
>>> C("foo")
570+
Traceback (most recent call last):
571+
File "<stdin>", line 1, in <module>
572+
ValueError: Cannot convert value to bool: foo
573+
574+
575+
554576
.. _api_setters:
555577

556578
Setters

src/attr/converters.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,44 @@ def default_if_none_converter(val):
109109
return default
110110

111111
return default_if_none_converter
112+
113+
114+
def to_bool(val):
115+
"""
116+
Convert "boolean" strings (e.g., from env. vars.) to real booleans.
117+
118+
Values mapping to :code:`True`:
119+
120+
- :code:`True`
121+
- :code:`"true"` / :code:`"t"`
122+
- :code:`"yes"` / :code:`"y"`
123+
- :code:`"on"`
124+
- :code:`"1"`
125+
- :code:`1`
126+
127+
Values mapping to :code:`False`:
128+
129+
- :code:`False`
130+
- :code:`"false"` / :code:`"f"`
131+
- :code:`"no"` / :code:`"n"`
132+
- :code:`"off"`
133+
- :code:`"0"`
134+
- :code:`0`
135+
136+
:raises ValueError: for any other value.
137+
138+
.. versionadded:: 21.3.0
139+
"""
140+
if isinstance(val, str):
141+
val = val.lower()
142+
truthy = {True, "true", "t", "yes", "y", "on", "1", 1}
143+
falsy = {False, "false", "f", "no", "n", "off", "0", 0}
144+
try:
145+
if val in truthy:
146+
return True
147+
if val in falsy:
148+
return False
149+
except TypeError:
150+
# Raised when "val" is not hashable (e.g., lists)
151+
pass
152+
raise ValueError("Cannot convert value to bool: {}".format(val))

src/attr/converters.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ def optional(converter: _ConverterType) -> _ConverterType: ...
1010
def default_if_none(default: _T) -> _ConverterType: ...
1111
@overload
1212
def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ...
13+
def to_bool(val: str) -> bool: ...

tests/test_converters.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@
44

55
from __future__ import absolute_import
66

7-
from distutils.util import strtobool
8-
97
import pytest
108

119
import attr
1210

1311
from attr import Factory, attrib
14-
from attr.converters import default_if_none, optional, pipe
12+
from attr.converters import default_if_none, optional, pipe, to_bool
1513

1614

1715
class TestOptional(object):
@@ -106,15 +104,15 @@ def test_success(self):
106104
"""
107105
Succeeds if all wrapped converters succeed.
108106
"""
109-
c = pipe(str, strtobool, bool)
107+
c = pipe(str, to_bool, bool)
110108

111109
assert True is c("True") is c(True)
112110

113111
def test_fail(self):
114112
"""
115113
Fails if any wrapped converter fails.
116114
"""
117-
c = pipe(str, strtobool)
115+
c = pipe(str, to_bool)
118116

119117
# First wrapped converter fails:
120118
with pytest.raises(ValueError):
@@ -131,8 +129,33 @@ def test_sugar(self):
131129

132130
@attr.s
133131
class C(object):
134-
a1 = attrib(default="True", converter=pipe(str, strtobool, bool))
135-
a2 = attrib(default=True, converter=[str, strtobool, bool])
132+
a1 = attrib(default="True", converter=pipe(str, to_bool, bool))
133+
a2 = attrib(default=True, converter=[str, to_bool, bool])
136134

137135
c = C()
138136
assert True is c.a1 is c.a2
137+
138+
139+
class TestToBool(object):
140+
def test_unhashable(self):
141+
"""
142+
Fails if value is unhashable.
143+
"""
144+
with pytest.raises(ValueError, match="Cannot convert value to bool"):
145+
to_bool([])
146+
147+
def test_truthy(self):
148+
"""
149+
Fails if truthy values are incorrectly converted.
150+
"""
151+
assert to_bool("t")
152+
assert to_bool("yes")
153+
assert to_bool("on")
154+
155+
def test_falsy(self):
156+
"""
157+
Fails if falsy values are incorrectly converted.
158+
"""
159+
assert not to_bool("f")
160+
assert not to_bool("no")
161+
assert not to_bool("off")

tests/typing_example.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ class Error(Exception):
118118
# ConvCDefaultIfNone(None)
119119

120120

121+
# @attr.s
122+
# class ConvCToBool:
123+
# x: int = attr.ib(converter=attr.converters.to_bool)
124+
125+
126+
# ConvCToBool(1)
127+
# ConvCToBool(True)
128+
# ConvCToBool("on")
129+
# ConvCToBool("yes")
130+
# ConvCToBool(0)
131+
# ConvCToBool(False)
132+
# ConvCToBool("n")
133+
134+
121135
# Validators
122136
@attr.s
123137
class Validated:

0 commit comments

Comments
 (0)