Skip to content

Commit

Permalink
rules: add the search rule
Browse files Browse the repository at this point in the history
  • Loading branch information
Exirel committed Jun 11, 2020
1 parent c7d2c46 commit 65fb76a
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 21 deletions.
7 changes: 6 additions & 1 deletion sopel/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions sopel/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,6 +145,7 @@ def is_limitable(obj):
allowed_attrs = (
'rule',
'find_rules',
'search_rules',
'event',
'intents',
'commands',
Expand Down Expand Up @@ -176,6 +183,7 @@ def is_triggerable(obj):
allowed_attrs = (
'rule',
'find_rules',
'search_rules',
'event',
'intents',
'commands',
Expand Down
108 changes: 89 additions & 19 deletions sopel/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
'require_privilege',
'require_privmsg',
'rule',
'search',
'thread',
'unblockable',
'url',
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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')
Expand All @@ -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):
Expand All @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions sopel/plugins/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
'Manager',
'Rule',
'FindRule',
'SearchRule',
'Command',
'NickCommand',
'ActionCommand',
Expand Down Expand Up @@ -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
<user> hello here
<Bot> Found the word "hello"
<user> sopelunker, how are you?
<Bot> 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
109 changes: 109 additions & 0 deletions test/plugins/test_plugins_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) == '<SearchRule testplugin.testrule (1)>'


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

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


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

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


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

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


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) == '<SearchRule 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" 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'
Loading

0 comments on commit 65fb76a

Please sign in to comment.