Repository: sopel-irc/sopel

Issue:
Nickname commands have wrong capture groups if alias_nicks set
After merging #1052, I discovered a problem with nickname commands. Reloading modules, for example, using the updated `reload.py` from #1053 was reloading `module 'reload'`, always.

The added regex capture group containing the bot's nick and configured aliases means nickname commands get an extra group compared to regular commands, **only if `core.alias_nicks` is set:**

https://github.com/sopel-irc/sopel/blob/a9d04a4899f6a1d8046d11d1b442f9b9e6e73f61/sopel/loader.py#L118-L124

It's easy to fix—just make that group non-capturing and Bob'll be my uncle—but it troubles me that it seems Sopel's test suite cannot currently catch this bug. Even though I missed the addition of a new capture group, the tests should have been able to catch it.


Discussion:

Resolving patch:
diff --git a/sopel/loader.py b/sopel/loader.py
--- a/sopel/loader.py
+++ b/sopel/loader.py
@@ -6,14 +6,11 @@
 import re
 import sys
 
-from sopel.tools import itervalues, get_command_regexp
+from sopel.tools import compile_rule, itervalues, get_command_regexp, get_nickname_command_regexp
 
 if sys.version_info.major >= 3:
     basestring = (str, bytes)
 
-# Can be implementation-dependent
-_regex_type = type(re.compile(''))
-
 
 def get_module_description(path):
     good_file = (os.path.isfile(path) and
@@ -110,27 +107,6 @@ def enumerate_modules(config, show_all=False):
     return modules
 
 
-def compile_rule(nick, pattern, alias_nicks):
-    # Not sure why this happens on reloads, but it shouldn't cause problems…
-    if isinstance(pattern, _regex_type):
-        return pattern
-
-    if alias_nicks:
-        nicks = alias_nicks.copy()
-        nicks.append(nick)
-        nicks = map(re.escape, nicks)
-        nick = '(%s)' % '|'.join(nicks)
-    else:
-        nick = re.escape(nick)
-
-    pattern = pattern.replace('$nickname', nick)
-    pattern = pattern.replace('$nick', r'{}[,:]\s+'.format(nick))
-    flags = re.IGNORECASE
-    if '\n' in pattern:
-        flags |= re.VERBOSE
-    return re.compile(pattern, flags)
-
-
 def trim_docstring(doc):
     """Get the docstring as a series of lines that can be sent"""
     if not doc:
@@ -183,18 +159,24 @@ def clean_callable(func, config):
             func.rule = [func.rule]
         func.rule = [compile_rule(nick, rule, alias_nicks) for rule in func.rule]
 
-    if hasattr(func, 'commands'):
+    if hasattr(func, 'commands') or hasattr(func, 'nickname_commands'):
         func.rule = getattr(func, 'rule', [])
-        for command in func.commands:
+        for command in getattr(func, 'commands', []):
             regexp = get_command_regexp(prefix, command)
             func.rule.append(regexp)
+        for command in getattr(func, 'nickname_commands', []):
+            regexp = get_nickname_command_regexp(nick, command, alias_nicks)
+            func.rule.append(regexp)
         if hasattr(func, 'example'):
             example = func.example[0]["example"]
             example = example.replace('$nickname', nick)
             if example[0] != help_prefix and not example.startswith(nick):
                 example = help_prefix + example[len(help_prefix):]
         if doc or example:
-            for command in func.commands:
+            cmds = []
+            cmds.extend(getattr(func, 'commands', []))
+            cmds.extend(getattr(func, 'nickname_commands', []))
+            for command in cmds:
                 func._docs[command] = (doc, example)
 
     if hasattr(func, 'intents'):
@@ -214,7 +196,7 @@ def load_module(name, path, type_):
 
 
 def is_triggerable(obj):
-    return any(hasattr(obj, attr) for attr in ('rule', 'intents', 'commands'))
+    return any(hasattr(obj, attr) for attr in ('rule', 'intents', 'commands', 'nickname_commands'))
 
 
 def clean_module(module, config):
diff --git a/sopel/module.py b/sopel/module.py
--- a/sopel/module.py
+++ b/sopel/module.py
@@ -171,31 +171,13 @@ def nickname_commands(*command_list):
             "$nickname hello! p1 p2 p3 p4 p5 p6 p7 p8 p9".
         @nickname_commands(".*"):
             Would trigger on anything starting with "$nickname[:,]? ", and
-            would have never have any additional parameters, as the command
-            would match the rest of the line.
+            would never have any additional parameters, as the command would
+            match the rest of the line.
 
     """
     def add_attribute(function):
-        if not hasattr(function, "rule"):
-            function.rule = []
-        rule = r"""
-        ^
-        $nickname[:,]? # Nickname.
-        \s+({command}) # Command as group 1.
-        (?:\s+         # Whitespace to end command.
-        (              # Rest of the line as group 2.
-        (?:(\S+))?     # Parameters 1-4 as groups 3-6.
-        (?:\s+(\S+))?
-        (?:\s+(\S+))?
-        (?:\s+(\S+))?
-        .*             # Accept anything after the parameters. Leave it up to
-                       # the module to parse the line.
-        ))?            # Group 1 must be None, if there are no parameters.
-        $              # EoL, so there are no partial matches.
-        """.format(command='|'.join(command_list))
-        function.rule.append(rule)
+        function.nickname_commands = [cmd for cmd in command_list]
         return function
-
     return add_attribute
 
 
diff --git a/sopel/tools/__init__.py b/sopel/tools/__init__.py
--- a/sopel/tools/__init__.py
+++ b/sopel/tools/__init__.py
@@ -37,6 +37,9 @@
 
 _channel_prefixes = ('#', '&', '+', '!')
 
+# Can be implementation-dependent
+_regex_type = type(re.compile(''))
+
 
 def get_input(prompt):
     """Get decoded input from the terminal (equivalent to python 3's ``input``).
@@ -61,6 +64,31 @@ def get_raising_file_and_line(tb=None):
     return filename, lineno
 
 
+def compile_rule(nick, pattern, alias_nicks):
+    """
+    Return a compiled rule regex, replacing placeholders for ``$nick`` and
+    ``$nickname`` with the values defined in the bot's config at startup.
+    """
+    # Not sure why this happens on reloads, but it shouldn't cause problems…
+    if isinstance(pattern, _regex_type):
+        return pattern
+
+    if alias_nicks:
+        nicks = list(alias_nicks)  # alias_nicks.copy() doesn't work in py2
+        nicks.append(nick)
+        nicks = map(re.escape, nicks)
+        nick = '(?:%s)' % '|'.join(nicks)
+    else:
+        nick = re.escape(nick)
+
+    pattern = pattern.replace('$nickname', nick)
+    pattern = pattern.replace('$nick', r'{}[,:]\s+'.format(nick))
+    flags = re.IGNORECASE
+    if '\n' in pattern:
+        flags |= re.VERBOSE
+    return re.compile(pattern, flags)
+
+
 def get_command_regexp(prefix, command):
     """Return a compiled regexp object that implements the command."""
     # Escape all whitespace with a single backslash. This ensures that regexp
@@ -68,11 +96,17 @@ def get_command_regexp(prefix, command):
     # to use the verbose syntax.
     prefix = re.sub(r"(\s)", r"\\\1", prefix)
 
-    # This regexp match equivalently and produce the same
+    pattern = get_command_pattern(prefix, command)
+    return re.compile(pattern, re.IGNORECASE | re.VERBOSE)
+
+
+def get_command_pattern(prefix, command):
+    """Return the uncompiled regex pattern for standard commands."""
+    # This regexp matches equivalently and produces the same
     # groups 1 and 2 as the old regexp: r'^%s(%s)(?: +(.*))?$'
     # The only differences should be handling all whitespace
     # like spaces and the addition of groups 3-6.
-    pattern = r"""
+    return r"""
         (?:{prefix})({command}) # Command as group 1.
         (?:\s+              # Whitespace to end command.
         (                   # Rest of the line as group 2.
@@ -87,7 +121,35 @@ def get_command_regexp(prefix, command):
                             # parameters.
         $                   # EoL, so there are no partial matches.
         """.format(prefix=prefix, command=command)
-    return re.compile(pattern, re.IGNORECASE | re.VERBOSE)
+
+
+def get_nickname_command_regexp(nick, command, alias_nicks):
+    """Return a compiled regexp object that implements the nickname command."""
+    if isinstance(alias_nicks, unicode):
+        alias_nicks = [alias_nicks]
+    elif not isinstance(alias_nicks, list):
+        raise ValueError('A list or string is required.')
+
+    return compile_rule(nick, get_nickname_command_pattern(command), alias_nicks)
+
+
+def get_nickname_command_pattern(command):
+    """Return the uncompiled regex pattern for nickname commands."""
+    return r"""
+        ^
+        $nickname[:,]? # Nickname.
+        \s+({command}) # Command as group 1.
+        (?:\s+         # Whitespace to end command.
+        (              # Rest of the line as group 2.
+        (?:(\S+))?     # Parameters 1-4 as groups 3-6.
+        (?:\s+(\S+))?
+        (?:\s+(\S+))?
+        (?:\s+(\S+))?
+        .*             # Accept anything after the parameters. Leave it up to
+                       # the module to parse the line.
+        ))?            # Group 1 must be None, if there are no parameters.
+        $              # EoL, so there are no partial matches.
+        """.format(command=command)
 
 
 def deprecated(old):


Test patch:
diff --git a/test/test_commands.py b/test/test_commands.py
new file mode 100644
--- /dev/null
+++ b/test/test_commands.py
@@ -0,0 +1,90 @@
+# coding=utf-8
+"""Tests for command handling"""
+from __future__ import unicode_literals, absolute_import, print_function, division
+
+import pytest
+import re
+
+from sopel.tools import get_command_regexp, get_nickname_command_regexp
+
+
+@pytest.fixture
+def nick():
+    return 'Sopel'
+
+
+@pytest.fixture
+def alias_nicks():
+    return ['Soap', 'Pie']
+
+
+@pytest.fixture
+def prefix():
+    return '.'
+
+
+@pytest.fixture
+def prefix_regex():
+    re.escape(prefix())
+
+
+@pytest.fixture
+def command():
+    return 'testcmd'
+
+
+@pytest.fixture
+def groups(command):
+    return {
+        3: "three",
+        4: "four",
+        5: "five",
+        6: "six",
+    }
+
+
+@pytest.fixture
+def command_line(prefix, command, groups):
+    return "{}{} {}".format(prefix, command, ' '.join(groups.values()))
+
+
+@pytest.fixture
+def nickname_command_line(nick, command, groups):
+    return "{}: {} {}".format(nick, command, ' '.join(groups.values()))
+
+
+def test_command_groups(prefix, command, groups, command_line):
+    regex = get_command_regexp(prefix, command)
+    match = re.match(regex, command_line)
+    assert match.group(0) == command_line
+    assert match.group(1) == command
+    assert match.group(2) == ' '.join(groups.values())
+    assert match.group(3) == groups[3]
+    assert match.group(4) == groups[4]
+    assert match.group(5) == groups[5]
+    assert match.group(6) == groups[6]
+
+
+def test_nickname_command_groups(command, nick, groups, nickname_command_line):
+    regex = get_nickname_command_regexp(nick, command, [])
+    match = re.match(regex, nickname_command_line)
+    assert match.group(0) == nickname_command_line
+    assert match.group(1) == command
+    assert match.group(2) == ' '.join(groups.values())
+    assert match.group(3) == groups[3]
+    assert match.group(4) == groups[4]
+    assert match.group(5) == groups[5]
+    assert match.group(6) == groups[6]
+
+
+def test_nickname_command_aliased(command, nick, alias_nicks, groups, nickname_command_line):
+    aliased_command_line = nickname_command_line.replace(nick, alias_nicks[0])
+    regex = get_nickname_command_regexp(nick, command, alias_nicks)
+    match = re.match(regex, aliased_command_line)
+    assert match.group(0) == aliased_command_line
+    assert match.group(1) == command
+    assert match.group(2) == ' '.join(groups.values())
+    assert match.group(3) == groups[3]
+    assert match.group(4) == groups[4]
+    assert match.group(5) == groups[5]
+    assert match.group(6) == groups[6]
diff --git a/test/test_module.py b/test/test_module.py
--- a/test/test_module.py
+++ b/test/test_module.py
@@ -89,25 +89,11 @@ def mock(bot, trigger, match):
     assert mock.commands == ['sopel']
 
 
-def test_nick_commands():
+def test_nickname_commands():
     @module.nickname_commands('sopel')
     def mock(bot, trigger, match):
         return True
-    assert mock.rule == ["""
-        ^
-        $nickname[:,]? # Nickname.
-        \s+(sopel) # Command as group 1.
-        (?:\s+         # Whitespace to end command.
-        (              # Rest of the line as group 2.
-        (?:(\S+))?     # Parameters 1-4 as groups 3-6.
-        (?:\s+(\S+))?
-        (?:\s+(\S+))?
-        (?:\s+(\S+))?
-        .*             # Accept anything after the parameters. Leave it up to
-                       # the module to parse the line.
-        ))?            # Group 1 must be None, if there are no parameters.
-        $              # EoL, so there are no partial matches.
-        """]
+    assert mock.nickname_commands == ['sopel']
 
 
 def test_priority():