From c7d2c46014ea58f2d91e2132b9d12b21156e0d00 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 1 Jun 2020 17:44:52 +0200 Subject: [PATCH 1/3] rules: add the find rule --- sopel/bot.py | 15 +++- sopel/loader.py | 13 +++- sopel/module.py | 55 ++++++++++++- sopel/plugins/rules.py | 50 +++++++++++- test/plugins/test_plugins_rules.py | 119 +++++++++++++++++++++++++++++ test/test_bot.py | 8 +- test/test_loader.py | 44 +++++++++++ test/test_module.py | 23 ++++++ 8 files changed, 317 insertions(+), 10 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index a0b93b7025..daba13512f 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -447,18 +447,20 @@ def register_callables(self, callables): for callbl in callables: rules = getattr(callbl, 'rule', []) + find_rules = getattr(callbl, 'find_rules', []) commands = getattr(callbl, 'commands', []) nick_commands = getattr(callbl, 'nickname_commands', []) action_commands = getattr(callbl, 'action_commands', []) + is_rule = any([rules, find_rules]) is_command = any([commands, nick_commands, action_commands]) if rules: rule = plugin_rules.Rule.from_callable(settings, callbl) self._rules_manager.register(rule) - elif not is_command: - callbl.rule = [match_any] - self._rules_manager.register( - plugin_rules.Rule.from_callable(self.settings, callbl)) + + if find_rules: + rule = plugin_rules.FindRule.from_callable(settings, callbl) + self._rules_manager.register(rule) if commands: rule = plugin_rules.Command.from_callable(settings, callbl) @@ -474,6 +476,11 @@ def register_callables(self, callables): settings, callbl) self._rules_manager.register_action_command(rule) + if not is_command and not is_rule: + callbl.rule = [match_any] + self._rules_manager.register( + plugin_rules.Rule.from_callable(self.settings, callbl)) + def register_jobs(self, jobs): for func in jobs: for interval in func.interval: diff --git a/sopel/loader.py b/sopel/loader.py index 881a320208..c16444e5b2 100644 --- a/sopel/loader.py +++ b/sopel/loader.py @@ -82,7 +82,16 @@ def clean_callable(func, config): if hasattr(func, 'rule'): if isinstance(func.rule, basestring): func.rule = [func.rule] - func.rule = [compile_rule(nick, rule, alias_nicks) for rule in func.rule] + func.rule = [ + compile_rule(nick, rule, alias_nicks) + for rule in func.rule + ] + + if hasattr(func, 'find_rules'): + func.find_rules = [ + compile_rule(nick, rule, alias_nicks) + for rule in func.find_rules + ] if any(hasattr(func, attr) for attr in ['commands', 'nickname_commands', 'action_commands']): if hasattr(func, 'example'): @@ -129,6 +138,7 @@ def is_limitable(obj): allowed_attrs = ( 'rule', + 'find_rules', 'event', 'intents', 'commands', @@ -165,6 +175,7 @@ def is_triggerable(obj): allowed_attrs = ( 'rule', + 'find_rules', 'event', 'intents', 'commands', diff --git a/sopel/module.py b/sopel/module.py index e6afd20b05..3ee0893064 100644 --- a/sopel/module.py +++ b/sopel/module.py @@ -22,6 +22,7 @@ 'echo', 'event', 'example', + 'find', 'intent', 'interval', 'nickname_commands', @@ -178,8 +179,8 @@ def rule(*patterns): .. note:: - A regex rule can match only once per line. A future version of Sopel - will (hopefully) remove this limitation. + A regex rule can match only once per line. Use the :func:`find` + decorator to match multiple times. .. note:: @@ -199,6 +200,56 @@ def add_attribute(function): return add_attribute +def find(*patterns): + """Decorate a function to be called each time patterns 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:: + + @find('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 rules:: + + @find('here') + @find('hello') + # will trigger once on "hello you" + # will trigger twice on "hello here" + # 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. + + 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:: + + @find('$nickname') + # will trigger for each time the bot's nick is in a trigger + + .. versionadded:: 7.1 + + .. 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. + + """ + def add_attribute(function): + if not hasattr(function, "find_rules"): + function.find_rules = [] + for value in patterns: + if value not in function.find_rules: + function.find_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 9f7d1ce531..f171190265 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -31,7 +31,14 @@ COMMAND_DEFAULT_HELP_PREFIX, COMMAND_DEFAULT_PREFIX) -__all__ = ['Manager', 'Rule', 'Command', 'NickCommand', 'ActionCommand'] +__all__ = [ + 'Manager', + 'Rule', + 'FindRule', + 'Command', + 'NickCommand', + 'ActionCommand', +] LOGGER = logging.getLogger(__name__) @@ -699,7 +706,8 @@ def __str__(self): plugin = self.get_plugin_name() or '(no-plugin)' - return '' % (plugin, label, len(self._regexes)) + return '<%s %s.%s (%d)>' % ( + self.__class__.__name__, plugin, label, len(self._regexes)) def get_plugin_name(self): return self._plugin_name @@ -1213,3 +1221,41 @@ def match_intent(self, intent): :rtype: bool """ return bool(intent and self.INTENT_REGEX.match(intent)) + + +class FindRule(Rule): + """Anonymous find rule definition. + + A find rule is like other anonymous rule with a twist: instead of maching + only once per IRC line, a find rule will execute for each non-overlapping + match for each of its regular expressions. + + For example, to match for each word starting with the h letter in a line, + you can use the pattern ``h\\w+``: + + .. code-block:: irc + + hello here + Found the word "hello" + Found the word "here" + sopelunker, how are you? + Found the word "how" + + .. seealso:: + + This rule uses :func:`re.finditer`. To know more about how it works, + see the official Python documentation. + + """ + @classmethod + def from_callable(cls, settings, handler): + regexes = tuple(handler.find_rules) + kwargs = cls.kwargs_from_callable(handler) + kwargs['handler'] = handler + + return cls(regexes, **kwargs) + + def parse(self, text): + for regex in self._regexes: + for match in regex.finditer(text): + yield match diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py index 99711b90d9..f38d480e5c 100644 --- a/test/plugins/test_plugins_rules.py +++ b/test/plugins/test_plugins_rules.py @@ -59,6 +59,37 @@ def test_manager_rule(mockbot): assert result_match.group(0) == 'Hello, world' +def test_manager_find(mockbot): + regex = re.compile(r'\w+') + rule = rules.FindRule([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) == 2, 'Exactly two rules must match' + assert len(items[0]) == 2, ( + 'First result must contain two items: (rule, match)') + assert len(items[1]) == 2, ( + 'Second result must contain two items: (rule, match)') + + # first result + result_rule, result_match = items[0] + assert result_rule == rule + assert result_match.group(0) == 'Hello', 'The first must match on "Hello"' + + # second result + result_rule, result_match = items[1] + assert result_rule == rule + assert result_match.group(0) == 'world', 'The second must match on "world"' + + def test_manager_command(mockbot): command = rules.Command('hello', prefix=r'\.', plugin='testplugin') manager = rules.Manager() @@ -2083,3 +2114,91 @@ def handler(wrapped, trigger): assert result.group(4) is None assert result.group(5) is None assert result.group(6) is None + + +# ----------------------------------------------------------------------------- +# test for :class:`sopel.plugins.rules.FindRule` + +def test_find_rule_str(): + regex = re.compile(r'.*') + rule = rules.FindRule([regex], plugin='testplugin', label='testrule') + + assert str(rule) == '' + + +def test_find_rule_str_no_plugin(): + regex = re.compile(r'.*') + rule = rules.FindRule([regex], label='testrule') + + assert str(rule) == '' + + +def test_find_str_no_label(): + regex = re.compile(r'.*') + rule = rules.FindRule([regex], plugin='testplugin') + + assert str(rule) == '' + + +def test_find_str_no_plugin_label(): + regex = re.compile(r'.*') + rule = rules.FindRule([regex]) + + assert str(rule) == '' + + +def test_find_rule_parse_pattern(): + # playing with regex + regex = re.compile(r'\w+') + + rule = rules.FindRule([regex]) + results = list(rule.parse('Hello, world!')) + assert len(results) == 2, 'Find rule on word must match twice' + assert results[0].group(0) == 'Hello' + assert results[1].group(0) == 'world' + + +def test_find_rule_from_callable(mockbot): + # prepare callable + @module.find(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.FindRule.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" twice + 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" twice because it's twice in 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) == 2, 'Exactly 2 rules must match' + assert all(result.group(0) == 'hey' for result in results) diff --git a/test/test_bot.py b/test/test_bot.py index ab4ae7ccac..b58d9e32fa 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -340,6 +340,10 @@ def test_register_callables(tmpconfig): def rule_hello(bot, trigger): pass + @module.find(r'(hi|hello|hey|sup)') + def rule_find_hello(bot, trigger): + pass + @module.commands('do') @module.example('.do nothing') def command_do(bot, trigger): @@ -381,6 +385,7 @@ def on_join(bot, trigger): # prepare callables to be registered callables = [ rule_hello, + rule_find_hello, command_do, command_main_sub, command_main_other, @@ -404,8 +409,9 @@ def on_join(bot, trigger): pretrigger = trigger.PreTrigger(sopel.nick, line) matches = sopel.rules.get_triggered_rules(sopel, pretrigger) - assert len(matches) == 1 + assert len(matches) == 2 assert matches[0][0].get_rule_label() == 'rule_hello' + assert matches[1][0].get_rule_label() == 'rule_find_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 06cb113c02..6204a2b8ec 100644 --- a/test/test_loader.py +++ b/test/test_loader.py @@ -202,6 +202,7 @@ def test_clean_callable_default(tmpconfig, func): assert not hasattr(func, 'global_rate') assert not hasattr(func, 'event') assert not hasattr(func, 'rule') + assert not hasattr(func, 'find_rules') assert not hasattr(func, 'commands') assert not hasattr(func, 'nickname_commands') assert not hasattr(func, 'action_commands') @@ -373,6 +374,49 @@ def test_clean_callable_rule_nickname(tmpconfig, func): assert regex in func.rule +def test_clean_callable_find_rules(tmpconfig, func): + setattr(func, 'find_rules', [r'abc']) + loader.clean_callable(func, tmpconfig) + + assert hasattr(func, 'find_rules') + assert len(func.find_rules) == 1 + assert not hasattr(func, 'rule') + + # Test the regex is compiled properly + regex = func.find_rules[0] + assert regex.findall('abc') + assert regex.findall('abcd') + assert not regex.findall('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, 'find_rules') + assert len(func.find_rules) == 1 + assert regex in func.find_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 ee107913a9..e477f08ff7 100644 --- a/test/test_module.py +++ b/test/test_module.py @@ -123,6 +123,29 @@ def mock(bot, trigger, match): assert mock.rule == [r'\w+', '.*', r'\d+'] +def test_find(): + @module.find('.*') + def mock(bot, trigger, match): + return True + assert mock.find_rules == ['.*'] + + +def test_find_args(): + @module.find('.*', r'\d+') + def mock(bot, trigger, match): + return True + assert mock.find_rules == ['.*', r'\d+'] + + +def test_find_multiple(): + @module.find('.*', r'\d+') + @module.find('.*') + @module.find(r'\w+') + def mock(bot, trigger, match): + return True + assert mock.find_rules == [r'\w+', '.*', r'\d+'] + + def test_thread(): @module.thread(True) def mock(bot, trigger, match): From 65fb76adb6496a3d8c75abd11c352ad02a48e54e Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 1 Jun 2020 19:03:41 +0200 Subject: [PATCH 2/3] rules: add the search rule --- sopel/bot.py | 7 +- sopel/loader.py | 8 +++ sopel/module.py | 108 +++++++++++++++++++++++----- sopel/plugins/rules.py | 42 +++++++++++ test/plugins/test_plugins_rules.py | 109 +++++++++++++++++++++++++++++ test/test_bot.py | 8 ++- test/test_loader.py | 45 ++++++++++++ test/test_module.py | 23 ++++++ 8 files changed, 329 insertions(+), 21 deletions(-) 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): From 910383bd89fd058186453a0e5b7ce3265b0d5caf Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Tue, 16 Jun 2020 11:50:48 +0200 Subject: [PATCH 3/3] rules: fix spelling mistakes Co-authored-by: dgw --- sopel/module.py | 16 ++++++++-------- sopel/plugins/rules.py | 12 ++++++------ test/plugins/test_plugins_rules.py | 8 ++++---- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/sopel/module.py b/sopel/module.py index 495e4c3502..de92347fd2 100644 --- a/sopel/module.py +++ b/sopel/module.py @@ -193,7 +193,7 @@ def rule(*patterns): The regex rule will match only once per line, starting at the beginning of the line only. - To match for each time the expression is found, use the :func:`find` + To match for each time an expression is found, use the :func:`find` decorator instead. To match only once from anywhere in the line, use the :func:`search` decorator instead. @@ -210,7 +210,7 @@ def add_attribute(function): def find(*patterns): - """Decorate a function to be called each time patterns is found in a line. + """Decorate a function to be called for each time a pattern is found in a line. :param str patterns: one or more regular expression(s) @@ -230,8 +230,8 @@ 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. Each - match will also contains the position of the instance it found. + will execute for each time a received message matches an expression. Each + match will also contain 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 @@ -244,7 +244,7 @@ def find(*patterns): .. note:: - The regex rule will match for each non-overlapping matches, from left + The regex rule will match once for each non-overlapping match, from left 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` @@ -264,7 +264,7 @@ def add_attribute(function): def search(*patterns): - """Decorate a function to be called when a pattern is found in a line. + """Decorate a function to be called when a pattern matches anywhere in a line. :param str patterns: one or more regular expression(s) @@ -286,7 +286,7 @@ def search(*patterns): 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 + :class:`~sopel.trigger.Trigger` object later. The match will also contain the position of the first instance found. Inside the regular expression, some special directives can be used. @@ -304,7 +304,7 @@ def search(*patterns): 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` + To match for each time an 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. diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index 8ccacde101..d8f34e3370 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -1227,11 +1227,11 @@ def match_intent(self, intent): class FindRule(Rule): """Anonymous find rule definition. - A find rule is like other anonymous rule with a twist: instead of maching + A find rule is like an anonymous rule with a twist: instead of matching only once per IRC line, a find rule will execute for each non-overlapping match for each of its regular expressions. - For example, to match for each word starting with the h letter in a line, + For example, to match for each word starting with the letter ``h`` in a line, you can use the pattern ``h\\w+``: .. code-block:: irc @@ -1265,11 +1265,11 @@ def parse(self, text): 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. + A search rule is like an anonymous rule with a twist: it will execute + exactly once per regular expression that matches anywhere in a line, not + just from the start. - For example, to search if any word starts with the ``h`` letter in a line, + For example, to search if any word starts with the letter ``h`` in a line, you can use the pattern ``h\\w+``: .. code-block:: irc diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py index d2e1eca13c..e4b7f1856c 100644 --- a/test/plugins/test_plugins_rules.py +++ b/test/plugins/test_plugins_rules.py @@ -2139,7 +2139,7 @@ def handler(wrapped, trigger): # ----------------------------------------------------------------------------- -# test for :class:`sopel.plugins.rules.FindRule` +# tests for :class:`sopel.plugins.rules.FindRule` def test_find_rule_str(): regex = re.compile(r'.*') @@ -2189,7 +2189,7 @@ def handler(wrapped, trigger): loader.clean_callable(handler, mockbot.settings) handler.plugin_name = 'testplugin' - # create rule from a clean callable + # create rule from a cleaned callable rule = rules.FindRule.from_callable(mockbot.settings, handler) assert str(rule) == '' @@ -2227,7 +2227,7 @@ def handler(wrapped, trigger): # ----------------------------------------------------------------------------- -# test for :class:`sopel.plugins.rules.SearchRule` +# tests for :class:`sopel.plugins.rules.SearchRule` def test_search_rule_str(): regex = re.compile(r'.*') @@ -2276,7 +2276,7 @@ def handler(wrapped, trigger): loader.clean_callable(handler, mockbot.settings) handler.plugin_name = 'testplugin' - # create rule from a clean callable + # create rule from a cleaned callable rule = rules.SearchRule.from_callable(mockbot.settings, handler) assert str(rule) == ''