diff --git a/sopel/bot.py b/sopel/bot.py index a2c9013aa7..082a5e9469 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -420,7 +420,6 @@ def remove_plugin(self, plugin, callables, jobs, shutdowns, urls): self._rules_manager.unregister_plugin(name) self._scheduler.unregister_plugin(name) self.unregister_shutdowns(shutdowns) - self.unregister_urls(urls) # remove plugin from registry del self._plugins[name] @@ -573,22 +572,21 @@ def unregister_shutdowns(self, shutdowns): def register_urls(self, urls): for func in urls: - for regex in func.url_regex: - self.register_url_callback(regex, func) - callable_name = getattr(func, "__name__", 'UNKNOWN') - LOGGER.debug( - 'URL Callback added "%s" for URL pattern "%s"', - callable_name, - regex) - - def unregister_urls(self, urls): - if "url_callbacks" in self.memory: - for func in urls: - regexes = func.url_regex - for regex in regexes: - if func == self.memory['url_callbacks'].get(regex): - self.unregister_url_callback(regex, func) - LOGGER.debug('URL Callback unregistered: %r', regex) + url_regex = getattr(func, 'url_regex', []) + url_lazy_loaders = getattr(func, 'url_lazy_loaders', None) + + if url_regex: + rule = plugin_rules.URLCallback.from_callable( + self.settings, func) + self._rules_manager.register_url_callback(rule) + + if url_lazy_loaders: + try: + rule = plugin_rules.URLCallback.from_callable_lazy( + self.settings, func) + self._rules_manager.register_url_callback(rule) + except plugins.exceptions.PluginError as err: + LOGGER.error('Cannot register URL callback: %s', err) @deprecated( reason="Replaced by `say` method.", diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py index 706fe53a03..9e782aaf5a 100644 --- a/sopel/config/core_section.py +++ b/sopel/config/core_section.py @@ -20,6 +20,8 @@ """Default prefix used for commands.""" COMMAND_DEFAULT_HELP_PREFIX = '.' """Default help prefix used in commands' usage messages.""" +URL_DEFAULT_SCHEMES = ['http', 'https', 'ftp'] +"""Default URL schemes allowed for URLs.""" def _find_certs(): @@ -188,7 +190,7 @@ class CoreSection(StaticSection): auto_url_schemes = ListAttribute( 'auto_url_schemes', strip=True, - default=['http', 'https', 'ftp']) + default=URL_DEFAULT_SCHEMES) """List of URL schemes that will trigger URL callbacks. :default: ``['http', 'https', 'ftp']`` diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py index faa26b8e81..16762bdf36 100644 --- a/sopel/irc/__init__.py +++ b/sopel/irc/__init__.py @@ -213,7 +213,11 @@ def on_message(self, message): """ self.last_raw_line = message - pretrigger = PreTrigger(self.nick, message) + pretrigger = PreTrigger( + self.nick, + message, + url_schemes=self.settings.core.auto_url_schemes, + ) if all(cap not in self.enabled_capabilities for cap in ['account-tag', 'extended-join']): pretrigger.tags.pop('account', None) @@ -262,7 +266,8 @@ def on_message_sent(self, raw): pretrigger = PreTrigger( self.nick, - ":{0}!{1}@{2} {3}".format(self.nick, self.user, host, raw) + ":{0}!{1}@{2} {3}".format(self.nick, self.user, host, raw), + url_schemes=self.settings.core.auto_url_schemes, ) self.dispatch(pretrigger) diff --git a/sopel/loader.py b/sopel/loader.py index 5f2282301a..3e075b0af9 100644 --- a/sopel/loader.py +++ b/sopel/loader.py @@ -62,9 +62,9 @@ def clean_callable(func, config): func.global_rate = getattr(func, 'global_rate', 0) func.unblockable = getattr(func, 'unblockable', False) - if not is_triggerable(func): - # Adding the remaining default attributes below is potentially confusing - # to other code (and a waste of memory) for non-triggerable functions. + if not is_triggerable(func) and not is_url_callback(func): + # Adding the remaining default attributes below is potentially + # confusing to other code (and a waste of memory) for jobs. return func.echo = getattr(func, 'echo', False) @@ -152,6 +152,7 @@ def is_limitable(obj): 'nickname_commands', 'action_commands', 'url_regex', + 'url_lazy_loaders', ) allowed = any(hasattr(obj, attr) for attr in allowed_attrs) @@ -173,10 +174,12 @@ def is_triggerable(obj): Many of the decorators defined in :mod:`sopel.plugin` make the decorated function a triggerable object. + """ forbidden_attrs = ( 'interval', 'url_regex', + 'url_lazy_loaders', ) forbidden = any(hasattr(obj, attr) for attr in forbidden_attrs) @@ -195,6 +198,35 @@ def is_triggerable(obj): return allowed and not forbidden +def is_url_callback(obj): + """Check if ``obj`` can handle a URL callback. + + :param obj: any :term:`function` to check + :return: ``True`` if ``obj`` can handle a URL callback + + A URL callback handler is a callable that will be used by the bot to + handle a particular URL in an IRC message. + + .. seealso:: + + Both :func:`sopel.plugin.url` :func:`sopel.plugin.url_lazy` make the + decorated function a URL callback handler. + + """ + forbidden_attrs = ( + 'interval', + ) + forbidden = any(hasattr(obj, attr) for attr in forbidden_attrs) + + allowed_attrs = ( + 'url_regex', + 'url_lazy_loaders', + ) + allowed = any(hasattr(obj, attr) for attr in allowed_attrs) + + return allowed and not forbidden + + def clean_module(module, config): callables = [] shutdowns = [] @@ -210,7 +242,7 @@ def clean_module(module, config): elif hasattr(obj, 'interval'): clean_callable(obj, config) jobs.append(obj) - elif hasattr(obj, 'url_regex'): + elif is_url_callback(obj): clean_callable(obj, config) urls.append(obj) return callables, jobs, shutdowns, urls diff --git a/sopel/modules/bugzilla.py b/sopel/modules/bugzilla.py index 675c5109ce..82c9ce9378 100644 --- a/sopel/modules/bugzilla.py +++ b/sopel/modules/bugzilla.py @@ -14,11 +14,9 @@ import requests import xmltodict +from sopel import plugin, plugins from sopel.config.types import ListAttribute, StaticSection -from sopel.module import rule - -regex = None LOGGER = logging.getLogger(__name__) @@ -42,45 +40,44 @@ def configure(config): def setup(bot): - global regex bot.config.define_section('bugzilla', BugzillaSection) - if not bot.config.bugzilla.domains: - return - domains = '|'.join(bot.config.bugzilla.domains) - regex = re.compile((r'https?://(%s)' - r'(/show_bug.cgi\?\S*?)' - r'(id=\d+)') - % domains) - bot.register_url_callback(regex, show_bug) +def _bugzilla_loader(settings): + if not settings.bugzilla.domains: + raise plugins.exceptions.PluginSettingsError( + 'Bugzilla URL callback requires ' + '"bugzilla.domains" to be configured; check your config file.') + domain_pattern = '|'.join( + re.escape(domain) + for domain in settings.bugzilla.domains) -def shutdown(bot): - bot.unregister_url_callback(regex, show_bug) + pattern = ( + r'https?://(%s)' + r'(/show_bug.cgi\?\S*?)' + r'(id=\d+).*' + ) % domain_pattern + return [re.compile(pattern)] -@rule(r'.*https?://(\S+?)' - r'(/show_bug.cgi\?\S*?)' - r'(id=\d+).*') + +@plugin.url_lazy(_bugzilla_loader) +@plugin.output_prefix('[BUGZILLA] ') def show_bug(bot, trigger, match=None): """Show information about a Bugzilla bug.""" - match = match or trigger - domain = match.group(1) - if domain not in bot.config.bugzilla.domains: - return - url = 'https://%s%sctype=xml&%s' % match.groups() + url = 'https://%s%sctype=xml&%s' % trigger.groups() data = requests.get(url).content bug = xmltodict.parse(data).get('bugzilla').get('bug') error = bug.get('@error', None) # error="NotPermitted" if error: LOGGER.warning('Bugzilla error: %s' % error) - bot.say('[BUGZILLA] Unable to get information for ' + bot.say('Unable to get information for ' 'linked bug (%s)' % error) return - message = ('[BUGZILLA] %s | Product: %s | Component: %s | Version: %s | ' + + message = ('%s | Product: %s | Component: %s | Version: %s | ' + 'Importance: %s | Status: %s | Assigned to: %s | ' + 'Reported: %s | Modified: %s') diff --git a/sopel/modules/url.py b/sopel/modules/url.py index c65bd7757f..d153cc6242 100644 --- a/sopel/modules/url.py +++ b/sopel/modules/url.py @@ -277,11 +277,11 @@ def check_callbacks(bot, url): :param bot: Sopel instance :param str url: URL to check - :return: True if ``url`` is excluded or matches any URL Callback pattern + :return: True if ``url`` is excluded or matches any URL callback pattern This function looks at the ``bot.memory`` for ``url_exclude`` patterns and it returns ``True`` if any matches the given ``url``. Otherwise, it looks - at the ``bot``'s URL Callback patterns, and it returns ``True`` if any + at the ``bot``'s URL callback patterns, and it returns ``True`` if any matches, ``False`` otherwise. .. seealso:: @@ -297,7 +297,11 @@ def check_callbacks(bot, url): """ # Check if it matches the exclusion list first matched = any(regex.search(url) for regex in bot.memory['url_exclude']) - return matched or any(bot.search_url_callbacks(url)) + return ( + matched or + any(bot.search_url_callbacks(url)) or + bot.rules.check_url_callback(bot, url) + ) def find_title(url, verify=True): diff --git a/sopel/modules/wikipedia.py b/sopel/modules/wikipedia.py index ecbde88082..2f69b5be0a 100644 --- a/sopel/modules/wikipedia.py +++ b/sopel/modules/wikipedia.py @@ -13,7 +13,7 @@ from requests import get from sopel.config.types import StaticSection, ValidatedAttribute -from sopel.module import commands, example, NOLIMIT, url +from sopel.module import commands, example, NOLIMIT, output_prefix, url from sopel.tools.web import quote, unquote try: # TODO: Remove fallback when dropping py2 @@ -133,9 +133,9 @@ def say_snippet(bot, trigger, server, query, show_url=True): snippet = mw_snippet(server, query) except KeyError: if show_url: - bot.say("[WIKIPEDIA] Error fetching snippet for \"{}\".".format(page_name)) + bot.say("Error fetching snippet for \"{}\".".format(page_name)) return - msg = '[WIKIPEDIA] {} | "{}"'.format(page_name, snippet) + msg = '{} | "{}"'.format(page_name, snippet) msg_url = msg + ' | https://{}/wiki/{}'.format(server, query) if msg_url == trigger: # prevents triggering on another instance of Sopel return @@ -166,10 +166,10 @@ def say_section(bot, trigger, server, query, section): snippet = mw_section(server, query, section) if not snippet: - bot.say("[WIKIPEDIA] Error fetching section \"{}\" for page \"{}\".".format(section, page_name)) + bot.say("Error fetching section \"{}\" for page \"{}\".".format(section, page_name)) return - msg = '[WIKIPEDIA] {} - {} | "{}"'.format(page_name, section.replace('_', ' '), snippet) + msg = '{} - {} | "{}"'.format(page_name, section.replace('_', ' '), snippet) bot.say(msg) @@ -217,6 +217,7 @@ def mw_section(server, query, section): # Matches a wikipedia page (excluding spaces and #, but not /File: links), with a separate optional field for the section @url(r'https?:\/\/([a-z]+\.wikipedia\.org)\/wiki\/((?!File\:)[^ #]+)#?([^ ]*)') +@output_prefix('[WIKIPEDIA] ') def mw_info(bot, trigger, match=None): """Retrieves and outputs a snippet of the linked page.""" if match.group(3): @@ -230,6 +231,7 @@ def mw_info(bot, trigger, match=None): @commands('w', 'wiki', 'wik') @example('.w San Francisco') +@output_prefix('[WIKIPEDIA] ') def wikipedia(bot, trigger): lang = bot.config.wikipedia.default_lang diff --git a/sopel/plugin.py b/sopel/plugin.py index 429398e68a..96cc50fd82 100644 --- a/sopel/plugin.py +++ b/sopel/plugin.py @@ -41,6 +41,7 @@ 'thread', 'unblockable', 'url', + 'url_lazy', ] @@ -902,22 +903,22 @@ def url(*url_rules): :param str url_rules: one or more regex pattern(s) to match URLs This decorator takes a regex string that will be matched against URLs in a - message. The function it decorates, in addition to the bot and trigger, - must take a third argument ``match``, which is the regular expression match - of the URL:: + message. The function it decorates is like any other callable:: from sopel import plugin @plugin.url(r'https://example.com/bugs/([a-z0-9]+)') @plugin.url(r'https://short.com/([a-z0-9]+)') - def handle_example_bugs(bot, trigger, match): - bot.reply('Found bug ID #%s' % match.group(1)) + def handle_example_bugs(bot, trigger): + bot.reply('Found bug ID #%s' % trigger.group(1)) - This should be used rather than the matching in ``trigger``, in order to - support e.g. the ``.title`` command. + The ``bot`` is an instance of :class:`~sopel.bot.SopelWrapper`, and + ``trigger`` is the usual :class:`~sopel.trigger.Trigger` object. - Under the hood, when Sopel collects the decorated handler it uses - :meth:`sopel.bot.Sopel.register_url_callback` to register the handler. + Under the hood, when Sopel collects the decorated handler it uses an + instance of :class:`sopel.plugins.rules.URLCallback` to register it to its + :attr:`rules manager ` and its + :meth:`~sopel.plugins.rules.Manager.register_url_callback` method. .. versionchanged:: 7.0 @@ -928,6 +929,14 @@ def handle_example_bugs(bot, trigger, match): More than one pattern can be provided as positional argument at once. + .. versionchanged:: 7.1 + + The ``match`` parameter is obsolete and can be omitted. When present + however, it represents the same match as the ``trigger`` argument. + + This behavior will be kept for backward compatibility and will be + removed in Sopel 9. + .. seealso:: To detect URLs, Sopel uses a matching pattern built from a list of URL @@ -946,6 +955,53 @@ def actual_decorator(function): return actual_decorator +def url_lazy(*loaders): + """Decorate a function to handle URL, using lazy-loading for its regex. + + :param loaders: one or more functions to generate a list of **compiled** + regexes to match URLs. + :type loaders: :term:`function` + + Each ``loader`` function must accept a ``settings`` parameter and return a + list (or tuple) of **compiled** regular expressions:: + + import re + + def loader(settings): + return [re.compile(r'')] + + It will be called by Sopel when the bot parses the plugin to register URL + callbacks to get its regexes. The ``settings`` argument will be the bot's + :class:`sopel.config.Config` object. + + If any of the ``loader`` functions raises a + :exc:`~sopel.plugins.exceptions.PluginError` exception, the URL callback + will be ignored; it will not fail the plugin's loading. + + The decorated function will behave like any other :func:`callable`:: + + from sopel import plugin + + @plugin.url_lazy(loader) + def my_url_handler(bot, trigger): + bot.say('URL found: %s' % trigger.group(0)) + + .. versionadded:: 7.1 + + .. seealso:: + + When more than one loader is provided, they will be chained together + with the :func:`sopel.tools.chain_loaders` function. + + """ + def decorator(function): + if not hasattr(function, 'url_lazy_loaders'): + function.url_lazy_loaders = [] + function.url_lazy_loaders.extend(loaders) + return function + return decorator + + class example(object): """Decorate a function with an example, and optionally test output. diff --git a/sopel/plugins/exceptions.py b/sopel/plugins/exceptions.py index 8fa2e890f0..1d840e4afa 100644 --- a/sopel/plugins/exceptions.py +++ b/sopel/plugins/exceptions.py @@ -16,3 +16,12 @@ def __init__(self, name): message = 'Plugin "%s" not registered' % name self.plugin_name = name super(PluginNotRegistered, self).__init__(message) + + +class PluginSettingsError(PluginError): + """Exception raised when a plugin is not properly configured. + + This can be used in any place where a plugin requires a specific config, + for example in its ``setup`` function, in any of its rules or commands, + and in the loader function for the :func:`sopel.plugin.url_lazy` decorator. + """ diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index b9a425c19e..463d3d899a 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -19,6 +19,7 @@ import datetime +import functools import inspect import itertools import logging @@ -28,7 +29,20 @@ from sopel import tools from sopel.config.core_section import ( - COMMAND_DEFAULT_HELP_PREFIX, COMMAND_DEFAULT_PREFIX) + COMMAND_DEFAULT_HELP_PREFIX, COMMAND_DEFAULT_PREFIX, URL_DEFAULT_SCHEMES) + + +try: + from urllib.parse import urlparse +except ImportError: + # TODO: remove when dropping Python 2.7 + from urlparse import urlparse + +try: + from inspect import getfullargspec as inspect_getargspec +except ImportError: + # TODO: remove when dropping Python 2.7 + from inspect import getargspec as inspect_getargspec __all__ = [ @@ -39,6 +53,7 @@ 'Command', 'NickCommand', 'ActionCommand', + 'URLCallback', ] LOGGER = logging.getLogger(__name__) @@ -59,6 +74,15 @@ """Mapping of priority label to priority scale.""" +def _has_labeled_rule(registry, label, plugin=None): + rules = ( + itertools.chain(*registry.values()) + if plugin is None + else registry.get(plugin, []) + ) + return any(rule.get_rule_label() == label for rule in rules) + + def _has_named_rule(registry, name, follow_alias=False, plugin=None): rules = registry.values() if plugin is None else [registry.get(plugin, {})] @@ -110,6 +134,7 @@ class Manager(object): * :meth:`register_command` for named rules with a prefix * :meth:`register_nick_command` for named rules based on nick calling * :meth:`register_action_command` for named rules based on ``ACTION`` + * :meth:`register_url_callback` for URL callback rules Then to match the rules against a ``trigger``, see the :meth:`get_triggered_rules`, which returns a list of ``(rule, match)``, @@ -120,6 +145,7 @@ def __init__(self): self._commands = tools.SopelMemoryWithDefault(dict) self._nick_commands = tools.SopelMemoryWithDefault(dict) self._action_commands = tools.SopelMemoryWithDefault(dict) + self._url_callbacks = tools.SopelMemoryWithDefault(list) self._register_lock = threading.Lock() def unregister_plugin(self, plugin_name): @@ -137,6 +163,7 @@ def unregister_plugin(self, plugin_name): self._commands, self._nick_commands, self._action_commands, + self._url_callbacks, ] unregistered_rules = 0 @@ -196,6 +223,17 @@ def register_action_command(self, command): self._action_commands[plugin][command.name] = command LOGGER.debug('Action Command registered: %s', str(command)) + def register_url_callback(self, url_callback): + """Register a plugin URL callback. + + :param url_callback: the URL callback to register + :type url_callback: :class:`URLCallback` + """ + with self._register_lock: + plugin = url_callback.get_plugin_name() + self._url_callbacks[plugin].append(url_callback) + LOGGER.debug('URL callback registered: %s', str(url_callback)) + def has_rule(self, label, plugin=None): """Tell if the manager knows a rule with this ``label``. @@ -207,12 +245,7 @@ def has_rule(self, label, plugin=None): The optional parameter ``plugin`` can be provided to limit the rules to only those from that plugin. """ - rules = ( - itertools.chain(*self._rules.values()) - if plugin is None - else self._rules.get(plugin, []) - ) - return any(rule.get_rule_label() == label for rule in rules) + return _has_labeled_rule(self._rules, label, plugin) def has_command(self, name, follow_alias=True, plugin=None): """Tell if the manager knows a command with this ``name``. @@ -268,6 +301,19 @@ def has_action_command(self, name, follow_alias=True, plugin=None): return _has_named_rule( self._action_commands, name, follow_alias, plugin) + def has_url_callback(self, label, plugin=None): + """Tell if the manager knows a URL callback with this ``label``. + + :param str label: the label of the URL callback to look for + :param str plugin: optional filter on the plugin name + :return: ``True`` if the URL callback exists, ``False`` otherwise + :rtype: bool + + The optional parameter ``plugin`` can be provided to limit the URL + callbacks to only those from that plugin. + """ + return _has_labeled_rule(self._url_callbacks, label, plugin) + def get_all_commands(self): """Retrieve all the registered commands, by plugin.""" # expose a copy of the registered commands @@ -298,12 +344,14 @@ def get_triggered_rules(self, bot, pretrigger): action_rules = ( rules_dict.values() for rules_dict in self._action_commands.values()) + url_callback_rules = self._url_callbacks.values() rules = itertools.chain( itertools.chain(*generic_rules), itertools.chain(*command_rules), itertools.chain(*nick_rules), itertools.chain(*action_rules), + itertools.chain(*url_callback_rules), ) matches = ( (rule, match) @@ -319,6 +367,22 @@ def get_triggered_rules(self, bot, pretrigger): # Making it immutable is the cherry on top. return tuple(sorted(matches, key=lambda x: x[0].priority_scale)) + def check_url_callback(self, bot, url): + """Tell if the ``url`` matches any of the registered URL callbacks. + + :param bot: Sopel instance + :type bot: :class:`sopel.bot.Sopel` + :param str url: URL to check + :return: ``True`` when ``url`` matches any URL callbacks, + ``False`` otherwise + :rtype: bool + """ + return any( + any(rule.parse(url)) + for plugin_rules in self._url_callbacks.values() + for rule in plugin_rules + ) + class AbstractRule(object): """Abstract definition of a plugin's rule. @@ -760,6 +824,14 @@ def get_output_prefix(self): def match(self, bot, pretrigger): args = pretrigger.args text = args[-1] if args else '' + + if not self.match_preconditions(bot, pretrigger): + return [] + + # parse text + return self.parse(text) + + def match_preconditions(self, bot, pretrigger): event = pretrigger.event intent = pretrigger.tags.get('intent') nick = pretrigger.nick @@ -768,20 +840,11 @@ def match(self, bot, pretrigger): event in ["PRIVMSG", "NOTICE"] ) - # check event - if not self.match_event(event): - return [] - - # check intent - if not self.match_intent(intent): - return [] - - # check echo - if is_echo_message and not self.allow_echo(): - return [] - - # parse text - return self.parse(text) + return ( + self.match_event(event) and + self.match_intent(intent) and + (not is_echo_message or self.allow_echo()) + ) def parse(self, text): for regex in self._regexes: @@ -1301,3 +1364,165 @@ def parse(self, text): match = regex.search(text) if match: yield match + + +class URLCallback(Rule): + """URL callback rule definition. + + A URL callback rule (or simply "a URL rule") detects URLs in a trigger + then it uses regular expressions to match at most once per URL per regular + expression, i.e. you can trigger between 0 and the number of regex the URL + callback has per URL in the IRC line. + + Here is an example with a URL rule with the pattern + ``r'https://example\\.com/(.*)'``: + + .. code-block:: irc + + https://example.com/test + You triggered a URL callback, with the "/test" path + and this URL is https://example.com/other can you get it? + You triggered a URL callback, with the "/other" path + + Like generic rules, URL callback rules are not triggered by any specific + name and they don't have aliases. + + .. note:: + + Unlike generic rules and commands, the :func:`~sopel.plugin.url` + decorator expects its decorated function to have the bot and the + trigger with a third parameter: the ``match`` parameter. + + To use this class with an existing URL callback handler, the + :meth:`from_callable` classmethod **must** be used: it will wrap the + handler to work as intended. In that case, the ``trigger`` and the + ``match`` arguments will be the same when the rule executes. + + This behavior makes the ``match`` parameter obsolete, which will be + removed in Sopel 9. + + """ + @classmethod + def from_callable(cls, settings, handler): + execute_handler = handler + url_regexes = getattr(handler, 'url_regex', []) or [] + if not url_regexes: + raise RuntimeError( + 'Invalid URL callback: %s' % handler) + + kwargs = cls.kwargs_from_callable(handler) + + # do we need to handle the match parameter? + # old style URL callback: callable(bot, trigger, match) + # new style: callable(bot, trigger) + match_count = 3 + if inspect.ismethod(handler): + # account for the 'self' parameter when the handler is a method + match_count = 4 + + argspec = inspect_getargspec(handler) + + if len(argspec.args) >= match_count: + @functools.wraps(handler) + def execute_handler(bot, trigger): + return handler(bot, trigger, match=trigger) + + kwargs.update({ + 'handler': execute_handler, + 'schemes': settings.core.auto_url_schemes, + }) + + return cls(url_regexes, **kwargs) + + @classmethod + def from_callable_lazy(cls, settings, handler): + """Instantiate a rule object from a handler with lazy-loaded regexes. + + :param settings: Sopel's settings + :type settings: :class:`sopel.config.Config` + :param callable handler: a function-based rule handler with a + lazy-loader for the regexes + :return: an instance of this class created from the ``handler`` + :rtype: :class:`AbstractRule` + + Similar to the :meth:`from_callable` classmethod, it requires a rule + handlers decorated with :mod:`sopel.plugin`'s decorators. + + Unlike the :meth:`from_callable` classmethod, the regexes are not + already attached to the handler: its loader functions will be used to + get the rule's regexes. See the :func:`sopel.plugin.url_lazy` decorator + for more information about the handler and the loaders' signatures. + + .. seealso:: + + The handler can have more than one loader attached. In that case, + these loaders are chained with :func:`sopel.tools.chain_loaders`. + + """ + url_lazy_loaders = getattr(handler, 'url_lazy_loaders', []) + if not url_lazy_loaders: + raise RuntimeError( + 'Invalid lazy loader URL callback: %s' % handler) + + loader = tools.chain_loaders(*url_lazy_loaders) + regexes = loader(settings) + + if not regexes: + raise RuntimeError( + 'Invalid lazy loader URL callback: %s' % handler) + + kwargs = cls.kwargs_from_callable(handler) + kwargs.update({ + 'handler': handler, + 'schemes': settings.core.auto_url_schemes, + }) + + return cls(regexes, **kwargs) + + def __init__(self, + regexes, + schemes=None, + **kwargs): + super(URLCallback, self).__init__(regexes, **kwargs) + # prevent mutability of registered schemes + self._schemes = tuple(schemes or URL_DEFAULT_SCHEMES) + + def match(self, bot, pretrigger): + """Match URL(s) in a pretrigger according to the rule. + + :param bot: Sopel instance + :type bot: :class:`sopel.bot.Sopel` + :param pretrigger: Line to match + :type pretrigger: :class:`sopel.trigger.PreTrigger` + + This method looks for :attr:`URLs in the IRC line + `, and for each it yields + :ref:`match objects ` using its regexes. + + .. seealso:: + + To detect URLs, this method uses the + :attr:`core.auto_url_schemes + ` option. + + """ + if not self.match_preconditions(bot, pretrigger): + return + + urls = ( + url + for url in pretrigger.urls + if urlparse(url).scheme in self._schemes + ) + + # Parse URL for each found + for url in urls: + # TODO: convert to 'yield from' when dropping Python 2.7 + for result in self.parse(url): + yield result + + def parse(self, text): + for regex in self._regexes: + result = regex.search(text) + if result: + yield result diff --git a/sopel/test_tools.py b/sopel/test_tools.py index 1a703d9836..4fa0dcbb15 100644 --- a/sopel/test_tools.py +++ b/sopel/test_tools.py @@ -166,6 +166,7 @@ def test(configfactory, botfactory, ircfactory): owner=owner, ) settings = configfactory('default.cfg', test_config) + url_schemes = settings.core.auto_url_schemes bot = botfactory(settings) server = ircfactory(bot) server.channel_joined('#Sopel') @@ -184,7 +185,8 @@ def test(configfactory, botfactory, ircfactory): # TODO enable message tags full_message = ':{} PRIVMSG {} :{}'.format(hostmask, sender, msg) - pretrigger = sopel.trigger.PreTrigger(bot.nick, full_message) + pretrigger = sopel.trigger.PreTrigger( + bot.nick, full_message, url_schemes=url_schemes) trigger = sopel.trigger.Trigger(bot.settings, pretrigger, match) pattern = re.compile(r'^%s: ' % re.escape(bot.nick)) @@ -206,7 +208,8 @@ def isnt_ignored(value): tested_func(wrapper, trigger) output_triggers = ( - sopel.trigger.PreTrigger(bot.nick, message.decode('utf-8')) + sopel.trigger.PreTrigger( + bot.nick, message.decode('utf-8'), url_schemes=url_schemes) for message in wrapper.backend.message_sent ) output_texts = ( diff --git a/sopel/tests/factories.py b/sopel/tests/factories.py index 6bfe580451..0e59c850f1 100644 --- a/sopel/tests/factories.py +++ b/sopel/tests/factories.py @@ -93,9 +93,10 @@ def wrapper(self, mockbot, raw, pattern=None): return bot.SopelWrapper(mockbot, trigger) def __call__(self, mockbot, raw, pattern=None): + url_schemes = mockbot.settings.core.auto_url_schemes return trigger.Trigger( mockbot.settings, - trigger.PreTrigger(mockbot.nick, raw), + trigger.PreTrigger(mockbot.nick, raw, url_schemes=url_schemes), re.match(pattern or r'.*', raw)) diff --git a/sopel/tools/__init__.py b/sopel/tools/__init__.py index 55f83dde6a..735c507118 100644 --- a/sopel/tools/__init__.py +++ b/sopel/tools/__init__.py @@ -769,3 +769,30 @@ def get_raising_file_and_line(tb=None): filename, lineno, _context, _line = traceback.extract_tb(tb)[-1] return filename, lineno + + +def chain_loaders(*lazy_loaders): + """Chain lazy loaders into one. + + :param lazy_loaders: one or more lazy loader functions + :type lazy_loaders: :term:`function` + :return: a lazy loader that combines all of the given ones + :rtype: :term:`function` + + This function takes any number of lazy loaders as arguments and merges them + together into one. It's primarily a helper for lazy rule decorators such as + :func:`sopel.plugin.url_lazy`. + + .. important:: + + This function doesn't check the uniqueness of regexes generated by + all the loaders. + + """ + def chained_loader(settings): + return [ + regex + for lazy_loader in lazy_loaders + for regex in lazy_loader(settings) + ] + return chained_loader diff --git a/sopel/trigger.py b/sopel/trigger.py index 3ebbe95159..f5fff35193 100644 --- a/sopel/trigger.py +++ b/sopel/trigger.py @@ -7,6 +7,7 @@ import sys from sopel import tools +from sopel.tools import web __all__ = [ @@ -24,6 +25,7 @@ class PreTrigger(object): :param str own_nick: the bot's own IRC nickname :param str line: the full line from the server + :param tuple url_schemes: allowed schemes for URL detection At the :class:`PreTrigger` stage, the line has not been matched against any rules yet. This is what Sopel uses to perform matching. @@ -80,6 +82,14 @@ class PreTrigger(object): For lines that do *not* contain ``:``, :attr:`text` will be the last argument in :attr:`args` instead. + .. py:attribute:: urls + :type: tuple + + List of URLs found in the :attr:`text`. + + This is for ``PRIVMSG`` and ``NOTICE`` messages only. For other + messages, this will be an empty ``tuple``. + .. py:attribute:: time The time when the message was received. @@ -95,9 +105,10 @@ class PreTrigger(object): component_regex = re.compile(r'([^!]*)!?([^@]*)@?(.*)') intent_regex = re.compile('\x01(\\S+) ?(.*)\x01') - def __init__(self, own_nick, line): + def __init__(self, own_nick, line, url_schemes=None): line = line.strip('\r\n') self.line = line + self.urls = tuple() # Break off IRCv3 message tags, if present self.tags = {} @@ -170,6 +181,10 @@ def __init__(self, own_nick, line): self.tags['intent'] = intent self.args[-1] = message or '' + # Search URLs after CTCP parsing + self.urls = tuple( + web.search_urls(self.args[-1], schemes=url_schemes)) + # Populate account from extended-join messages if self.event == 'JOIN' and len(self.args) == 3: # Account is the second arg `...JOIN #Sopel account :realname` @@ -315,6 +330,14 @@ class Trigger(unicode): example, when setting ``mode -m`` on the channel ``#example``, args would be ``('#example', '-m')`` """ + urls = property(lambda self: self._pretrigger.urls) + """A tuple containing all URLs found in the text. + + :type: tuple + + URLs are listed only for ``PRIVMSG`` or a ``NOTICE``, otherwise this is + an empty tuple. + """ tags = property(lambda self: self._pretrigger.tags) """A map of the IRCv3 message tags on the message. diff --git a/test/modules/test_modules_url.py b/test/modules/test_modules_url.py index 1a7381c3c2..a2c1fbf6d9 100644 --- a/test/modules/test_modules_url.py +++ b/test/modules/test_modules_url.py @@ -2,11 +2,22 @@ """Tests for Sopel's ``url`` plugin""" from __future__ import absolute_import, division, print_function, unicode_literals +import re + import pytest +from sopel import bot, loader, module, plugin, plugins, trigger from sopel.modules import url +TMP_CONFIG = """ +[core] +owner = testnick +nick = TestBot +enable = coretasks +""" + + INVALID_URLS = ( "http://.example.com/", # empty label "http://example..com/", # empty label @@ -14,7 +25,82 @@ ) +@pytest.fixture +def mockbot(configfactory): + tmpconfig = configfactory('test.cfg', TMP_CONFIG) + url_plugin = plugins.handlers.PyModulePlugin('url', 'sopel.modules') + + # setup the bot + sopel = bot.Sopel(tmpconfig) + url_plugin.load() + url_plugin.setup(sopel) + url_plugin.register(sopel) + + @module.url(re.escape('https://example.com/') + r'(.+)') + @plugin.label('handle_urls_https') + def url_callback_https(bot, trigger, match): + pass + + @module.url(re.escape('http://example.com/') + r'(.+)') + @plugin.label('handle_urls_http') + def url_callback_http(bot, trigger, match): + pass + + # prepare callables to be registered + callables = [ + url_callback_https, + url_callback_http, + ] + + # clean callables and set plugin name by hand + # since the loader and plugin handlers are excluded here + for handler in callables: + loader.clean_callable(handler, tmpconfig) + handler.plugin_name = 'testplugin' + + # register callables + sopel.register_urls(callables) + + # manually register URL Callback + pattern = re.escape('https://help.example.com/') + r'(.+)' + + def callback(bot, trigger, match): + pass + + sopel.register_url_callback(pattern, callback) + return sopel + + @pytest.mark.parametrize("site", INVALID_URLS) def test_find_title_invalid(site): # All local for invalid ones assert url.find_title(site) is None + + +def test_check_callbacks(mockbot): + """Test that check_callbacks works with both new & legacy URL callbacks.""" + assert url.check_callbacks(mockbot, 'https://example.com/test') + assert url.check_callbacks(mockbot, 'http://example.com/test') + assert url.check_callbacks(mockbot, 'https://help.example.com/test') + assert not url.check_callbacks(mockbot, 'https://not.example.com/test') + + +def test_url_triggers_rules_and_auto_title(mockbot): + line = ':Foo!foo@example.com PRIVMSG #sopel :https://not.example.com/test' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = mockbot.rules.get_triggered_rules(mockbot, pretrigger) + + assert len(results) == 1, 'Only one should match' + result = results[0] + assert isinstance(result[0], plugins.rules.Rule) + assert result[0].get_rule_label() == 'title_auto' + + line = ':Foo!foo@example.com PRIVMSG #sopel :https://example.com/test' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = mockbot.rules.get_triggered_rules(mockbot, pretrigger) + + assert len(results) == 2, ( + 'Two rules should match: title_auto and handle_urls_https') + labels = sorted(result[0].get_rule_label() for result in results) + expected = ['handle_urls_https', 'title_auto'] + assert labels == expected diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py index 5f1e6c8252..8f64f1aa6a 100644 --- a/test/plugins/test_plugins_rules.py +++ b/test/plugins/test_plugins_rules.py @@ -206,6 +206,34 @@ def test_manager_rule_and_command(mockbot): assert list(manager.get_all_nick_commands()) == [] +def test_manager_url_callback(mockbot): + regex = re.compile(r'https://example\.com/.*') + rule = rules.URLCallback([regex], plugin='testplugin', label='testrule') + manager = rules.Manager() + manager.register_url_callback(rule) + + assert manager.has_url_callback('testrule') + assert manager.has_url_callback('testrule', plugin='testplugin') + assert not manager.has_url_callback('testrule', plugin='not-plugin') + + line = ':Foo!foo@example.com PRIVMSG #sopel :https://example.com/' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + + items = manager.get_triggered_rules(mockbot, pretrigger) + assert len(items) == 1, 'Exactly one rule must match' + + result = items[0] + assert len(result) == 2, 'Result must contain two items: (rule, match)' + + result_rule, result_match = items[0] + assert result_rule == rule + assert result_match.group(0) == 'https://example.com/' + + assert manager.check_url_callback(mockbot, 'https://example.com/') + assert manager.check_url_callback(mockbot, 'https://example.com/test') + assert not manager.check_url_callback(mockbot, 'https://not-example.com/') + + def test_manager_unregister_plugin(mockbot): regex = re.compile('.*') a_rule = rules.Rule([regex], plugin='plugin_a', label='the_rule') @@ -239,6 +267,40 @@ def test_manager_unregister_plugin(mockbot): assert b_command in items[1] +def test_manager_unregister_plugin_url_callbacks(mockbot): + regex = re.compile('.*') + a_rule = rules.Rule([regex], plugin='plugin_a', label='the_rule') + b_rule = rules.Rule([regex], plugin='plugin_b', label='the_rule') + a_callback = rules.URLCallback([regex], plugin='plugin_a', label='the_url') + b_callback = rules.URLCallback([regex], plugin='plugin_b', label='the_url') + + manager = rules.Manager() + manager.register(a_rule) + manager.register_url_callback(a_callback) + manager.register(b_rule) + manager.register_url_callback(b_callback) + + line = ':Foo!foo@example.com PRIVMSG #sopel :https://example.com/test' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + + items = manager.get_triggered_rules(mockbot, pretrigger) + assert len(items) == 4, 'All 4 rules must match' + assert manager.has_rule('the_rule') + assert manager.has_url_callback('the_url') + + result = manager.unregister_plugin('plugin_a') + assert result == 2 + assert manager.has_rule('the_rule') + assert not manager.has_rule('the_rule', plugin='plugin_a') + assert manager.has_url_callback('the_url') + assert not manager.has_url_callback('the_url', plugin='plugin_a') + + items = manager.get_triggered_rules(mockbot, pretrigger) + assert len(items) == 2, 'Only 2 must match by now' + assert b_rule in items[0] + assert b_callback in items[1] + + def test_manager_unregister_plugin_unknown(): regex = re.compile('.*') a_rule = rules.Rule([regex], plugin='plugin_a', label='the_rule') @@ -2311,3 +2373,303 @@ def handler(wrapped, trigger): assert len(results) == 1, 'The rule must match once from anywhere' assert results[0].group(0) == 'hey' + + +# ----------------------------------------------------------------------------- +# tests for :class:`sopel.plugins.rules.URLCallback` + + +def test_url_callback_str(): + regex = re.compile(r'.*') + rule = rules.URLCallback([regex], plugin='testplugin', label='testrule') + + assert str(rule) == '' + + +def test_url_callback_str_no_plugin(): + regex = re.compile(r'.*') + rule = rules.URLCallback([regex], label='testrule') + + assert str(rule) == '' + + +def test_url_callback_str_no_label(): + regex = re.compile(r'.*') + rule = rules.URLCallback([regex], plugin='testplugin') + + assert str(rule) == '' + + +def test_url_callback_str_no_plugin_label(): + regex = re.compile(r'.*') + rule = rules.URLCallback([regex]) + + assert str(rule) == '' + + +def test_url_callback_parse(): + # playing with regex + regex = re.compile( + re.escape('https://wikipedia.com/') + r'(\w+)' + ) + + rule = rules.SearchRule([regex]) + results = list(rule.parse('https://wikipedia.com/something')) + assert len(results) == 1, 'URLCallback on word must match only once' + assert results[0].group(0) == 'https://wikipedia.com/something' + assert results[0].group(1) == 'something' + + +def test_url_callback_execute(mockbot): + regex = re.compile(r'.*') + rule = rules.URLCallback([regex]) + + line = ( + ':Foo!foo@example.com PRIVMSG #sopel :' + 'some link https://example.com/test in your line' + ) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + matches = list(rule.match(mockbot, pretrigger)) + match = matches[0] + match_trigger = trigger.Trigger( + mockbot.settings, pretrigger, match, account=None) + + with pytest.raises(RuntimeError): + rule.execute(mockbot, match_trigger) + + def handler(wrapped, trigger): + wrapped.say('Hi!') + return 'The return value: %s' % trigger.group(0) + + rule = rules.URLCallback([regex], handler=handler) + matches = list(rule.match(mockbot, pretrigger)) + match = matches[0] + match_trigger = trigger.Trigger( + mockbot.settings, pretrigger, match, account=None) + wrapped = bot.SopelWrapper(mockbot, match_trigger) + result = rule.execute(wrapped, match_trigger) + + assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') + assert result == 'The return value: https://example.com/test' + + +def test_url_callback_match_filter_intent(mockbot): + test_url = 'https://example.com/test' + line = ( + ':Foo!foo@example.com PRIVMSG #sopel :' + '\x01ACTION reads %s\x01' % test_url + ) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + regex = re.compile(re.escape('https://example.com/') + r'(.*)') + + rule = rules.URLCallback([regex]) + matches = list(rule.match(mockbot, pretrigger)) + assert len(matches) == 1, 'Exactly one match must be found' + + match = matches[0] + assert match.group(0) == 'https://example.com/test' + assert match.group(1) == 'test' + + rule = rules.URLCallback([regex], intents=[re.compile(r'ACTION')]) + matches = list(rule.match(mockbot, pretrigger)) + assert len(matches) == 1, 'Exactly one match must be found' + + match = matches[0] + assert match.group(0) == 'https://example.com/test' + assert match.group(1) == 'test' + + rule = rules.URLCallback([regex], intents=[re.compile(r'VERSION')]) + assert not list(rule.match(mockbot, pretrigger)) + + +def test_url_callback_from_callable(mockbot): + base = ':Foo!foo@example.com PRIVMSG #sopel' + link_1 = 'https://example.com/test' + link_2 = 'https://example.com/other' + link_3 = 'https://not-example.com/test' + + # prepare callable + @module.url(re.escape('https://example.com/') + r'(\w+)') + def handler(wrapped, trigger, match): + wrapped.say('Hi!') + return 'The return value: %s' % match.group(0) + + loader.clean_callable(handler, mockbot.settings) + handler.plugin_name = 'testplugin' + + # create rule from a cleaned callable + rule = rules.URLCallback.from_callable(mockbot.settings, handler) + assert str(rule) == '' + + # match on a single link + line = '%s :%s' % (base, link_1) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert all(result.group(0) == link_1 for result in results) + + # match on a two link + line = '%s :%s %s' % (base, link_1, link_2) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 2, 'Exactly 2 rules must match' + assert results[0].group(0) == link_1 + assert results[1].group(0) == link_2 + + # match only once per unique link + line = '%s :%s %s' % (base, link_1, link_1) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert all(result.group(0) == link_1 for result in results) + + # match on a single link with pre-text + line = '%s :there is some pre-text: %s' % (base, link_1) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert all(result.group(0) == link_1 for result in results) + + # match on a single link with post-text + line = '%s :%s and with post-text this time' % (base, link_1) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert all(result.group(0) == link_1 for result in results) + + # match on a single link with surrounding text + line = '%s :before text %s and after text' % (base, link_1) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert all(result.group(0) == link_1 for result in results) + + # execute based on the match + match_trigger = trigger.Trigger( + mockbot.settings, pretrigger, results[0], account=None) + wrapped = bot.SopelWrapper(mockbot, match_trigger) + result = rule.execute(wrapped, match_trigger) + + assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') + assert result == 'The return value: https://example.com/test' + + # does not match an invalid link + line = '%s :%s' % (base, link_3) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + assert not any(rule.match(mockbot, pretrigger)) + + +def test_url_callback_from_callable_no_match_parameter(mockbot): + base = ':Foo!foo@example.com PRIVMSG #sopel' + link = 'https://example.com/test' + + # prepare callable + @module.url(re.escape('https://example.com/') + r'(\w+)') + def handler(wrapped, trigger): + wrapped.say('Hi!') + return 'The return value: %s' % trigger.group(0) + + loader.clean_callable(handler, mockbot.settings) + handler.plugin_name = 'testplugin' + + # create rule from a cleaned callable + rule = rules.URLCallback.from_callable(mockbot.settings, handler) + assert str(rule) == '' + + # execute based on the match + line = '%s :before text %s and after text' % (base, link) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + match_trigger = trigger.Trigger( + mockbot.settings, pretrigger, results[0], account=None) + wrapped = bot.SopelWrapper(mockbot, match_trigger) + result = rule.execute(wrapped, match_trigger) + + assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') + assert result == 'The return value: https://example.com/test' + + +def test_url_callback_from_callable_lazy(mockbot): + base = ':Foo!foo@example.com PRIVMSG #sopel' + link_1 = 'https://example.com/test' + link_2 = 'https://help.example.com/other' + link_3 = 'https://not-example.com/test' + + def lazy_loader(settings): + return [ + re.compile(re.escape('https://example.com/') + r'(\w+)'), + re.compile(re.escape('https://help.example.com/') + r'(\w+)'), + ] + + # prepare callable + @plugin.url_lazy(lazy_loader) + def handler(wrapped, trigger): + wrapped.say('Hi!') + return 'The return value: %s' % trigger.group(0) + + loader.clean_callable(handler, mockbot.settings) + handler.plugin_name = 'testplugin' + + # create rule from a cleaned callable + rule = rules.URLCallback.from_callable_lazy(mockbot.settings, handler) + assert str(rule) == '' + + # match on the example.com link + line = '%s :%s' % (base, link_1) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + print(list(result.group(0) for result in results)) + assert all(result.group(0) == link_1 for result in results) + + # match on the help.example.com link + line = '%s :%s' % (base, link_2) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + results = list(rule.match(mockbot, pretrigger)) + + assert len(results) == 1, 'Exactly 1 rule must match' + assert all(result.group(0) == link_2 for result in results) + + # does not match an invalid link + line = '%s :%s' % (base, link_3) + pretrigger = trigger.PreTrigger(mockbot.nick, line) + assert not any(rule.match(mockbot, pretrigger)) + + +def test_url_callback_from_callable_invalid(mockbot): + def url_loader(settings): + return [ + re.compile(re.escape('https://example.com/') + r'(\w+)'), + ] + + # prepare callable + @plugin.url_lazy(url_loader) + def handler(wrapped, trigger, match=None): + wrapped.reply('Hi!') + + loader.clean_callable(handler, mockbot.settings) + + # create rule from a cleaned callable + with pytest.raises(RuntimeError): + rules.URLCallback.from_callable(mockbot.settings, handler) + + +def test_url_callback_from_callable_lazy_invalid(mockbot): + # prepare callable + @module.url(r'.*') + def handler(wrapped, trigger, match=None): + wrapped.reply('Hi!') + + loader.clean_callable(handler, mockbot.settings) + + # create rule from a cleaned callable + with pytest.raises(RuntimeError): + rules.URLCallback.from_callable_lazy(mockbot.settings, handler) diff --git a/test/test_bot.py b/test/test_bot.py index 63bc0def44..f2174d3e16 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -271,7 +271,8 @@ def test_register_plugin(tmpconfig, mockplugin): assert sopel.rules.has_nick_command('info', plugin='mockplugin') assert sopel.rules.has_action_command('tell') assert sopel.rules.has_action_command('tell', plugin='mockplugin') - assert list(sopel.search_url_callbacks('example.com')) + assert sopel.rules.has_url_callback('example_url') + assert sopel.rules.has_url_callback('example_url', plugin='mockplugin') def test_register_unregister_plugin(tmpconfig, mockplugin): @@ -512,6 +513,51 @@ def on_join(bot, trigger): } +def test_register_urls(tmpconfig): + sopel = bot.Sopel(tmpconfig) + + @module.url(r'https://(\S+)/(.+)?') + @plugin.label('handle_urls_https') + def url_callback_https(bot, trigger, match): + pass + + @module.url(r'http://(\S+)/(.+)?') + @plugin.label('handle_urls_http') + def url_callback_http(bot, trigger, match): + pass + + # prepare callables to be registered + callables = [ + url_callback_https, + url_callback_http, + ] + + # clean callables and set plugin name by hand + # since the loader and plugin handlers are excluded here + for handler in callables: + loader.clean_callable(handler, tmpconfig) + handler.plugin_name = 'testplugin' + + # register callables + sopel.register_urls(callables) + + # trigger URL callback "handle_urls_https" + line = ':Foo!foo@example.com PRIVMSG #sopel :https://example.com/test' + pretrigger = trigger.PreTrigger(sopel.nick, line) + + matches = sopel.rules.get_triggered_rules(sopel, pretrigger) + assert len(matches) == 1 + assert matches[0][0].get_rule_label() == 'handle_urls_https' + + # trigger URL callback "handle_urls_https" + line = ':Foo!foo@example.com PRIVMSG #sopel :http://example.com/test' + pretrigger = trigger.PreTrigger(sopel.nick, line) + + matches = sopel.rules.get_triggered_rules(sopel, pretrigger) + assert len(matches) == 1 + assert matches[0][0].get_rule_label() == 'handle_urls_http' + + # ----------------------------------------------------------------------------- # call_rule diff --git a/test/test_loader.py b/test/test_loader.py index 5251c03a96..ff34c12d24 100644 --- a/test/test_loader.py +++ b/test/test_loader.py @@ -11,7 +11,10 @@ MOCK_MODULE_CONTENT = """# coding=utf-8 +import re + import sopel.module +import sopel.plugin @sopel.module.commands("first") @@ -35,7 +38,16 @@ def interval10s(bot): @sopel.module.url(r'.\\.example\\.com') -def example_url(bot): +def example_url(bot, trigger, match=None): + pass + + +def loader(settings): + return [re.compile(r'.+\\.example\\.com')] + + +@sopel.plugin.url_lazy(loader) +def example_url_lazy(bot, trigger): pass @@ -98,6 +110,7 @@ def test_is_limitable(testplugin): assert not loader.is_limitable(test_mod.shutdown) assert loader.is_limitable(test_mod.example_url) + assert loader.is_limitable(test_mod.example_url_lazy) def test_is_triggerable(testplugin): @@ -114,6 +127,25 @@ def test_is_triggerable(testplugin): assert not loader.is_triggerable(test_mod.shutdown) assert not loader.is_triggerable(test_mod.example_url) + assert not loader.is_triggerable(test_mod.example_url_lazy) + + +def test_is_url_callback(testplugin): + """Test is_triggerable behavior before clean_module is called.""" + testplugin.load() + test_mod = testplugin._module + + assert not loader.is_url_callback(test_mod.first_command) + assert not loader.is_url_callback(test_mod.second_command) + assert not loader.is_url_callback(test_mod.on_topic_command) + + assert not loader.is_url_callback(test_mod.interval5s) + assert not loader.is_url_callback(test_mod.interval10s) + + assert not loader.is_url_callback(test_mod.shutdown) + + assert loader.is_url_callback(test_mod.example_url) + assert loader.is_url_callback(test_mod.example_url_lazy) def test_clean_module(testplugin, tmpconfig): @@ -132,8 +164,9 @@ def test_clean_module(testplugin, tmpconfig): assert test_mod.interval10s in jobs assert len(shutdowns) assert test_mod.shutdown in shutdowns - assert len(urls) == 1 + assert len(urls) == 2 assert test_mod.example_url in urls + assert test_mod.example_url_lazy in urls # assert is_triggerable behavior *after* clean_module has been called assert loader.is_triggerable(test_mod.first_command) @@ -145,6 +178,7 @@ def test_clean_module(testplugin, tmpconfig): assert not loader.is_triggerable(test_mod.shutdown) assert not loader.is_triggerable(test_mod.example_url) + assert not loader.is_triggerable(test_mod.example_url_lazy) # ignored function is ignored assert test_mod.ignored not in callables @@ -164,7 +198,7 @@ def test_clean_module_idempotency(testplugin, tmpconfig): assert len(callables) == 3 assert len(jobs) == 2 assert len(shutdowns) == 1 - assert len(urls) == 1 + assert len(urls) == 2 # recall clean_module, we should have the same result new_callables, new_jobs, new_shutdowns, new_urls = loader.clean_module( diff --git a/test/test_plugin.py b/test/test_plugin.py index c9a55577ab..8e4ff4393b 100644 --- a/test/test_plugin.py +++ b/test/test_plugin.py @@ -56,3 +56,46 @@ def test_search_multiple(): def mock(bot, trigger, match): return True assert mock.search_rules == [r'\w+', '.*', r'\d+'] + + +def test_url_lazy(): + def loader(settings): + return [r'\w+', '.*', r'\d+'] + + @plugin.url_lazy(loader) + def mock(bot, trigger, match): + return True + + assert mock.url_lazy_loaders == [loader] + assert not hasattr(mock, 'url_regex') + + +def test_url_lazy_args(): + def loader_1(settings): + return [r'\w+', '.*', r'\d+'] + + def loader_2(settings): + return [r'[a-z]+'] + + @plugin.url_lazy(loader_1, loader_2) + def mock(bot, trigger, match): + return True + + assert mock.url_lazy_loaders == [loader_1, loader_2] + assert not hasattr(mock, 'url_regex') + + +def test_url_lazy_multiple(): + def loader_1(settings): + return [r'\w+', '.*', r'\d+'] + + def loader_2(settings): + return [r'[a-z]+'] + + @plugin.url_lazy(loader_2) + @plugin.url_lazy(loader_1) + def mock(bot, trigger, match): + return True + + assert mock.url_lazy_loaders == [loader_1, loader_2] + assert not hasattr(mock, 'url_regex') diff --git a/test/test_tools.py b/test/test_tools.py index 878686f7b0..df73745ea3 100644 --- a/test/test_tools.py +++ b/test/test_tools.py @@ -10,6 +10,13 @@ from sopel import tools from sopel.tools.time import seconds_to_human +TMP_CONFIG = """ +[core] +owner = testnick +nick = TestBot +enable = coretasks +""" + @pytest.fixture def nick(): @@ -192,3 +199,22 @@ def test_time_timedelta_formatter(): payload = timedelta(hours=-4) assert seconds_to_human(payload) == 'in 4 hours' + + +def test_chain_loaders(configfactory): + re_numeric = re.compile(r'\d+') + re_text = re.compile(r'\w+') + settings = configfactory('test.cfg', TMP_CONFIG) + + def loader_numeric(settings): + return [re_numeric] + + def loader_text(settings): + return [re_text] + + loader = tools.chain_loaders(loader_numeric, loader_text) + + assert callable(loader) + results = loader(settings) + + assert results == [re_numeric, re_text]