Repository: sopel-irc/sopel

Issue:
admin: .set cannot clear values
The `.set` command of `admin.py` does not work as described.

https://github.com/sopel-irc/sopel/blob/824ae7484b9e04310079a27d4d6ec40edeafdfd1/sopel/modules/admin.py#L252-L263

says that `if value is None, the option will be deleted`. Currently, a `None` value does not get parsed properly, and the setting just ends up getting converted to a literal `None` string, as opposed to an actual `NoneType`.

I considered that the sentence meant that no value should be supplied, but that would preclude the *See* part of the `See and modify values of Sopel's config object`, where no argument should be provided for setting (which is working as expected). Thus, :bug:

---

Steps to replicate:
```python
# admin_set_test.py

from __future__ import unicode_literals, absolute_import, division, print_function

from sopel.config.types import StaticSection, ValidatedAttribute
import sopel.module


class AdminSetTestSection(StaticSection):
    test_setting = ValidatedAttribute('test_setting', default=None)


def setup(bot):
    bot.config.define_section('admin_set_test', AdminSetTestSection)


@sopel.module.require_privmsg
@sopel.module.commands('ast-show')
def show(bot, trigger):
    bot.say('repr: "{}", type: "{}"'.format(
        repr(bot.config.admin_set_test.test_setting),
        type(bot.config.admin_set_test.test_setting)
    ))
    
```

```
# my-config.cfg
## Note: there is no `test_setting` set, so it defaults to `None` (`NoneType`)

[admin_set_test]

```

```
# _See_ current setting value
<HumorBaby>  .set admin_set_test.test_setting
<testbot>  HumorBaby: admin_set_test.test_setting = None

# See repr and type of setting
<HumorBaby>  .ast-show
<testbot>  repr: "None", type: "<type 'NoneType'>"

# Set a random value to setting
<HumorBaby>  .set admin_set_test.test_setting abc123

# Confirm change
<HumorBaby>  .set admin_set_test.test_setting
<testbot>  HumorBaby: admin_set_test.test_setting = abc123
<HumorBaby>  .ast-show
<testbot>  repr: "u'abc123'", type: "<type 'unicode'>"

# Try to "delete"/unset setting
<HumorBaby>  .set admin_set_test.test_setting None

# Confirm change
<HumorBaby>  .set admin_set_test.test_setting
<testbot>  HumorBaby: admin_set_test.test_setting = None
# everything looks okay...

# But wait... wat?
<HumorBaby>  .ast-show
<testbot>  repr: "u'None'", type: "<type 'unicode'>"
```

So, I wondered if `.save`'ing the config would somehow solve the issue...
```
<HumorBaby>  .save

# Restart bot here...

<HumorBaby>  .set admin_set_test.test_setting
<testbot>  HumorBaby: admin_set_test.test_setting = None
<HumorBaby>  .ast-show
<testbot>  repr: "'None'", type: "<type 'str'>"
```
Nope...


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
@@ -156,6 +156,8 @@ def __get__(self, instance, owner=None):
 
     def __set__(self, instance, value):
         if value is None:
+            if self.default == NO_DEFAULT:
+                raise ValueError('Cannot unset an option with a required value.')
             instance._parser.remove_option(instance._section_name, self.name)
             return
         value = self.serialize(value)
diff --git a/sopel/modules/admin.py b/sopel/modules/admin.py
--- a/sopel/modules/admin.py
+++ b/sopel/modules/admin.py
@@ -44,6 +44,19 @@ def setup(bot):
     bot.config.define_section('admin', AdminSection)
 
 
+class InvalidSection(Exception):
+    def __init__(self, section):
+        super(InvalidSection, self).__init__(self, 'Section [{}] does not exist.'.format(section))
+        self.section = section
+
+
+class InvalidSectionOption(Exception):
+    def __init__(self, section, option):
+        super(InvalidSectionOption, self).__init__(self, 'Section [{}] does not have option \'{}\'.'.format(section, option))
+        self.section = section
+        self.option = option
+
+
 def _get_config_channels(channels):
     """List"""
     for channel_info in channels:
@@ -245,57 +258,83 @@ def mode(bot, trigger):
     bot.write(('MODE', bot.nick + ' ' + mode))
 
 
-@sopel.module.require_privmsg("This command only works as a private message.")
-@sopel.module.require_admin("This command requires admin privileges.")
-@sopel.module.commands('set')
-@sopel.module.example('.set core.owner Me')
-def set_config(bot, trigger):
-    """See and modify values of Sopel's config object.
+def parse_section_option_value(config, trigger):
+    """Parse trigger for set/unset to get relevant config elements.
 
-    Trigger args:
-        arg1 - section and option, in the form "section.option"
-        arg2 - value
+    :param config: Sopel's config
+    :param trigger: IRC line trigger
+    :return: A tuple with ``(section, section_name, static_sec, option, value)``
+    :raises InvalidSection: section does not exist
+    :raises InvalidSectionOption: option does not exist for section
 
-    If there is no section, section will default to "core".
-    If value is None, the option will be deleted.
+    The ``value`` is optional and can be returned as ``None`` if omitted from command.
     """
-    # Get section and option from first argument.
     match = trigger.group(3)
     if match is None:
-        bot.reply("Usage: .set section.option value")
-        return
+        raise ValueError  # Invalid command
+
+    # Get section and option from first argument.
     arg1 = match.split('.')
     if len(arg1) == 1:
         section_name, option = "core", arg1[0]
     elif len(arg1) == 2:
         section_name, option = arg1
     else:
-        bot.reply("Usage: .set section.option value")
-        return
-    section = getattr(bot.config, section_name)
+        raise ValueError  # invalid command format
+
+    section = getattr(config, section_name, False)
+    if not section:
+        raise InvalidSection(section_name)
     static_sec = isinstance(section, StaticSection)
 
     if static_sec and not hasattr(section, option):
-        bot.say('[{}] section has no option {}.'.format(section_name, option))
-        return
+        raise InvalidSectionOption(section_name, option)  # Option not found in section
+
+    if not static_sec and not config.parser.has_option(section_name, option):
+        raise InvalidSectionOption(section_name, option)  # Option not found in section
 
     delim = trigger.group(2).find(' ')
     # Skip preceding whitespaces, if any.
     while delim > 0 and delim < len(trigger.group(2)) and trigger.group(2)[delim] == ' ':
         delim = delim + 1
 
-    # Display current value if no value is given.
+    value = trigger.group(2)[delim:]
     if delim == -1 or delim == len(trigger.group(2)):
-        if not static_sec and bot.config.parser.has_option(section, option):
-            bot.reply("Option %s.%s does not exist." % (section_name, option))
-            return
-        # Except if the option looks like a password. Censor those to stop them
-        # from being put on log files.
+        value = None
+
+    return (section, section_name, static_sec, option, value)
+
+
+@sopel.module.require_privmsg("This command only works as a private message.")
+@sopel.module.require_admin("This command requires admin privileges.")
+@sopel.module.commands('set')
+@sopel.module.example('.set core.owner Me')
+def set_config(bot, trigger):
+    """See and modify values of Sopel's config object.
+
+    Trigger args:
+        arg1 - section and option, in the form "section.option"
+        arg2 - value
+
+    If there is no section, section will default to "core".
+    If value is not provided, the current value will be displayed.
+    """
+    try:
+        section, section_name, static_sec, option, value = parse_section_option_value(bot.config, trigger)
+    except ValueError:
+        bot.reply('Usage: {}set section.option [value]'.format(bot.config.core.help_prefix))
+        return
+    except (InvalidSection, InvalidSectionOption) as exc:
+        bot.say(exc.args[1])
+        return
+
+    # Display current value if no value is given
+    if not value:
         if option.endswith("password") or option.endswith("pass"):
             value = "(password censored)"
         else:
             value = getattr(section, option)
-        bot.reply("%s.%s = %s" % (section_name, option, value))
+        bot.reply("%s.%s = %s (%s)" % (section_name, option, value, type(value).__name__))
         return
 
     # Owner-related settings cannot be modified interactively. Any changes to these
@@ -305,8 +344,7 @@ def set_config(bot, trigger):
                 .format(section_name, option))
         return
 
-    # Otherwise, set the value to one given as argument 2.
-    value = trigger.group(2)[delim:]
+    # Otherwise, set the value to one given
     if static_sec:
         descriptor = getattr(section.__class__, option)
         try:
@@ -318,6 +356,42 @@ def set_config(bot, trigger):
             bot.say("Can't set attribute: " + str(exc))
             return
     setattr(section, option, value)
+    bot.say("OK. Set '{}.{}' successfully.".format(section_name, option))
+
+
+@sopel.module.require_privmsg("This command only works as a private message.")
+@sopel.module.require_admin("This command requires admin privileges.")
+@sopel.module.commands('unset')
+@sopel.module.example('.unset core.owner')
+def unset_config(bot, trigger):
+    """Unset value of Sopel's config object.
+
+    Unsetting a value will reset it to the default specified in the config
+    definition.
+
+    Trigger args:
+        arg1 - section and option, in the form "section.option"
+
+    If there is no section, section will default to "core".
+    """
+    try:
+        section, section_name, static_sec, option, value = parse_section_option_value(bot.config, trigger)
+    except ValueError:
+        bot.reply('Usage: {}unset section.option [value]'.format(bot.config.core.help_prefix))
+        return
+    except (InvalidSection, InvalidSectionOption) as exc:
+        bot.say(exc.args[1])
+        return
+
+    if value:
+        bot.reply('Invalid command; no value should be provided to unset.')
+        return
+
+    try:
+        setattr(section, option, None)
+        bot.say("OK. Unset '{}.{}' successfully.".format(section_name, option))
+    except ValueError:
+        bot.reply('Cannot unset {}.{}; it is a required option.'.format(section_name, option))
 
 
 @sopel.module.require_privmsg


Test patch: