Repository: sopel-irc/sopel

Issue:
Sopel should have a BooleanAttribute config type
### The problem
It's really unintuitive to have `ValidatedAttribute` with `bool` as the `parse` parameter, because all that happens is the object silently uses something completely different to parse _and serialize_ its values.

### The solution
Let's add a `BooleanAttribute` type in ~~8.0~~ 7.1 that uses `ConfigParser.getboolean()` instead of bespoke parse/serialize functions.

### Notes
Bonus points if a `ValidatedAttribute` with `parse == bool` gets internally transformed into a `BooleanAttribute` so we can remove the private helpers `_parse_boolean` and `_serialize_boolean` immediately.

Of course the special handling of `parse == bool` will get taken out eventually, but that `if` statement has to stick around anyway so it can emit a deprecation warning until 9.0.


Discussion:

Resolving patch:
diff --git a/sopel/config/types.py b/sopel/config/types.py
--- a/sopel/config/types.py
+++ b/sopel/config/types.py
@@ -31,7 +31,7 @@
 import re
 import sys
 
-from sopel.tools import get_input
+from sopel.tools import deprecated, get_input
 
 if sys.version_info.major >= 3:
     unicode = str
@@ -276,6 +276,19 @@ def _serialize_boolean(value):
     return 'true' if _parse_boolean(value) else 'false'
 
 
+@deprecated(
+    reason='Use BooleanAttribute instead of ValidatedAttribute with parse=bool',
+    version='7.1',
+    removed_in='9.0',
+    stack_frame=-2,
+)
+def _deprecated_special_bool_handling(serialize):
+    if not serialize or serialize == bool:
+        serialize = _serialize_boolean
+
+    return _parse_boolean, serialize
+
+
 class ValidatedAttribute(BaseValidated):
     """A descriptor for settings in a :class:`StaticSection`.
 
@@ -299,10 +312,10 @@ def __init__(self,
                  is_secret=False):
         super(ValidatedAttribute, self).__init__(
             name, default=default, is_secret=is_secret)
+
         if parse == bool:
-            parse = _parse_boolean
-            if not serialize or serialize == bool:
-                serialize = _serialize_boolean
+            parse, serialize = _deprecated_special_bool_handling(serialize)
+
         self.parse = parse or self.parse
         self.serialize = serialize or self.serialize
 
@@ -329,6 +342,82 @@ def configure(self, prompt, default, parent, section_name):
         return super(ValidatedAttribute, self).configure(prompt, default, parent, section_name)
 
 
+class BooleanAttribute(BaseValidated):
+    """A descriptor for Boolean settings in a :class:`StaticSection`.
+
+    :param str name: the attribute name to use in the config file
+    :param bool default: the default value to use if this setting is not
+                         present in the config file
+
+    If the ``default`` value is not specified, it will be ``False``.
+    """
+    def __init__(self, name, default=False):
+        super(BooleanAttribute, self).__init__(
+            name, default=default, is_secret=False)
+
+    def configure(self, prompt, default, parent, section_name):
+        """Parse and return a value from user's input.
+
+        :param str prompt: text to show the user
+        :param bool default: default value used if no input given
+        :param parent: usually the parent Config object
+        :type parent: :class:`~sopel.config.Config`
+        :param str section_name: the name of the containing section
+
+        This method displays the ``prompt`` and waits for user's input on the
+        terminal. If no input is provided (i.e. the user just presses "Enter"),
+        the ``default`` value is returned instead.
+        """
+        prompt = '{} ({})'.format(prompt, 'Y/n' if default else 'y/N')
+        value = get_input(prompt + ' ') or default
+        section = getattr(parent, section_name)
+        return self._parse(value, parent, section)
+
+    def serialize(self, value):
+        """Convert a Boolean value to a string for saving to the config file.
+
+        :param bool value: the value to serialize
+        """
+        return 'true' if self.parse(value) else 'false'
+
+    def parse(self, value):
+        """Parse a limited set of values/objects into Boolean representations.
+
+        :param mixed value: the value to parse
+
+        The literal values ``True`` or ``1`` will be parsed as ``True``. The
+        strings ``'1'``, ``'yes'``, ``'y'``, ``'true'``, ``'enable'``,
+        ``'enabled'``, and ``'on'`` will also be parsed as ``True``,
+        regardless of case. All other values will be parsed as ``False``.
+        """
+        if value is True or value == 1:
+            return True
+        if isinstance(value, basestring):
+            return value.lower() in [
+                '1',
+                'enable',
+                'enabled',
+                'on',
+                'true',
+                'y',
+                'yes',
+            ]
+        return bool(value)
+
+    def _parse(self, value, settings, section):
+        return self.parse(value)
+
+    def __set__(self, instance, value):
+        if value is None:
+            instance._parser.remove_option(instance._section_name, self.name)
+            return
+
+        settings = instance._parent
+        section = getattr(settings, instance._section_name)
+        value = self._serialize(value, settings, section)
+        instance._parser.set(instance._section_name, self.name, value)
+
+
 class SecretAttribute(ValidatedAttribute):
     """A config attribute containing a value which must be kept secret.
 


Test patch:
diff --git a/test/config/test_config_types.py b/test/config/test_config_types.py
--- a/test/config/test_config_types.py
+++ b/test/config/test_config_types.py
@@ -174,6 +174,72 @@ def _fixed_serialized(value):
     assert option.serialize('enabled') == 'fixed value'
 
 
+def test_boolean_attribute():
+    option = types.BooleanAttribute('foo')
+    assert option.name == 'foo'
+    assert option.default is False
+
+
+def test_boolean_parse():
+    option = types.BooleanAttribute('foo')
+    assert option.parse('string') is False
+    assert option.parse('1') is True
+    assert option.parse('') is False
+    assert option.parse(1) is True
+
+    # true-ish values
+    assert option.parse('yes') is True
+    assert option.parse('YES') is True
+    assert option.parse('yES') is True
+    assert option.parse('y') is True
+    assert option.parse('Y') is True
+    assert option.parse('true') is True
+    assert option.parse('True') is True
+    assert option.parse('TRUE') is True
+    assert option.parse('trUE') is True
+    assert option.parse('on') is True
+    assert option.parse('ON') is True
+    assert option.parse('On') is True
+    assert option.parse('enable') is True
+    assert option.parse('enabled') is True
+
+    # everything else
+    assert option.parse('no') is False
+    assert option.parse('off') is False
+    assert option.parse('disable') is False
+    assert option.parse('disabled') is False
+
+
+def test_boolean_serialize():
+    option = types.BooleanAttribute('foo')
+    assert option.serialize(True) == 'true'
+    assert option.serialize('string') == 'false'
+    assert option.serialize('1') == 'true'
+    assert option.serialize('') == 'false'
+    assert option.serialize(1) == 'true'
+
+    # true-ish values
+    assert option.serialize('yes') == 'true'
+    assert option.serialize('YES') == 'true'
+    assert option.serialize('yES') == 'true'
+    assert option.serialize('y') == 'true'
+    assert option.serialize('Y') == 'true'
+    assert option.serialize('true') == 'true'
+    assert option.serialize('True') == 'true'
+    assert option.serialize('TRUE') == 'true'
+    assert option.serialize('trUE') == 'true'
+    assert option.serialize('on') == 'true'
+    assert option.serialize('ON') == 'true'
+    assert option.serialize('On') == 'true'
+    assert option.serialize('enable') == 'true'
+    assert option.serialize('enabled') == 'true'
+
+    # everything else
+    assert option.serialize('no') == 'false'
+    assert option.serialize('disable') == 'false'
+    assert option.serialize('disabled') == 'false'
+
+
 def test_secret_attribute():
     option = types.SecretAttribute('foo')
     assert option.name == 'foo'