diff --git a/sopel/bot.py b/sopel/bot.py index daba13512f..b60726496c 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -448,10 +448,11 @@ def register_callables(self, callables): for callbl in callables: rules = getattr(callbl, 'rule', []) find_rules = getattr(callbl, 'find_rules', []) + search_rules = getattr(callbl, 'search_rules', []) commands = getattr(callbl, 'commands', []) nick_commands = getattr(callbl, 'nickname_commands', []) action_commands = getattr(callbl, 'action_commands', []) - is_rule = any([rules, find_rules]) + is_rule = any([rules, find_rules, search_rules]) is_command = any([commands, nick_commands, action_commands]) if rules: @@ -462,6 +463,10 @@ def register_callables(self, callables): rule = plugin_rules.FindRule.from_callable(settings, callbl) self._rules_manager.register(rule) + if search_rules: + rule = plugin_rules.SearchRule.from_callable(settings, callbl) + self._rules_manager.register(rule) + if commands: rule = plugin_rules.Command.from_callable(settings, callbl) self._rules_manager.register_command(rule) diff --git a/sopel/loader.py b/sopel/loader.py index c16444e5b2..5104991838 100644 --- a/sopel/loader.py +++ b/sopel/loader.py @@ -93,6 +93,12 @@ def clean_callable(func, config): for rule in func.find_rules ] + if hasattr(func, 'search_rules'): + func.search_rules = [ + compile_rule(nick, rule, alias_nicks) + for rule in func.search_rules + ] + if any(hasattr(func, attr) for attr in ['commands', 'nickname_commands', 'action_commands']): if hasattr(func, 'example'): # If no examples are flagged as user-facing, just show the first one like Sopel<7.0 did @@ -139,6 +145,7 @@ def is_limitable(obj): allowed_attrs = ( 'rule', 'find_rules', + 'search_rules', 'event', 'intents', 'commands', @@ -176,6 +183,7 @@ def is_triggerable(obj): allowed_attrs = ( 'rule', 'find_rules', + 'search_rules', 'event', 'intents', 'commands', diff --git a/sopel/module.py b/sopel/module.py index 3ee0893064..495e4c3502 100644 --- a/sopel/module.py +++ b/sopel/module.py @@ -36,6 +36,7 @@ 'require_privilege', 'require_privmsg', 'rule', + 'search', 'thread', 'unblockable', 'url', @@ -159,17 +160,27 @@ def rule(*patterns): :param str patterns: one or more regular expression(s) - Each argument is a regular expression which will trigger the function. + Each argument is a regular expression which will trigger the function:: + + @rule('hello', 'how') + # will trigger once on "how are you?" + # will trigger once on "hello, what's up?" + + This decorator can be used multiple times to add more rules:: - This decorator can be used multiple times to add more rules. + @rule('how') + @rule('hello') + # will trigger once on "how are you?" + # will trigger once on "hello, what's up?" - If the Sopel instance is in a channel, or sent a PRIVMSG, where a string - matching this expression is said, the function will execute. Note that - captured groups here will be retrievable through the Trigger object later. + If the Sopel instance is in a channel, or sent a ``PRIVMSG``, where a + string matching this expression is said, the function will execute. Note + that captured groups here will be retrievable through the + :class:`~sopel.trigger.Trigger` object later. - Inside the regular expression, some special directives can be used. $nick - will be replaced with the nick of the bot and , or :, and $nickname will be - replaced with the nick of the bot. + Inside the regular expression, some special directives can be used. + ``$nick`` will be replaced with the nick of the bot and ``,`` or ``:``, and + ``$nickname`` will be replaced with the nick of the bot. .. versionchanged:: 7.0 @@ -179,14 +190,12 @@ def rule(*patterns): .. note:: - A regex rule can match only once per line. Use the :func:`find` - decorator to match multiple times. + The regex rule will match only once per line, starting at the beginning + of the line only. - .. note:: - - The regex match must start at the beginning of the line. To match - anywhere in a line, surround the actual pattern with ``.*``. A future - version of Sopel may remove this requirement. + To match for each time the expression is found, use the :func:`find` + decorator instead. To match only once from anywhere in the line, + use the :func:`search` decorator instead. """ def add_attribute(function): @@ -221,10 +230,11 @@ def find(*patterns): # will trigger once on "I'm right here!" If the Sopel instance is in a channel, or sent a ``PRIVMSG``, the function - will execute for each time in a string said matches the expression. + will execute for each time in a string said matches the expression. Each + match will also contains the position of the instance it found. Inside the regular expression, some special directives can be used. - ``$nick`` will be replaced with the nick of the bot and ``,`` or ``:,`` and + ``$nick`` will be replaced with the nick of the bot and ``,`` or ``:``, and ``$nickname`` will be replaced with the nick of the bot:: @find('$nickname') @@ -235,8 +245,11 @@ def find(*patterns): .. note:: The regex rule will match for each non-overlapping matches, from left - to right, and the function will execute for each of these matches. To - match only once per line, use the :func:`rule` decorator instead. + to right, and the function will execute for each of these matches. + + To match only once from anywhere in the line, use the :func:`search` + decorator instead. To match only once from the start of the line, + use the :func:`rule` decorator instead. """ def add_attribute(function): @@ -250,6 +263,63 @@ def add_attribute(function): return add_attribute +def search(*patterns): + """Decorate a function to be called when a pattern is found in a line. + + :param str patterns: one or more regular expression(s) + + Each argument is a regular expression which will trigger the function:: + + @search('hello', 'here') + # will trigger once on "hello you" + # will trigger twice on "hello here" + # will trigger once on "I'm right here!" + + This decorator can be used multiple times to add more search rules:: + + @search('here') + @search('hello') + # will trigger once on "hello you" + # will trigger twice on "hello here" (once per expression) + # will trigger once on "I'm right here!" + + If the Sopel instance is in a channel, or sent a PRIVMSG, where a part + of a string matching this expression is said, the function will execute. + Note that captured groups here will be retrievable through the + :class:`~sopel.trigger.Trigger` object later. The match will also contains + the position of the first instance found. + + Inside the regular expression, some special directives can be used. + ``$nick`` will be replaced with the nick of the bot and ``,`` or ``:``, and + ``$nickname`` will be replaced with the nick of the bot:: + + @search('$nickname') + # will trigger once when the bot's nick is in a trigger + + .. versionadded:: 7.1 + + .. note:: + + The regex rule will match for the first instance only, starting from + the left of the line, and the function will execute only once per + regular expression. + + To match for each time the expression is found, use the :func:`find` + decorator instead. To match only once from the start of the line, + use the :func:`rule` decorator instead. + + """ + def add_attribute(function): + if not hasattr(function, "search_rules"): + function.search_rules = [] + for value in patterns: + if value not in function.search_rules: + function.search_rules.append(value) + return function + + return add_attribute + + def thread(value): """Decorate a function to specify if it should be run in a separate thread. diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index f171190265..8ccacde101 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -35,6 +35,7 @@ 'Manager', 'Rule', 'FindRule', + 'SearchRule', 'Command', 'NickCommand', 'ActionCommand', @@ -1259,3 +1260,44 @@ def parse(self, text): for regex in self._regexes: for match in regex.finditer(text): yield match + + +class SearchRule(Rule): + """Anonymous search rule definition. + + An anonymous search rule (or simply "a search rule") is like anonymous + rules with a twist: it will execute exactly once per regular expression + that match any part of a line, not just from the start. + + For example, to search if any word starts with the ``h`` letter in a line, + you can use the pattern ``h\\w+``: + + .. code-block:: irc + + hello here + Found the word "hello" + sopelunker, how are you? + Found the word "how" + + The match object it returns contains the first element that matches the + expression in the line. + + .. seealso:: + + This rule uses :func:`re.search`. To know more about how it works, + see the official Python documentation. + + """ + @classmethod + def from_callable(cls, settings, handler): + regexes = tuple(handler.search_rules) + kwargs = cls.kwargs_from_callable(handler) + kwargs['handler'] = handler + + return cls(regexes, **kwargs) + + def parse(self, text): + for regex in self._regexes: + match = regex.search(text) + if match: + yield match diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py index f38d480e5c..d2e1eca13c 100644 --- a/test/plugins/test_plugins_rules.py +++ b/test/plugins/test_plugins_rules.py @@ -90,6 +90,28 @@ def test_manager_find(mockbot): assert result_match.group(0) == 'world', 'The second must match on "world"' +def test_manager_search(mockbot): + regex = re.compile(r'\w+') + rule = rules.SearchRule([regex], plugin='testplugin', label='testrule') + manager = rules.Manager() + manager.register(rule) + + assert manager.has_rule('testrule') + assert manager.has_rule('testrule', plugin='testplugin') + assert not manager.has_rule('testrule', plugin='not-plugin') + + line = ':Foo!foo@example.com PRIVMSG #sopel :Hello, world' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + + items = manager.get_triggered_rules(mockbot, pretrigger) + assert len(items) == 1, 'Exactly one rule must match' + + # first result + result_rule, result_match = items[0] + assert result_rule == rule + assert result_match.group(0) == 'Hello' + + def test_manager_command(mockbot): command = rules.Command('hello', prefix=r'\.', plugin='testplugin') manager = rules.Manager() @@ -2202,3 +2224,90 @@ def handler(wrapped, trigger): assert len(results) == 2, 'Exactly 2 rules must match' assert all(result.group(0) == 'hey' for result in results) + + +# ----------------------------------------------------------------------------- +# test for :class:`sopel.plugins.rules.SearchRule` + +def test_search_rule_str(): + regex = re.compile(r'.*') + rule = rules.SearchRule([regex], plugin='testplugin', label='testrule') + + assert str(rule) == '' + + +def test_search_rule_str_no_plugin(): + regex = re.compile(r'.*') + rule = rules.SearchRule([regex], label='testrule') + + assert str(rule) == '' + + +def test_search_str_no_label(): + regex = re.compile(r'.*') + rule = rules.SearchRule([regex], plugin='testplugin') + + assert str(rule) == '' + + +def test_search_str_no_plugin_label(): + regex = re.compile(r'.*') + rule = rules.SearchRule([regex]) + + assert str(rule) == '' + + +def test_search_rule_parse_pattern(): + # playing with regex + regex = re.compile(r'\w+') + + rule = rules.SearchRule([regex]) + results = list(rule.parse('Hello, world!')) + assert len(results) == 1, 'Search rule on word must match only once' + assert results[0].group(0) == 'Hello' + + +def test_search_rule_from_callable(mockbot): + # prepare callable + @module.search(r'hello', r'hi', r'hey', r'hello|hi') + def handler(wrapped, trigger): + wrapped.reply('Hi!') + + loader.clean_callable(handler, mockbot.settings) + handler.plugin_name = 'testplugin' + + # create rule from a clean callable + rule = rules.SearchRule.from_callable(mockbot.settings, handler) + assert str(rule) == '' + + # match on "Hello" twice + line = ':Foo!foo@example.com PRIVMSG #sopel :Hello, world' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 2, 'Exactly 2 rules must match' + assert all(result.group(0) == 'Hello' for result in results) + + # match on "hi" twice + line = ':Foo!foo@example.com PRIVMSG #sopel :hi!' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 2, 'Exactly 2 rules must match' + assert all(result.group(0) == 'hi' for result in results) + + # match on "hey" once + line = ':Foo!foo@example.com PRIVMSG #sopel :hey how are you doing?' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert results[0].group(0) == 'hey' + + # match on "hey" once even if not at the beginning of the line + line = ':Foo!foo@example.com PRIVMSG #sopel :I say hey, can you say hey?' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'The rule must match once from anywhere' + assert results[0].group(0) == 'hey' diff --git a/test/test_bot.py b/test/test_bot.py index b58d9e32fa..5f8258c6c2 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -344,6 +344,10 @@ def rule_hello(bot, trigger): def rule_find_hello(bot, trigger): pass + @module.search(r'(hi|hello|hey|sup)') + def rule_search_hello(bot, trigger): + pass + @module.commands('do') @module.example('.do nothing') def command_do(bot, trigger): @@ -386,6 +390,7 @@ def on_join(bot, trigger): callables = [ rule_hello, rule_find_hello, + rule_search_hello, command_do, command_main_sub, command_main_other, @@ -409,9 +414,10 @@ def on_join(bot, trigger): pretrigger = trigger.PreTrigger(sopel.nick, line) matches = sopel.rules.get_triggered_rules(sopel, pretrigger) - assert len(matches) == 2 + assert len(matches) == 3 assert matches[0][0].get_rule_label() == 'rule_hello' assert matches[1][0].get_rule_label() == 'rule_find_hello' + assert matches[2][0].get_rule_label() == 'rule_search_hello' # trigger command "do" line = ':Foo!foo@example.com PRIVMSG #sopel :.do' diff --git a/test/test_loader.py b/test/test_loader.py index 6204a2b8ec..5251c03a96 100644 --- a/test/test_loader.py +++ b/test/test_loader.py @@ -203,6 +203,7 @@ def test_clean_callable_default(tmpconfig, func): assert not hasattr(func, 'event') assert not hasattr(func, 'rule') assert not hasattr(func, 'find_rules') + assert not hasattr(func, 'search_rules') assert not hasattr(func, 'commands') assert not hasattr(func, 'nickname_commands') assert not hasattr(func, 'action_commands') @@ -417,6 +418,50 @@ def test_clean_callable_find_rules(tmpconfig, func): assert func.global_rate == 0 +def test_clean_callable_search_rules(tmpconfig, func): + setattr(func, 'search_rules', [r'abc']) + loader.clean_callable(func, tmpconfig) + + assert hasattr(func, 'search_rules') + assert len(func.search_rules) == 1 + assert not hasattr(func, 'rule') + + # Test the regex is compiled properly + regex = func.search_rules[0] + assert regex.search('abc') + assert regex.search('xyzabc') + assert regex.search('abcd') + assert not regex.search('adbc') + + # Default values + assert hasattr(func, 'unblockable') + assert func.unblockable is False + assert hasattr(func, 'priority') + assert func.priority == 'medium' + assert hasattr(func, 'thread') + assert func.thread is True + assert hasattr(func, 'rate') + assert func.rate == 0 + assert hasattr(func, 'channel_rate') + assert func.channel_rate == 0 + assert hasattr(func, 'global_rate') + assert func.global_rate == 0 + + # idempotency + loader.clean_callable(func, tmpconfig) + assert hasattr(func, 'search_rules') + assert len(func.search_rules) == 1 + assert regex in func.search_rules + assert not hasattr(func, 'rule') + + assert func.unblockable is False + assert func.priority == 'medium' + assert func.thread is True + assert func.rate == 0 + assert func.channel_rate == 0 + assert func.global_rate == 0 + + def test_clean_callable_nickname_command(tmpconfig, func): setattr(func, 'nickname_commands', ['hello!']) loader.clean_callable(func, tmpconfig) diff --git a/test/test_module.py b/test/test_module.py index e477f08ff7..4d5e6bd3f0 100644 --- a/test/test_module.py +++ b/test/test_module.py @@ -146,6 +146,29 @@ def mock(bot, trigger, match): assert mock.find_rules == [r'\w+', '.*', r'\d+'] +def test_search(): + @module.search('.*') + def mock(bot, trigger, match): + return True + assert mock.search_rules == ['.*'] + + +def test_search_args(): + @module.search('.*', r'\d+') + def mock(bot, trigger, match): + return True + assert mock.search_rules == ['.*', r'\d+'] + + +def test_search_multiple(): + @module.search('.*', r'\d+') + @module.search('.*') + @module.search(r'\w+') + def mock(bot, trigger, match): + return True + assert mock.search_rules == [r'\w+', '.*', r'\d+'] + + def test_thread(): @module.thread(True) def mock(bot, trigger, match):