Skip to content

Commit

Permalink
rules: add the find rule
Browse files Browse the repository at this point in the history
  • Loading branch information
Exirel committed Jun 11, 2020
1 parent 5fddc73 commit c7d2c46
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 10 deletions.
15 changes: 11 additions & 4 deletions sopel/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion sopel/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down Expand Up @@ -129,6 +138,7 @@ def is_limitable(obj):

allowed_attrs = (
'rule',
'find_rules',
'event',
'intents',
'commands',
Expand Down Expand Up @@ -165,6 +175,7 @@ def is_triggerable(obj):

allowed_attrs = (
'rule',
'find_rules',
'event',
'intents',
'commands',
Expand Down
55 changes: 53 additions & 2 deletions sopel/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'echo',
'event',
'example',
'find',
'intent',
'interval',
'nickname_commands',
Expand Down Expand Up @@ -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::
Expand All @@ -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.
Expand Down
50 changes: 48 additions & 2 deletions sopel/plugins/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -699,7 +706,8 @@ def __str__(self):

plugin = self.get_plugin_name() or '(no-plugin)'

return '<Rule %s.%s (%d)>' % (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
Expand Down Expand Up @@ -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
<user> hello here
<Bot> Found the word "hello"
<Bot> Found the word "here"
<user> sopelunker, how are you?
<Bot> 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
119 changes: 119 additions & 0 deletions test/plugins/test_plugins_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) == '<FindRule testplugin.testrule (1)>'


def test_find_rule_str_no_plugin():
regex = re.compile(r'.*')
rule = rules.FindRule([regex], label='testrule')

assert str(rule) == '<FindRule (no-plugin).testrule (1)>'


def test_find_str_no_label():
regex = re.compile(r'.*')
rule = rules.FindRule([regex], plugin='testplugin')

assert str(rule) == '<FindRule testplugin.(generic) (1)>'


def test_find_str_no_plugin_label():
regex = re.compile(r'.*')
rule = rules.FindRule([regex])

assert str(rule) == '<FindRule (no-plugin).(generic) (1)>'


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) == '<FindRule testplugin.handler (4)>'

# 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)
8 changes: 7 additions & 1 deletion test/test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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'
Expand Down
Loading

0 comments on commit c7d2c46

Please sign in to comment.