Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rules: add find and search rules #1881

Merged
merged 3 commits into from
Jun 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions sopel/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,18 +447,25 @@ 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, search_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 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)
Expand All @@ -474,6 +481,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
21 changes: 20 additions & 1 deletion sopel/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,22 @@ 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 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'):
Expand Down Expand Up @@ -129,6 +144,8 @@ def is_limitable(obj):

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

allowed_attrs = (
'rule',
'find_rules',
'search_rules',
'event',
'intents',
'commands',
Expand Down
151 changes: 136 additions & 15 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 All @@ -35,6 +36,7 @@
'require_privilege',
'require_privmsg',
'rule',
'search',
'thread',
'unblockable',
'url',
Expand Down Expand Up @@ -158,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::

This decorator can be used multiple times to add more rules.
@rule('hello', 'how')
# 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.
This decorator can be used multiple times to add more rules::

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.
@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
: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.

.. versionchanged:: 7.0

Expand All @@ -178,14 +190,12 @@ def rule(*patterns):

.. note::

A regex rule can match only once per line. A future version of Sopel
will (hopefully) remove this limitation.
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 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.

"""
def add_attribute(function):
Expand All @@ -199,6 +209,117 @@ def add_attribute(function):
return add_attribute


def find(*patterns):
"""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)

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 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
``$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 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`
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, "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 search(*patterns):
"""Decorate a function to be called when a pattern matches anywhere 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 contain
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 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.

"""
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
92 changes: 90 additions & 2 deletions sopel/plugins/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@
COMMAND_DEFAULT_HELP_PREFIX, COMMAND_DEFAULT_PREFIX)


__all__ = ['Manager', 'Rule', 'Command', 'NickCommand', 'ActionCommand']
__all__ = [
'Manager',
'Rule',
'FindRule',
'SearchRule',
'Command',
'NickCommand',
'ActionCommand',
]

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -699,7 +707,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 +1222,82 @@ 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 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 letter ``h`` 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


class SearchRule(Rule):
"""Anonymous search rule definition.

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 letter ``h`` 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
Loading