From ccbcfa7d4d07cff78d98ccb80d03d0e3d4879896 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 10 Apr 2023 03:16:53 +0200 Subject: [PATCH 1/2] sopel, docs, test: replace SopelWrapper with contextvars For ease of use, Sopel provides a SopelWrapper to plugin callable so they can use ``bot.say(message)`` without having to specify the destination: the wrapper will look at the trigger's sender and send the message back to it (be it a channel or a nick). However, this has the disavantage of having to 1. maintain a separate class with dunder methods (boo, magic!), and 2. it makes static typing more complex. Welcome Python 3.7 and its new built-in module: contextvars. This wonderful utility module provides a thread-safe and asyncio-safe object that can have a context specific value. Sopel now uses this feature to bind itself to: * a trigger that matches a rule * and the triggered rule so now it can be used instead of a SopelWrapper instance. The biggest changes are on ``call_rule``, which doesn't require anymore a SopelWrapper instance, and on the tests, in particular on the trigger factory's wrapped method, which now returns a Sopel instance. The second side-effect on test is that now it is not possible to bind a trigger multiple times on a test bot, and tests have to rely on Sopel.sopel_wrapper context manager (as Sopel.call_rule does), which requires a Rule object. This is not perfect, albeit manageable. In the future, it would be nice to have a better testing utility, maybe a context manager to do someething like: ``` def some_test(mockbot, triggerfactory): with triggerfactory.wraps(mockbot, "PRIVMSG #channel Nick :text") as wrapped: wrapped.say('something') ``` Wouldn't it be cool? --- docs/source/package/bot.rst | 1 + docs/source/plugin/advanced.rst | 2 +- docs/source/plugin/anatomy.rst | 4 +- docs/source/plugin/bot/talk.rst | 15 +- sopel/bot.py | 560 +++++++++++++++++++--- sopel/coretasks.py | 26 +- sopel/irc/capabilities.py | 12 +- sopel/modules/isup.py | 2 +- sopel/modules/remind.py | 2 +- sopel/modules/safety.py | 10 +- sopel/modules/url.py | 20 +- sopel/plugin.py | 16 +- sopel/plugins/capabilities.py | 10 +- sopel/plugins/rules.py | 6 +- sopel/tests/factories.py | 5 +- sopel/tests/pytest_plugin.py | 10 +- sopel/trigger.py | 7 +- test/coretasks/test_coretasks_cap.py | 10 +- test/irc/test_irc_capabilities.py | 234 +++++---- test/modules/test_modules_isup.py | 2 +- test/plugins/test_plugins_capabilities.py | 63 ++- test/plugins/test_plugins_rules.py | 30 +- test/test_bot.py | 72 +-- test/test_module.py | 3 +- test/test_plugin.py | 26 +- 25 files changed, 831 insertions(+), 317 deletions(-) diff --git a/docs/source/package/bot.rst b/docs/source/package/bot.rst index 1382341d1e..3691a4f006 100644 --- a/docs/source/package/bot.rst +++ b/docs/source/package/bot.rst @@ -6,3 +6,4 @@ The bot and its state :members: :inherited-members: :show-inheritance: + :exclude-members: SopelWrapper diff --git a/docs/source/plugin/advanced.rst b/docs/source/plugin/advanced.rst index 0d2579e00e..22013ff6ba 100644 --- a/docs/source/plugin/advanced.rst +++ b/docs/source/plugin/advanced.rst @@ -237,7 +237,7 @@ automatically: @plugin.thread(False) @plugin.unblockable @plugin.priority('medium') - def sasl_success(bot: SopelWrapper, trigger: Trigger): + def sasl_success(bot: Sopel, trigger: Trigger): """Resume capability negotiation on successful SASL auth.""" LOGGER.info("Successful SASL Auth.") bot.resume_capability_negotiation( diff --git a/docs/source/plugin/anatomy.rst b/docs/source/plugin/anatomy.rst index aedb7eb1db..68a7eb34f8 100644 --- a/docs/source/plugin/anatomy.rst +++ b/docs/source/plugin/anatomy.rst @@ -204,12 +204,12 @@ the same interface: .. py:function:: plugin_callable(bot, trigger) :param bot: wrapped bot instance - :type bot: :class:`sopel.bot.SopelWrapper` + :type bot: :class:`sopel.bot.Sopel` :param trigger: the object that triggered the call :type trigger: :class:`sopel.trigger.Trigger` A callable must accept two positional arguments: a -:class:`bot ` object, and a +:class:`bot ` object, and a :class:`trigger ` object. Both are tied to the specific message that matches the rule. diff --git a/docs/source/plugin/bot/talk.rst b/docs/source/plugin/bot/talk.rst index 718df8301e..2d1b767a1b 100644 --- a/docs/source/plugin/bot/talk.rst +++ b/docs/source/plugin/bot/talk.rst @@ -4,9 +4,9 @@ Make it talk ============ The most basic way to make the bot talk is to use its -:meth:`~sopel.bot.SopelWrapper.say` method. The wrapper knows the origin of -the trigger (a channel or a private message), and it will use this origin as -the default destination for your message:: +:meth:`~sopel.bot.Sopel.say` method. The wrapper knows the origin of the +trigger (a channel or a private message), and it will use this origin as the +default destination for your message:: # will send that to trigger.sender bot.say('The bot is now talking!') @@ -41,7 +41,7 @@ rule is triggered, you can use the bot's settings:: The ``say`` method sends a ``PRIVMSG`` command to the IRC server. To send a ``NOTICE`` command instead, you need to use the - :meth:`~sopel.bot.SopelWrapper.notice` method instead. + :meth:`~sopel.bot.Sopel.notice` method instead. Make it reply @@ -57,9 +57,8 @@ shortcut for that:: bot.reply('ping!') -As with the ``say`` method seen above, the -:meth:`~sopel.bot.SopelWrapper.reply` method can send your message to another -destination:: +As with the ``say`` method seen above, the :meth:`~sopel.bot.Sopel.reply` +method can send your message to another destination:: bot.reply('ping!', '#another-channel') @@ -90,6 +89,6 @@ Besides talking, the bot can also **act**: * and even to :meth:`~sopel.bot.Sopel.quit` the server, Oh, and let's not forget about ``/me does something``, which can be done with -the :meth:`~sopel.bot.SopelWrapper.action` method:: +the :meth:`~sopel.bot.Sopel.action` method:: bot.action('does something') diff --git a/sopel/bot.py b/sopel/bot.py index 20992a859d..247e30a18b 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -8,6 +8,8 @@ from __future__ import annotations from ast import literal_eval +import contextlib +import contextvars from datetime import datetime import inspect import itertools @@ -18,9 +20,13 @@ from types import MappingProxyType from typing import ( Any, + Callable, Dict, + Generator, Iterable, + List, Mapping, + MutableMapping, Optional, Tuple, TYPE_CHECKING, @@ -40,24 +46,43 @@ if TYPE_CHECKING: from sopel.trigger import PreTrigger + from sopel.config import Config + from sopel.tools.target import User -__all__ = ['Sopel', 'SopelWrapper'] +__all__ = ['Sopel'] LOGGER = logging.getLogger(__name__) class Sopel(irc.AbstractBot): - def __init__(self, config, daemon=False): + def __init__(self, config: Config, daemon: bool = False): super().__init__(config) self._daemon = daemon # Used for iPython. TODO something saner here - self._running_triggers = [] + self._running_triggers: List[threading.Thread] = [] self._running_triggers_lock = threading.Lock() self._plugins: Dict[str, Any] = {} self._rules_manager = plugin_rules.Manager() self._cap_requests_manager = plugin_capabilities.Manager() self._scheduler = plugin_jobs.Scheduler(self) + self._trigger_context: contextvars.ContextVar[ + Trigger + ] = contextvars.ContextVar('_trigger_context') + """Context var for a trigger object. + + This context is used to bind the bot to a trigger within a specific + context. + """ + self._rule_context: contextvars.ContextVar[ + plugin_rules.AbstractRule + ] = contextvars.ContextVar('_rule_context') + """Context var for a rule object. + + This context is used to bind the bot to a plugin rule within a specific + context. + """ + self._url_callbacks = tools.SopelMemory() """Tracking of manually registered URL callbacks. @@ -67,7 +92,7 @@ def __init__(self, config, daemon=False): Remove in Sopel 9, along with the above related methods. """ - self._times = {} + self._times: Dict[str, Dict[Callable, float]] = {} """ A dictionary mapping lowercased nicks to dictionaries which map function names to the time which they were last used by that nick. @@ -86,7 +111,7 @@ def __init__(self, config, daemon=False): which contain the users in the channel and their permissions. """ - self.users = tools.SopelIdentifierMemory( + self.users: MutableMapping[str, User] = tools.SopelIdentifierMemory( identifier_factory=self.make_identifier, ) """A map of the users that Sopel is aware of. @@ -106,7 +131,7 @@ def __init__(self, config, daemon=False): plugins. See :class:`sopel.tools.memories.SopelMemory`. """ - self.shutdown_methods = [] + self.shutdown_methods: List[Callable] = [] """List of methods to call on shutdown.""" @property @@ -192,8 +217,12 @@ def hostmask(self) -> Optional[str]: if not self.users or self.nick not in self.users: # bot must be connected and in at least one channel return None + user: Optional[User] = self.users.get(self.nick) + + if not user: + return None - return self.users.get(self.nick).hostmask + return user.hostmask @property def plugins(self) -> Mapping[str, plugins.handlers.AbstractPluginHandler]: @@ -227,6 +256,120 @@ def has_channel_privilege(self, channel, privilege) -> bool: return self.channels[channel].has_privilege(self.nick, privilege) + # trigger binding + + @property + def trigger(self) -> Optional[Trigger]: + """Context bound trigger. + + .. seealso:: + + The :meth:`bind_trigger` method must be used to have a context + bound trigger. + """ + return self._trigger_context.get(None) + + @property + def rule(self) -> Optional[plugin_rules.AbstractRule]: + """Context bound plugin rule. + + .. seealso:: + + The :meth:`bind_rule` method must be used to have a context bound + plugin rule. + """ + return self._rule_context.get(None) + + @property + def default_destination(self) -> Optional[str]: + """Default say/reply destination. + + :return: the channel (with status prefix) or nick to send messages to + + This property returns the :class:`str` version of the destination that + will be used by default by these methods: + + * :meth:`say` + * :meth:`reply` + * :meth:`action` + * :meth:`notice` + + For a channel, it also ensures that the status-specific prefix is added + to the result, so the bot replies with the same status. + """ + if not self.trigger or not self.trigger.sender: + return None + + trigger = self.trigger + + # ensure str and not Identifier + destination = str(trigger.sender) + + # prepend status prefix if it exists + if trigger.status_prefix: + destination = trigger.status_prefix + destination + + return destination + + @property + def output_prefix(self) -> str: + """Rule output prefix. + + :return: an output prefix for the current rule (if bound) + """ + if self.rule is None: + return '' + + return self.rule.get_output_prefix() + + def bind_trigger(self, trigger: Trigger) -> contextvars.Token: + """Bind the bot to a ``trigger`` instance. + + :param trigger: the trigger instance to bind the bot with + :raise RuntimeError: when the bot is already bound to a trigger + + Bind the bot to a ``trigger`` so it can be used in the context of a + plugin callable execution. + + Once the plugin callable is done, a call to :meth:`unbind_trigger` must + be performed to unbind the bot. + """ + if self.trigger is not None: + raise RuntimeError('Sopel is already bound to a trigger.') + + return self._trigger_context.set(trigger) + + def unbind_trigger(self, token: contextvars.Token) -> None: + """Unbind the bot from the trigger with a context ``token``. + + :param token: the context token returned by :meth:`bind_trigger` + """ + self._trigger_context.reset(token) + + def bind_rule(self, rule: plugin_rules.AbstractRule) -> contextvars.Token: + """Bind the bot to a ``rule`` instance. + + :param rule: the rule instance to bind the bot with + :raise RuntimeError: when the bot is already bound to a rule + + Bind the bot to a ``rule`` so it can be used in the context of its + execution. + + Once the plugin callable is done, a call to :meth:`unbind_rule` must + be performed to unbind the bot. + """ + if self.rule is not None: + raise RuntimeError('Sopel is already bound to a rule.') + + return self._rule_context.set(rule) + + def unbind_rule(self, token: contextvars.Token) -> None: + """Unbind the bot from the rule with a context ``token``. + + :param token: the context token returned by :meth:`bind_rule` + """ + self._rule_context.reset(token) + # setup def setup(self) -> None: @@ -594,80 +737,139 @@ def register_urls(self, urls: Iterable) -> None: # message dispatch + @contextlib.contextmanager + def sopel_wrapper( + self, + trigger: Trigger, + rule: plugin_rules.AbstractRule, + ) -> Generator[Sopel, None, None]: + """Context manager to bind and unbind the bot to a trigger and a rule. + + :param trigger: a trigger to bind the bot to + :param rule: a plugin rule to bind the bot to + :return: yield the bot bound to the trigger and the plugin rule + + Bind the bot to a trigger and a rule for a specific context usage. + This makes the destination and the nick optional when sending messages + with the bot, for example:: + + with bot.sopel_wrapper(trigger, rule): + bot.say('My message') + bot.reply('Yes I am talking to you!') + + This will send both messages to ``trigger.sender``, and the repply will + be prefixed by ``trigger.nick``. + + If the ``rule`` has an output prefix, it will be used in the + :meth:`say` method. + + .. versionadded: 8.0 + + .. seealso:: + + The :meth:`call_rule` method uses this context manager to ensure + that the rule executes with a bot properly bound to the trigger + and the rule that must be executed. + + """ + trigger_token = self.bind_trigger(trigger) + rule_token = self.bind_rule(rule) + try: + yield self + finally: + self.unbind_trigger(trigger_token) + self.unbind_rule(rule_token) + def call_rule( self, rule: plugin_rules.AbstractRule, - sopel: 'SopelWrapper', trigger: Trigger, ) -> None: - nick = trigger.nick - context = trigger.sender - is_channel = context and not context.is_nick() - - # rate limiting - if not trigger.admin and not rule.is_unblockable(): - if rule.is_user_rate_limited(nick): - message = rule.get_user_rate_message(nick) - if message: - sopel.notice(message, destination=nick) - return + """Execute the ``rule`` with the bot bound to it and the ``trigger``. - if is_channel and rule.is_channel_rate_limited(context): - message = rule.get_channel_rate_message(nick, context) - if message: - sopel.notice(message, destination=nick) - return + :param rule: the rule to execute + :param trigger: the trigger that matches the rule - if rule.is_global_rate_limited(): - message = rule.get_global_rate_message(nick) - if message: - sopel.notice(message, destination=nick) - return + Perform all necessary checks before executing the rule. A rule might + not be executed if it is rate limited, or if it is disabled in a + channel by configuration. - # channel config - if is_channel and context in self.config: - channel_config = self.config[context] - plugin_name = rule.get_plugin_name() + The rule is executed with a bot bound to it and the trigger that + matches the rule. - # disable listed plugins completely on provided channel - if 'disable_plugins' in channel_config: - disabled_plugins = channel_config.disable_plugins.split(',') + .. warning:: + + This is an internal method documented to help contribution to the + development of Sopel's core. This should not be used by a plugin + directly. + + """ + with self.sopel_wrapper(trigger, rule) as sopel: + nick = trigger.nick + context = trigger.sender + is_channel = context and not context.is_nick() + + # rate limiting + if not trigger.admin and not rule.is_unblockable(): + if rule.is_user_rate_limited(nick): + message = rule.get_user_rate_message(nick) + if message: + sopel.notice(message, destination=nick) + return - if plugin_name == 'coretasks': - LOGGER.debug("disable_plugins refuses to skip a coretasks handler") - elif '*' in disabled_plugins: + if is_channel and rule.is_channel_rate_limited(context): + message = rule.get_channel_rate_message(nick, context) + if message: + sopel.notice(message, destination=nick) return - elif plugin_name in disabled_plugins: + + if rule.is_global_rate_limited(): + message = rule.get_global_rate_message(nick) + if message: + sopel.notice(message, destination=nick) return - # disable chosen methods from plugins - if 'disable_commands' in channel_config: - disabled_commands = literal_eval(channel_config.disable_commands) - disabled_commands = disabled_commands.get(plugin_name, []) - if rule.get_rule_label() in disabled_commands: - if plugin_name != 'coretasks': + # channel config + if is_channel and context in sopel.config: + channel_config = sopel.config[context] + plugin_name = rule.get_plugin_name() + + # disable listed plugins completely on provided channel + if 'disable_plugins' in channel_config: + disabled_plugins = channel_config.disable_plugins.split(',') + + if plugin_name == 'coretasks': + LOGGER.debug("disable_plugins refuses to skip a coretasks handler") + elif '*' in disabled_plugins: + return + elif plugin_name in disabled_plugins: return - LOGGER.debug("disable_commands refuses to skip a coretasks handler") - try: - rule.execute(sopel, trigger) - except KeyboardInterrupt: - raise - except Exception as error: - self.error(trigger, exception=error) + # disable chosen methods from plugins + if 'disable_commands' in channel_config: + disabled_commands = literal_eval(channel_config.disable_commands) + disabled_commands = disabled_commands.get(plugin_name, []) + if rule.get_rule_label() in disabled_commands: + if plugin_name != 'coretasks': + return + LOGGER.debug("disable_commands refuses to skip a coretasks handler") + + try: + rule.execute(sopel, trigger) + except KeyboardInterrupt: + raise + except Exception as error: + sopel.error(trigger, exception=error) def call( self, func: Any, - sopel: 'SopelWrapper', trigger: Trigger, ) -> None: """Call a function, applying any rate limits or other restrictions. :param func: the function to call :type func: :term:`function` - :param sopel: a SopelWrapper instance - :type sopel: :class:`SopelWrapper` :param Trigger trigger: the Trigger object for the line from the server that triggered this call """ @@ -750,11 +952,12 @@ def call( ) return - try: - exit_code = func(sopel, trigger) - except Exception as error: # TODO: Be specific - exit_code = None - self.error(trigger, exception=error) + with self.sopel_wrapper(trigger, func) as sopel: + try: + exit_code = func(sopel, trigger) + except Exception as error: # TODO: Be specific + exit_code = None + self.error(trigger, exception=error) if exit_code != plugin.NOLIMIT: self._times[nick][func] = current_time @@ -796,7 +999,7 @@ def dispatch(self, pretrigger: PreTrigger) -> None: """ # list of commands running in separate threads for this dispatch - running_triggers = [] + running_triggers: List[threading.Thread] = [] # nickname/hostname blocking nick_blocked, host_blocked = self._is_pretrigger_blocked(pretrigger) blocked = bool(nick_blocked or host_blocked) @@ -820,12 +1023,9 @@ def dispatch(self, pretrigger: PreTrigger) -> None: list_of_blocked_rules.add(str(rule)) continue - wrapper = SopelWrapper( - self, trigger, output_prefix=rule.get_output_prefix()) - if rule.is_threaded(): # run in a separate thread - targs = (rule, wrapper, trigger) + targs = (rule, trigger) t = threading.Thread(target=self.call_rule, args=targs) plugin_name = rule.get_plugin_name() rule_label = rule.get_rule_label() @@ -834,7 +1034,7 @@ def dispatch(self, pretrigger: PreTrigger) -> None: running_triggers.append(t) else: # direct call - self.call_rule(rule, wrapper, trigger) + self.call_rule(rule, trigger) # update currently running triggers self._update_running_triggers(running_triggers) @@ -865,7 +1065,10 @@ def running_triggers(self) -> list: with self._running_triggers_lock: return [t for t in self._running_triggers if t.is_alive()] - def _update_running_triggers(self, running_triggers: list) -> None: + def _update_running_triggers( + self, + running_triggers: List[threading.Thread], + ) -> None: """Update list of running triggers. :param list running_triggers: newly started threads @@ -1218,6 +1421,206 @@ def search_url_callbacks(self, url): if match: yield function, match + # IRC methods + + def say( + self, + message: str, + destination: Optional[str] = None, + max_messages: int = 1, + truncation: str = '', + trailing: str = '', + ) -> None: + """Send a ``message`` to ``destination``. + + :param str message: message to say + :param str destination: channel or nickname; defaults to + :attr:`trigger.sender ` + :param int max_messages: split ``message`` into at most this many + messages if it is too long to fit into one + line (optional) + :param str truncation: string to indicate that the ``message`` was + truncated (optional) + :param str trailing: string that should always appear at the end of + ``message`` (optional) + :raise RuntimeError: when the bot cannot found a ``destination`` for + the message + + The ``destination`` will default to the channel in which the + trigger happened (or nickname, if received in a private message). + + .. seealso:: + + For more details about the optional arguments to this wrapper + method, consult the documentation for + :meth:`sopel.irc.AbstractBot.say`. + + .. warning:: + + If the bot is not bound to a trigger, then this methods requires + a ``destination`` or it will raise an exception. + + """ + if destination is None: + destination = self.default_destination + + if destination is None: + raise RuntimeError('bot.say requires a destination.') + + super().say( + self.output_prefix + message, + destination, + max_messages, + truncation, + trailing, + ) + + def action(self, message: str, destination: Optional[str] = None) -> None: + """Send a ``message`` as an action to ``destination``. + + :param str message: action message + :param str destination: channel or nickname; defaults to + :attr:`trigger.sender ` + :raise RuntimeError: when the bot cannot found a ``destination`` for + the message + + The ``destination`` will default to the channel in which the + trigger happened (or nickname, if received in a private message). + + .. seealso:: + + :meth:`sopel.irc.AbstractBot.action` + + .. warning:: + + If the bot is not bound to a trigger, then this methods requires + a ``destination`` or it will raise an exception. + + """ + if destination is None: + destination = self.default_destination + + if destination is None: + raise RuntimeError('bot.action requires a destination.') + + super().action(message, destination) + + def notice(self, message: str, destination: Optional[str] = None) -> None: + """Send a ``message`` as a notice to ``destination``. + + :param str message: notice message + :param str destination: channel or nickname; defaults to + :attr:`trigger.sender ` + :raise RuntimeError: when the bot cannot found a ``destination`` for + the message + + The ``destination`` will default to the channel in which the + trigger happened (or nickname, if received in a private message). + + .. seealso:: + + :meth:`sopel.irc.AbstractBot.notice` + + .. warning:: + + If the bot is not bound to a trigger, then this methods requires + a ``destination`` or it will raise an exception. + + """ + if destination is None: + destination = self.default_destination + + if destination is None: + raise RuntimeError('bot.notice requires a destination.') + + super().notice(self.output_prefix + message, destination) + + def reply( + self, + message: str, + destination: Optional[str] = None, + reply_to: Optional[str] = None, + notice: bool = False, + ) -> None: + """Reply with a ``message`` to the ``reply_to`` nick. + + :param str message: reply message + :param str destination: channel or nickname; defaults to + :attr:`trigger.sender ` + :param str reply_to: person to reply to; defaults to + :attr:`trigger.nick ` + :param bool notice: reply as an IRC notice or with a simple message + :raise RuntimeError: when the bot cannot found a ``destination`` or + a nick (``reply_to``) for the message + + The ``destination`` will default to the channel in which the + trigger happened (or nickname, if received in a private message). + + ``reply_to`` will default to the nickname who sent the trigger. + + .. seealso:: + + :meth:`sopel.irc.AbstractBot.reply` + + .. warning:: + + If the bot is not bound to a trigger, then this methods requires + a ``destination`` and a ``reply_to``, or it will raise an + exception. + + """ + if destination is None: + destination = self.default_destination + + if destination is None: + raise RuntimeError('bot.action requires a destination.') + + if reply_to is None: + if self.trigger is None: + raise RuntimeError('Error: reply requires a nick.') + reply_to = self.trigger.nick + + super().reply(message, destination, reply_to, notice) + + def kick( + self, + nick: str, + channel: Optional[str] = None, + message: Optional[str] = None, + ) -> None: + """Override ``Sopel.kick`` to kick in a channel + + :param str nick: nick to kick out of the ``channel`` + :param str channel: optional channel to kick ``nick`` from + :param str message: optional message for the kick + :raise RuntimeError: when the bot cannot found a ``channel`` for the + message + + The ``channel`` will default to the channel in which the call was + triggered. If triggered from a private message, ``channel`` is + required. + + .. seealso:: + + :meth:`sopel.irc.AbstractBot.kick` + + .. warning:: + + If the bot is not bound to a trigger, then this methods requires + a ``channel``, or it will raise an exception. + + """ + if channel is None: + if self.trigger is None or self.trigger.is_privmsg: + raise RuntimeError('Error: KICK requires a channel.') + else: + channel = self.trigger.sender + + if not nick: + raise RuntimeError('Error: KICK requires a nick.') + + super().kick(nick, channel, message) + class SopelWrapper: """Wrapper around a Sopel instance and a Trigger. @@ -1233,8 +1636,23 @@ class SopelWrapper: their ``bot`` argument. It acts as a proxy, providing the ``trigger``'s ``sender`` (source channel or private message) as the default ``destination`` argument for overridden methods. + + .. deprecated:: 8.0 + + This class is deprecated since Sopel 8.0 and is no longer used by + Sopel. With Python 3.7, Sopel takes advantage of the :mod:`contextvars` + built-in module to manipulate a context specific trigger and rule. + + This class will be removed in Sopel 9.0 and must not be used anymore. + """ - def __init__(self, sopel, trigger, output_prefix=''): + @deprecated('Obsolete, see sopel.bot.Sopel.sopel_wrapper', '8.0', '9.0') + def __init__( + self, + sopel: Sopel, + trigger: Trigger, + output_prefix: str = '', + ): if not output_prefix: # Just in case someone passes in False, None, etc. output_prefix = '' @@ -1250,10 +1668,10 @@ def __dir__(self): if not attr.startswith('__')] return list(self.__dict__) + classattrs + dir(self._bot) - def __getattr__(self, attr): + def __getattr__(self, attr: str): return getattr(self._bot, attr) - def __setattr__(self, attr, value): + def __setattr__(self, attr: str, value: Any): return setattr(self._bot, attr, value) @property diff --git a/sopel/coretasks.py b/sopel/coretasks.py index b1b1ed7e52..1367347f5d 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -37,7 +37,7 @@ from sopel.tools import events, jobs, SopelMemory, target if TYPE_CHECKING: - from sopel.bot import Sopel, SopelWrapper + from sopel.bot import Sopel from sopel.tools import Identifier from sopel.trigger import Trigger @@ -64,7 +64,7 @@ def _handle_account_and_extjoin_capabilities( - cap_req: Tuple[str, ...], bot: SopelWrapper, acknowledged: bool, + cap_req: Tuple[str, ...], bot: Sopel, acknowledged: bool, ) -> plugin.CapabilityNegotiation: if acknowledged: return plugin.CapabilityNegotiation.DONE @@ -89,7 +89,7 @@ def _handle_account_and_extjoin_capabilities( def _handle_sasl_capability( - cap_req: Tuple[str, ...], bot: SopelWrapper, acknowledged: bool, + cap_req: Tuple[str, ...], bot: Sopel, acknowledged: bool, ) -> plugin.CapabilityNegotiation: # Manage CAP REQ :sasl auth_method = bot.settings.core.auth_method @@ -1013,7 +1013,7 @@ def track_quit(bot, trigger): auth_after_register(bot) -def _receive_cap_ls_reply(bot: SopelWrapper, trigger: Trigger) -> None: +def _receive_cap_ls_reply(bot: Sopel, trigger: Trigger) -> None: if not bot.capabilities.handle_ls(bot, trigger): # multi-line, we must wait for more return @@ -1025,7 +1025,7 @@ def _receive_cap_ls_reply(bot: SopelWrapper, trigger: Trigger) -> None: def _handle_cap_acknowledgement( - bot: SopelWrapper, + bot: Sopel, cap_req: Tuple[str, ...], results: List[Tuple[bool, Optional[plugin.CapabilityNegotiation]]], was_completed: bool, @@ -1048,7 +1048,7 @@ def _handle_cap_acknowledgement( bot.write(('CAP', 'END')) # close negotiation now -def _receive_cap_ack(bot: SopelWrapper, trigger: Trigger) -> None: +def _receive_cap_ack(bot: Sopel, trigger: Trigger) -> None: was_completed = bot.cap_requests.is_complete cap_ack: Tuple[str, ...] = bot.capabilities.handle_ack(bot, trigger) @@ -1083,7 +1083,7 @@ def _receive_cap_ack(bot: SopelWrapper, trigger: Trigger) -> None: _handle_cap_acknowledgement(bot, cap_ack, result, was_completed) -def _receive_cap_nak(bot: SopelWrapper, trigger: Trigger) -> None: +def _receive_cap_nak(bot: Sopel, trigger: Trigger) -> None: was_completed = bot.cap_requests.is_complete cap_ack = bot.capabilities.handle_nak(bot, trigger) @@ -1118,19 +1118,19 @@ def _receive_cap_nak(bot: SopelWrapper, trigger: Trigger) -> None: _handle_cap_acknowledgement(bot, cap_ack, result, was_completed) -def _receive_cap_new(bot: SopelWrapper, trigger: Trigger) -> None: +def _receive_cap_new(bot: Sopel, trigger: Trigger) -> None: cap_new = bot.capabilities.handle_new(bot, trigger) LOGGER.info('Capability is now available: %s', ', '.join(cap_new)) # TODO: try to request what wasn't requested before -def _receive_cap_del(bot: SopelWrapper, trigger: Trigger) -> None: +def _receive_cap_del(bot: Sopel, trigger: Trigger) -> None: cap_del = bot.capabilities.handle_del(bot, trigger) LOGGER.info('Capability is now unavailable: %s', ', '.join(cap_del)) # TODO: what to do when a CAP is removed? NAK callbacks? -CAP_HANDLERS: Dict[str, Callable[[SopelWrapper, Trigger], None]] = { +CAP_HANDLERS: Dict[str, Callable[[Sopel, Trigger], None]] = { 'LS': _receive_cap_ls_reply, # Server is listing capabilities 'ACK': _receive_cap_ack, # Server is acknowledging a capability 'NAK': _receive_cap_nak, # Server is denying a capability @@ -1143,7 +1143,7 @@ def _receive_cap_del(bot: SopelWrapper, trigger: Trigger) -> None: @plugin.thread(False) @plugin.unblockable @plugin.priority('medium') -def receive_cap_list(bot: SopelWrapper, trigger: Trigger) -> None: +def receive_cap_list(bot: Sopel, trigger: Trigger) -> None: """Handle client capability negotiation.""" subcommand = trigger.args[1] if subcommand in CAP_HANDLERS: @@ -1257,7 +1257,7 @@ def _make_sasl_plain_token(account, password): @plugin.thread(False) @plugin.unblockable @plugin.priority('medium') -def sasl_success(bot: SopelWrapper, trigger: Trigger): +def sasl_success(bot: Sopel, trigger: Trigger): """Resume capability negotiation on successful SASL auth.""" LOGGER.info("Successful SASL Auth.") bot.resume_capability_negotiation(CAP_SASL.cap_req, 'coretasks') @@ -1620,4 +1620,4 @@ def handle_url_callbacks(bot, trigger): def decorated(bot, trigger): return function(bot, trigger, match=match) - bot.call(decorated, bot, trigger) + bot.call(decorated, trigger) diff --git a/sopel/irc/capabilities.py b/sopel/irc/capabilities.py index 98ed771109..bafcee2791 100644 --- a/sopel/irc/capabilities.py +++ b/sopel/irc/capabilities.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: - from sopel.bot import SopelWrapper + from sopel.bot import Sopel from sopel.trigger import Trigger @@ -135,7 +135,7 @@ def is_enabled(self, name: str) -> bool: """Tell if the capability ``name`` is enabled on the server.""" return name in self._enabled - def handle_ls(self, bot: SopelWrapper, trigger: Trigger) -> bool: + def handle_ls(self, bot: Sopel, trigger: Trigger) -> bool: """Handle a ``CAP LS`` command. This method behaves as a plugin callable with its ``bot`` and @@ -160,7 +160,7 @@ def handle_ls(self, bot: SopelWrapper, trigger: Trigger) -> bool: def handle_ack( self, - bot: SopelWrapper, + bot: Sopel, trigger: Trigger, ) -> Tuple[str, ...]: """Handle a ``CAP ACK`` command. @@ -195,7 +195,7 @@ def handle_ack( def handle_nak( self, - bot: SopelWrapper, + bot: Sopel, trigger: Trigger, ) -> Tuple[str, ...]: """Handle a ``CAP NAK`` command. @@ -218,7 +218,7 @@ def handle_nak( def handle_new( self, - bot: SopelWrapper, + bot: Sopel, trigger: Trigger, ) -> Tuple[str, ...]: """Handle a ``CAP NEW`` command. @@ -245,7 +245,7 @@ def handle_new( def handle_del( self, - bot: SopelWrapper, + bot: Sopel, trigger: Trigger, ) -> Tuple[str, ...]: """Handle a ``CAP DEL`` command. diff --git a/sopel/modules/isup.py b/sopel/modules/isup.py index 7982975fbb..e4c0bce7cc 100644 --- a/sopel/modules/isup.py +++ b/sopel/modules/isup.py @@ -52,7 +52,7 @@ def handle_isup(bot, trigger, secure=True): """Handle the ``bot`` command from ``trigger`` :param bot: Sopel instance - :type bot: :class:`sopel.bot.SopelWrapper` + :type bot: :class:`sopel.bot.Sopel` :param trigger: Command's trigger instance :type trigger: :class:`sopel.trigger.Trigger` :param bool secure: Check SSL error if ``True`` (the default) diff --git a/sopel/modules/remind.py b/sopel/modules/remind.py index 8dc3222b8a..e479ddadb6 100644 --- a/sopel/modules/remind.py +++ b/sopel/modules/remind.py @@ -104,7 +104,7 @@ def create_reminder(bot, trigger, duration, message): """Create a reminder into the ``bot``'s database and reply to the sender :param bot: the bot's instance - :type bot: :class:`~sopel.bot.SopelWrapper` + :type bot: :class:`~sopel.bot.Sopel` :param trigger: the object that triggered the call :type trigger: :class:`~sopel.trigger.Trigger` :param int duration: duration from now, in seconds, until ``message`` diff --git a/sopel/modules/safety.py b/sopel/modules/safety.py index 260acf70b2..4fd1905817 100644 --- a/sopel/modules/safety.py +++ b/sopel/modules/safety.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from typing import Dict, Optional - from sopel.bot import Sopel, SopelWrapper + from sopel.bot import Sopel from sopel.config import Config from sopel.trigger import Trigger @@ -211,7 +211,7 @@ def shutdown(bot: Sopel): @plugin.rule(r'(?u).*(https?://\S+).*') @plugin.priority('high') @plugin.output_prefix(PLUGIN_OUTPUT_PREFIX) -def url_handler(bot: SopelWrapper, trigger: Trigger): +def url_handler(bot: Sopel, trigger: Trigger): """Checks for malicious URLs.""" mode = bot.db.get_channel_value( trigger.sender, @@ -271,7 +271,7 @@ def url_handler(bot: SopelWrapper, trigger: Trigger): def virustotal_lookup( - bot: SopelWrapper, + bot: Sopel, url: str, local_only: bool = False, max_cache_age: Optional[timedelta] = None, @@ -365,7 +365,7 @@ def virustotal_lookup( @plugin.example(".virustotal https://malware.wicar.org/") @plugin.example(".virustotal hxxps://malware.wicar.org/") @plugin.output_prefix("[safety][VirusTotal] ") -def vt_command(bot: SopelWrapper, trigger: Trigger): +def vt_command(bot: Sopel, trigger: Trigger): """Look up VT results on demand.""" if not bot.settings.safety.vt_api_key: bot.reply("Sorry, I don't have a VirusTotal API key configured.") @@ -421,7 +421,7 @@ def vt_command(bot: SopelWrapper, trigger: Trigger): @plugin.command('safety') @plugin.example(".safety on") @plugin.output_prefix(PLUGIN_OUTPUT_PREFIX) -def toggle_safety(bot: SopelWrapper, trigger: Trigger): +def toggle_safety(bot: Sopel, trigger: Trigger): """Set safety setting for channel.""" if not trigger.admin and bot.channels[trigger.sender].privileges[trigger.nick] < plugin.OP: bot.reply('Only channel operators can change safety settings') diff --git a/sopel/modules/url.py b/sopel/modules/url.py index 1d216c42c5..ae6af81683 100644 --- a/sopel/modules/url.py +++ b/sopel/modules/url.py @@ -25,7 +25,7 @@ from sopel.tools import web if TYPE_CHECKING: - from sopel.bot import Sopel, SopelWrapper + from sopel.bot import Sopel from sopel.config import Config from sopel.trigger import Trigger @@ -152,7 +152,7 @@ def shutdown(bot: Sopel): pass -def _user_can_change_excludes(bot: SopelWrapper, trigger: Trigger): +def _user_can_change_excludes(bot: Sopel, trigger: Trigger): if trigger.admin: return True @@ -170,7 +170,7 @@ def _user_can_change_excludes(bot: SopelWrapper, trigger: Trigger): @plugin.example('.urlpexclude example\\.com/\\w+', user_help=True) @plugin.example('.urlexclude example.com/path', user_help=True) @plugin.output_prefix('[url] ') -def url_ban(bot: SopelWrapper, trigger: Trigger): +def url_ban(bot: Sopel, trigger: Trigger): """Exclude a URL from auto title. Use ``urlpexclude`` to exclude a pattern instead of a URL. @@ -221,7 +221,7 @@ def url_ban(bot: SopelWrapper, trigger: Trigger): @plugin.example('.urlpallow example\\.com/\\w+', user_help=True) @plugin.example('.urlallow example.com/path', user_help=True) @plugin.output_prefix('[url] ') -def url_unban(bot: SopelWrapper, trigger: Trigger): +def url_unban(bot: Sopel, trigger: Trigger): """Allow a URL for auto title. Use ``urlpallow`` to allow a pattern instead of a URL. @@ -274,7 +274,7 @@ def url_unban(bot: SopelWrapper, trigger: Trigger): 'Google | www.google.com', online=True, vcr=True) @plugin.output_prefix('[url] ') -def title_command(bot: SopelWrapper, trigger: Trigger): +def title_command(bot: Sopel, trigger: Trigger): """ Show the title or URL information for the given URL, or the last URL seen in this channel. @@ -314,7 +314,7 @@ def title_command(bot: SopelWrapper, trigger: Trigger): @plugin.rule(r'(?u).*(https?://\S+).*') @plugin.output_prefix('[url] ') -def title_auto(bot: SopelWrapper, trigger: Trigger): +def title_auto(bot: Sopel, trigger: Trigger): """ Automatically show titles for URLs. For shortened URLs/redirects, find where the URL redirects to and show the title for that. @@ -377,7 +377,7 @@ class URLInfo(NamedTuple): def process_urls( - bot: SopelWrapper, + bot: Sopel, trigger: Trigger, urls: List[str], requested: bool = False, @@ -469,10 +469,10 @@ def process_urls( if (shorten_url_length > 0) and (len(url) > shorten_url_length): tinyurl = get_or_create_shorturl(bot, url) - yield (url, title, parsed_url.hostname, tinyurl, False) + yield URLInfo(url, title, parsed_url.hostname, tinyurl, False) -def check_callbacks(bot: SopelWrapper, url: str, use_excludes: bool = True) -> bool: +def check_callbacks(bot: Sopel, url: str, use_excludes: bool = True) -> bool: """Check if ``url`` is excluded or matches any URL callback patterns. :param bot: Sopel instance @@ -553,7 +553,7 @@ def find_title(url: str, verify: bool = True) -> Optional[str]: return title or None -def get_or_create_shorturl(bot: SopelWrapper, url: str) -> Optional[str]: +def get_or_create_shorturl(bot: Sopel, url: str) -> Optional[str]: """Get or create a short URL for ``url`` :param bot: Sopel instance diff --git a/sopel/plugin.py b/sopel/plugin.py index 17ab5dd060..0b602dcd74 100644 --- a/sopel/plugin.py +++ b/sopel/plugin.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: - from sopel.bot import SopelWrapper + from sopel.bot import Sopel __all__ = [ # constants @@ -122,7 +122,7 @@ class CapabilityNegotiation(enum.Enum): if TYPE_CHECKING: CapabilityHandler = Callable[ - [Tuple[str, ...], SopelWrapper, bool], + [Tuple[str, ...], Sopel, bool], CapabilityNegotiation, ] @@ -146,12 +146,12 @@ class capability: The handler must follow this interface:: from sopel import plugin - from sopel.bot import SopelWrapper + from sopel.bot import Sopel @plugin.capability('example/cap-name') def capability_handler( cap_req: Tuple[str, ...], - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> plugin.CapabilityNegotiation: if acknowledged: @@ -253,7 +253,7 @@ def cap_req(self) -> Tuple[str, ...]: def callback( self, - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> Tuple[bool, Optional[CapabilityNegotiation]]: """Execute the acknowlegement callback of a capability request. @@ -1596,7 +1596,7 @@ def url(*url_rules: str) -> Callable: def handle_example_bugs(bot, trigger): bot.reply('Found bug ID #%s' % trigger.group(1)) - The ``bot`` is an instance of :class:`~sopel.bot.SopelWrapper`, and + The ``bot`` is an instance of :class:`~sopel.bot.Sopel`, and ``trigger`` is the usual :class:`~sopel.trigger.Trigger` object. Under the hood, when Sopel collects the decorated handler it uses an @@ -1840,8 +1840,8 @@ def output_prefix(prefix: str) -> Callable: Prefix will be added to text sent through: - * :meth:`bot.say ` - * :meth:`bot.notice ` + * :meth:`bot.say ` + * :meth:`bot.notice ` """ def add_attribute(function): diff --git a/sopel/plugins/capabilities.py b/sopel/plugins/capabilities.py index 9ccc1227c2..e0ea481872 100644 --- a/sopel/plugins/capabilities.py +++ b/sopel/plugins/capabilities.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: - from sopel.bot import Sopel, SopelWrapper + from sopel.bot import Sopel from sopel.plugin import capability, CapabilityNegotiation @@ -360,7 +360,7 @@ def resume( def acknowledge( self, - bot: SopelWrapper, + bot: Sopel, cap_req: Tuple[str, ...], ) -> Optional[List[Tuple[bool, Optional[CapabilityNegotiation]]]]: """Acknowledge a capability request and execute handlers. @@ -395,7 +395,7 @@ def acknowledge( def deny( self, - bot: SopelWrapper, + bot: Sopel, cap_req: Tuple[str, ...], ) -> Optional[List[Tuple[bool, Optional[CapabilityNegotiation]]]]: """Deny a capability request and execute handlers. @@ -429,7 +429,7 @@ def deny( def _callbacks( self, - bot: SopelWrapper, + bot: Sopel, cap_req: Tuple[str, ...], acknowledged: bool, ) -> List[Tuple[bool, Optional[CapabilityNegotiation]]]: @@ -446,7 +446,7 @@ def _callback( self, plugin_name: str, handler_info: Tuple[capability, bool], - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> Tuple[bool, Optional[CapabilityNegotiation]]: handler = handler_info[0] diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index 9777cbdfe9..897d06ae1d 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -653,7 +653,7 @@ def get_output_prefix(self) -> str: .. seealso:: - See the :class:`sopel.bot.SopelWrapper` class for more information + See the :class:`sopel.bot.Sopel` class for more information on how the output prefix can be used. """ @@ -805,8 +805,8 @@ def parse(self, text) -> Generator: def execute(self, bot, trigger): """Execute the triggered rule. - :param bot: Sopel wrapper - :type bot: :class:`sopel.bot.SopelWrapper` + :param bot: Sopel context bound to a trigger & a rule + :type bot: :class:`sopel.bot.Sopel` :param trigger: IRC line :type trigger: :class:`sopel.trigger.Trigger` diff --git a/sopel/tests/factories.py b/sopel/tests/factories.py index 4c9f3d3a4b..27831fb5fe 100644 --- a/sopel/tests/factories.py +++ b/sopel/tests/factories.py @@ -97,9 +97,10 @@ def wrapper( mockbot: bot.Sopel, raw: str, pattern: Optional[str] = None, - ) -> bot.SopelWrapper: + ) -> bot.Sopel: trigger = self(mockbot, raw, pattern=pattern) - return bot.SopelWrapper(mockbot, trigger) + mockbot.bind_trigger(trigger) + return mockbot def __call__( self, diff --git a/sopel/tests/pytest_plugin.py b/sopel/tests/pytest_plugin.py index 16a58c07ab..273178d7a2 100644 --- a/sopel/tests/pytest_plugin.py +++ b/sopel/tests/pytest_plugin.py @@ -9,7 +9,7 @@ import pytest -from sopel import bot, loader, plugins, trigger +from sopel import loader, plugins, trigger from .factories import BotFactory, ConfigFactory, IRCFactory, TriggerFactory, UserFactory @@ -47,7 +47,7 @@ def get_example_test(tested_func, msg, results, privmsg, admin, """Get a function that calls ``tested_func`` with fake wrapper and trigger. :param callable tested_func: a Sopel callable that accepts a - :class:`~.bot.SopelWrapper` and a + :class:`~.bot.Sopel` and a :class:`~.trigger.Trigger` :param str msg: message that is supposed to trigger the command :param list results: expected output from the callable @@ -79,6 +79,7 @@ def test(configfactory, botfactory, ircfactory): raise AssertionError('Function is not a command.') loader.clean_callable(tested_func, settings) + tested_func.output_prefix = '' test_rule = plugins.rules.Command.from_callable(settings, tested_func) parse_results = list(test_rule.parse(msg)) assert parse_results, "Example did not match any command." @@ -113,8 +114,9 @@ def isnt_ignored(value): expected_output_count = 0 for _i in range(repeat): expected_output_count += len(results) - wrapper = bot.SopelWrapper(mockbot, test_trigger) - tested_func(wrapper, test_trigger) + + with mockbot.sopel_wrapper(test_trigger, test_rule) as wrapper: + tested_func(wrapper, test_trigger) output_triggers = ( trigger.PreTrigger( diff --git a/sopel/trigger.py b/sopel/trigger.py index 6c509e1c97..e008e8457d 100644 --- a/sopel/trigger.py +++ b/sopel/trigger.py @@ -335,11 +335,8 @@ class Trigger(str): does not include the status prefix. Be sure to use the :attr:`status_prefix` when replying. - Note that the ``bot`` argument passed to plugin callables is a - :class:`~sopel.bot.SopelWrapper` that handles this for the default - ``destination`` of the methods it overrides (most importantly, - :meth:`~sopel.bot.SopelWrapper.say` & - :meth:`~sopel.bot.SopelWrapper.reply`). + Note that the ``bot`` argument passed to plugin callables handles this + by using its :attr:`~sopel.bot.Sopel.default_destination` attribute. .. warning:: diff --git a/test/coretasks/test_coretasks_cap.py b/test/coretasks/test_coretasks_cap.py index d5690e2fa1..1ce18aefa3 100644 --- a/test/coretasks/test_coretasks_cap.py +++ b/test/coretasks/test_coretasks_cap.py @@ -9,7 +9,7 @@ from sopel.tests import rawlist if TYPE_CHECKING: - from sopel.bot import Sopel, SopelWrapper + from sopel.bot import Sopel from sopel.config import Config from sopel.tests.factories import BotFactory, ConfigFactory @@ -233,7 +233,7 @@ def test_cap_ack_config_error(mockbot: Sopel): @plugin.capability('example/cap') def cap_req( cap_req: Tuple[str, ...], - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> None: raise config.ConfigurationError('Improperly configured.') @@ -255,7 +255,7 @@ def test_cap_ack_error(mockbot: Sopel): @plugin.capability('example/cap') def cap_req( cap_req: Tuple[str, ...], - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> None: raise Exception('Random error.') @@ -277,7 +277,7 @@ def test_cap_nak_config_error(mockbot: Sopel): @plugin.capability('example/cap') def cap_req( cap_req: Tuple[str, ...], - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> None: raise config.ConfigurationError('Improperly configured.') @@ -299,7 +299,7 @@ def test_cap_nak_error(mockbot: Sopel): @plugin.capability('example/cap') def cap_req( cap_req: Tuple[str, ...], - bot: SopelWrapper, + bot: Sopel, acknowledged: bool, ) -> None: raise Exception('Random error.') diff --git a/test/irc/test_irc_capabilities.py b/test/irc/test_irc_capabilities.py index 2a4e6d042f..66db36f02a 100644 --- a/test/irc/test_irc_capabilities.py +++ b/test/irc/test_irc_capabilities.py @@ -6,6 +6,7 @@ import pytest from sopel.irc.capabilities import Capabilities, CapabilityInfo +from sopel.plugins.rules import Rule if typing.TYPE_CHECKING: from sopel.tests.factories import TriggerFactory @@ -29,6 +30,11 @@ def mockbot(tmpconfig, botfactory): return botfactory(tmpconfig) +@pytest.fixture +def mockrule(): + return Rule(regexes=['*'], plugin='test_plugin') + + def test_capabilities_empty(): manager = Capabilities() assert not manager.is_available('away-notify') @@ -41,7 +47,7 @@ def test_capabilities_ls(mockbot, triggerfactory: TriggerFactory): raw = 'CAP * LS :away-notify' wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_ls(wrapped, wrapped._trigger) + assert manager.handle_ls(wrapped, wrapped.trigger) assert manager.is_available('away-notify') assert not manager.is_enabled('away-notify') assert manager.available == {'away-notify': None} @@ -55,7 +61,7 @@ def test_capabilities_ls_parameter(mockbot, triggerfactory: TriggerFactory): raw = 'CAP * LS :sasl=EXTERNAL,PLAIN' wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_ls(wrapped, wrapped._trigger) + assert manager.handle_ls(wrapped, wrapped.trigger) assert manager.is_available('sasl') assert not manager.is_enabled('sasl') assert manager.available == {'sasl': 'EXTERNAL,PLAIN'} @@ -64,18 +70,25 @@ def test_capabilities_ls_parameter(mockbot, triggerfactory: TriggerFactory): assert manager.get_capability_info('sasl') == expected -def test_capabilities_ls_multiline(mockbot, triggerfactory): - raw = 'CAP * LS * :away-notify' - wrapped = triggerfactory.wrapper(mockbot, raw) +def test_capabilities_ls_multiline(mockbot, triggerfactory, mockrule): manager = Capabilities() - assert not manager.handle_ls(wrapped, wrapped._trigger) + + raw = 'CAP * LS * :away-notify' + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert not manager.handle_ls(wrapped, wrapped.trigger) + assert manager.is_available('away-notify') assert not manager.is_enabled('away-notify') assert manager.available == {'away-notify': None} raw = 'CAP * LS :account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_ls(wrapped, wrapped._trigger) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ls(wrapped, wrapped.trigger) + assert manager.is_available('away-notify') assert manager.is_available('account-tag') assert not manager.is_enabled('away-notify') @@ -83,11 +96,14 @@ def test_capabilities_ls_multiline(mockbot, triggerfactory): assert manager.available == {'away-notify': None, 'account-tag': None} -def test_capabilities_ack(mockbot, triggerfactory): +def test_capabilities_ack(mockbot, triggerfactory, mockrule): raw = 'CAP * ACK :away-notify' - wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_ack(wrapped, wrapped._trigger) == ('away-notify',) + + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ('away-notify',) assert not manager.is_available('away-notify'), ( 'ACK a capability does not update server availability.') assert manager.is_enabled('away-notify'), ( @@ -95,8 +111,10 @@ def test_capabilities_ack(mockbot, triggerfactory): assert manager.enabled == frozenset({'away-notify'}) raw = 'CAP * ACK :account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_ack(wrapped, wrapped._trigger) == ('account-tag',) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ('account-tag',) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert manager.is_enabled('away-notify') @@ -104,13 +122,16 @@ def test_capabilities_ack(mockbot, triggerfactory): assert manager.enabled == frozenset({'away-notify', 'account-tag'}) -def test_capabilities_ack_multiple(mockbot, triggerfactory): +def test_capabilities_ack_multiple(mockbot, triggerfactory, mockrule): raw = 'CAP * ACK :away-notify account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_ack(wrapped, wrapped._trigger) == ( - 'account-tag', 'away-notify', - ) + + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ( + 'account-tag', 'away-notify', + ) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert manager.is_enabled('away-notify') @@ -118,8 +139,11 @@ def test_capabilities_ack_multiple(mockbot, triggerfactory): assert manager.enabled == frozenset({'away-notify', 'account-tag'}) raw = 'CAP * ACK :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_ack(wrapped, wrapped._trigger) == ('echo-message',) + + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ('echo-message',) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert not manager.is_available('echo-message') @@ -131,15 +155,17 @@ def test_capabilities_ack_multiple(mockbot, triggerfactory): }) -def test_capabilities_ack_disable_prefix(mockbot, triggerfactory): +def test_capabilities_ack_disable_prefix(mockbot, triggerfactory, mockrule): raw = 'CAP * ACK :away-notify account-tag -echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_ack(wrapped, wrapped._trigger) == ( - '-echo-message', - 'account-tag', - 'away-notify', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ( + '-echo-message', + 'account-tag', + 'away-notify', + ) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert not manager.is_available('echo-message') @@ -149,10 +175,12 @@ def test_capabilities_ack_disable_prefix(mockbot, triggerfactory): assert manager.enabled == frozenset({'away-notify', 'account-tag'}) raw = 'CAP * ACK :-account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_ack(wrapped, wrapped._trigger) == ( - '-account-tag', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ( + '-account-tag', + ) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert manager.is_enabled('away-notify') @@ -162,11 +190,13 @@ def test_capabilities_ack_disable_prefix(mockbot, triggerfactory): assert manager.enabled == frozenset({'away-notify'}) -def test_capabilities_nak(mockbot, triggerfactory): +def test_capabilities_nak(mockbot, triggerfactory, mockrule): raw = 'CAP * NAK :away-notify' - wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_nak(wrapped, wrapped._trigger) == ('away-notify',) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_nak(wrapped, wrapped.trigger) == ('away-notify',) assert not manager.is_available('away-notify'), ( 'NAK a capability does not update server availability.') assert not manager.is_enabled('away-notify'), ( @@ -174,8 +204,10 @@ def test_capabilities_nak(mockbot, triggerfactory): assert not manager.enabled raw = 'CAP * NAK :account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_nak(wrapped, wrapped._trigger) == ('account-tag',) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_nak(wrapped, wrapped.trigger) == ('account-tag',) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert not manager.is_enabled('away-notify') @@ -183,13 +215,15 @@ def test_capabilities_nak(mockbot, triggerfactory): assert not manager.enabled -def test_capabilities_nack_multiple(mockbot, triggerfactory): +def test_capabilities_nack_multiple(mockbot, triggerfactory, mockrule): raw = 'CAP * NAK :away-notify account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) manager = Capabilities() - assert manager.handle_nak(wrapped, wrapped._trigger) == ( - 'account-tag', 'away-notify', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_nak(wrapped, wrapped.trigger) == ( + 'account-tag', 'away-notify', + ) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert not manager.is_enabled('away-notify') @@ -197,8 +231,10 @@ def test_capabilities_nack_multiple(mockbot, triggerfactory): assert not manager.enabled raw = 'CAP * NAK :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_nak(wrapped, wrapped._trigger) == ('echo-message',) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_nak(wrapped, wrapped.trigger) == ('echo-message',) assert not manager.is_available('away-notify') assert not manager.is_available('account-tag') assert not manager.is_available('echo-message') @@ -208,36 +244,44 @@ def test_capabilities_nack_multiple(mockbot, triggerfactory): assert not manager.enabled -def test_capabilities_ack_and_nack(mockbot, triggerfactory): +def test_capabilities_ack_and_nack(mockbot, triggerfactory, mockrule): manager = Capabilities() # ACK a single CAP raw = 'CAP * ACK :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_ack(wrapped, wrapped._trigger) == ( - 'echo-message', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ( + 'echo-message', + ) # ACK multiple CAPs raw = 'CAP * ACK :away-notify account-tag' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_ack(wrapped, wrapped._trigger) == ( - 'account-tag', 'away-notify', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_ack(wrapped, wrapped.trigger) == ( + 'account-tag', 'away-notify', + ) # NAK a single CAP raw = 'CAP * NAK :batch' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_nak(wrapped, wrapped._trigger) == ( - 'batch', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_nak(wrapped, wrapped.trigger) == ( + 'batch', + ) # NAK multiple CAPs raw = 'CAP * NAK :batch echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_nak(wrapped, wrapped._trigger) == ( - 'batch', 'echo-message', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_nak(wrapped, wrapped.trigger) == ( + 'batch', 'echo-message', + ) # check the result assert not manager.available, 'ACK/NAK do not change availability.' @@ -246,15 +290,17 @@ def test_capabilities_ack_and_nack(mockbot, triggerfactory): }) -def test_capabilities_new(mockbot, triggerfactory): +def test_capabilities_new(mockbot, triggerfactory, mockrule): manager = Capabilities() # NEW CAP raw = 'CAP * NEW :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_new(wrapped, wrapped._trigger) == ( - 'echo-message', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_new(wrapped, wrapped.trigger) == ( + 'echo-message', + ) assert manager.is_available('echo-message') assert not manager.is_enabled('echo-message') assert manager.available == {'echo-message': None} @@ -262,10 +308,12 @@ def test_capabilities_new(mockbot, triggerfactory): # NEW CAP again raw = 'CAP * NEW :away-notify' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_new(wrapped, wrapped._trigger) == ( - 'away-notify', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_new(wrapped, wrapped.trigger) == ( + 'away-notify', + ) assert manager.is_available('echo-message') assert manager.is_available('away-notify') assert not manager.is_enabled('echo-message') @@ -279,7 +327,7 @@ def test_capabilities_new_multiple(mockbot, triggerfactory): raw = 'CAP * NEW :echo-message away-notify' wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_new(wrapped, wrapped._trigger) == ( + assert manager.handle_new(wrapped, wrapped.trigger) == ( 'away-notify', 'echo-message', ) assert manager.is_available('echo-message') @@ -296,7 +344,7 @@ def test_capabilities_new_params(mockbot, triggerfactory): # NEW CAP raw = 'CAP * NEW :sasl=PLAIN,EXTERNAL' wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_new(wrapped, wrapped._trigger) == ( + assert manager.handle_new(wrapped, wrapped.trigger) == ( 'sasl', ) assert manager.is_available('sasl') @@ -311,7 +359,7 @@ def test_capabilities_del(mockbot, triggerfactory): # DEL CAP raw = 'CAP * DEL :echo-message' wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_del(wrapped, wrapped._trigger) == ( + assert manager.handle_del(wrapped, wrapped.trigger) == ( 'echo-message', ) assert not manager.is_available('echo-message') @@ -320,45 +368,55 @@ def test_capabilities_del(mockbot, triggerfactory): assert not manager.enabled -def test_capabilities_del_available(mockbot, triggerfactory): +def test_capabilities_del_available(mockbot, triggerfactory, mockrule): manager = Capabilities() # NEW CAP raw = 'CAP * NEW :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.handle_new(wrapped, wrapped._trigger) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.handle_new(wrapped, wrapped.trigger) # DEL CAP raw = 'CAP * DEL :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_del(wrapped, wrapped._trigger) == ( - 'echo-message', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_del(wrapped, wrapped.trigger) == ( + 'echo-message', + ) assert not manager.is_available('echo-message') assert not manager.is_enabled('echo-message') assert not manager.available assert not manager.enabled -def test_capabilities_del_enabled(mockbot, triggerfactory): +def test_capabilities_del_enabled(mockbot, triggerfactory, mockrule): manager = Capabilities() # NEW CAP raw = 'CAP * NEW :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.handle_new(wrapped, wrapped._trigger) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.handle_new(wrapped, wrapped.trigger) # ACK CAP raw = 'CAP * ACK :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.handle_ack(wrapped, wrapped._trigger) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.handle_ack(wrapped, wrapped.trigger) # DEL CAP raw = 'CAP * DEL :echo-message' - wrapped = triggerfactory.wrapper(mockbot, raw) - assert manager.handle_del(wrapped, wrapped._trigger) == ( - 'echo-message', - ) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + assert manager.handle_del(wrapped, wrapped.trigger) == ( + 'echo-message', + ) assert not manager.is_available('echo-message') assert not manager.is_enabled('echo-message') assert not manager.available diff --git a/test/modules/test_modules_isup.py b/test/modules/test_modules_isup.py index b38975bb2f..5ac9e599ce 100644 --- a/test/modules/test_modules_isup.py +++ b/test/modules/test_modules_isup.py @@ -122,7 +122,7 @@ def test_isup_command_unparseable(irc, bot, user, requests_mock): assert len(bot.backend.message_sent) == 1, ( '.isup command should output exactly one line') assert bot.backend.message_sent == rawlist( - 'PRIVMSG User :User: "http://.foo" is not a valid URL.' + 'PRIVMSG User :[isup] User: "http://.foo" is not a valid URL.' ) diff --git a/test/plugins/test_plugins_capabilities.py b/test/plugins/test_plugins_capabilities.py index a30c6cdd41..560b4714b6 100644 --- a/test/plugins/test_plugins_capabilities.py +++ b/test/plugins/test_plugins_capabilities.py @@ -5,6 +5,7 @@ from sopel import plugin from sopel.plugins.capabilities import Manager +from sopel.plugins.rules import Rule from sopel.tests import rawlist @@ -26,6 +27,11 @@ def mockbot(tmpconfig, botfactory): return botfactory(tmpconfig) +@pytest.fixture +def mockrule(): + return Rule(regexes=['*'], plugin='test_plugin') + + def test_manager_empty(): req_example = ('example/cap',) manager = Manager() @@ -175,7 +181,7 @@ def test_manager_ack_unknown_request(mockbot, triggerfactory): assert results is None -def test_manager_ack_nak_request(mockbot, triggerfactory): +def test_manager_ack_nak_request(mockbot, triggerfactory, mockrule): manager = Manager() req_example = ('example/cap',) cap_example = plugin.capability(*req_example) @@ -183,13 +189,18 @@ def test_manager_ack_nak_request(mockbot, triggerfactory): manager.request_available(mockbot, req_example) raw = 'CAP * ACK :example/cap' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.acknowledge(wrapped, req_example) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.acknowledge(wrapped, req_example) raw = 'CAP * NAK :example/cap' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.deny(wrapped, req_example) + + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.deny(wrapped, req_example) # reversed set assert not manager.acknowledged @@ -232,7 +243,7 @@ def test_manager_nak_unknown_request(mockbot, triggerfactory): assert results is None -def test_manager_nak_ack_request(mockbot, triggerfactory): +def test_manager_nak_ack_request(mockbot, triggerfactory, mockrule): manager = Manager() req_example = ('example/cap',) cap_example = plugin.capability(*req_example) @@ -240,19 +251,23 @@ def test_manager_nak_ack_request(mockbot, triggerfactory): manager.request_available(mockbot, req_example) raw = 'CAP * NAK :example/cap' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.deny(wrapped, req_example) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.deny(wrapped, req_example) raw = 'CAP * ACK :example/cap' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.acknowledge(wrapped, req_example) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.acknowledge(wrapped, req_example) # reversed set assert manager.acknowledged == {req_example} assert not manager.denied -def test_manager_complete_requests(mockbot, triggerfactory): +def test_manager_complete_requests(mockbot, triggerfactory, mockrule): manager = Manager() req_example = ('example/cap',) req_example_2 = ('example/cap2', 'example/cap3') @@ -264,12 +279,16 @@ def test_manager_complete_requests(mockbot, triggerfactory): manager.request_available(mockbot, req_example + req_example_2) raw = 'CAP * ACK :example/cap' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.acknowledge(wrapped, req_example) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.acknowledge(wrapped, req_example) raw = 'CAP * NAK :example/cap2 example/cap3' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.deny(wrapped, req_example_2) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.deny(wrapped, req_example_2) assert manager.acknowledged == {req_example} assert manager.denied == {req_example_2} @@ -279,7 +298,7 @@ def test_manager_complete_requests(mockbot, triggerfactory): assert manager.resume(req_example, 'example') == (True, True) -def test_manager_resume_requests(mockbot, triggerfactory): +def test_manager_resume_requests(mockbot, triggerfactory, mockrule): manager = Manager() req_example = ('example/cap',) req_example_2 = ('example/cap2', 'example/cap3') @@ -295,12 +314,16 @@ def cap_example_2(bot, cap_req, acknowledge): manager.request_available(mockbot, req_example + req_example_2) raw = 'CAP * ACK :example/cap' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.acknowledge(wrapped, req_example) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.acknowledge(wrapped, req_example) raw = 'CAP * NAK :example/cap2 example/cap3' - wrapped = triggerfactory.wrapper(mockbot, raw) - manager.deny(wrapped, req_example_2) + with mockbot.sopel_wrapper( + triggerfactory(mockbot, raw), mockrule + ) as wrapped: + manager.deny(wrapped, req_example_2) assert manager.acknowledged == {req_example} assert manager.denied == {req_example_2} diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py index 90205c5b07..0fd39f44c7 100644 --- a/test/plugins/test_plugins_rules.py +++ b/test/plugins/test_plugins_rules.py @@ -6,7 +6,7 @@ import pytest -from sopel import bot, loader, plugin, trigger +from sopel import loader, plugin, trigger from sopel.plugins import rules from sopel.tests import rawlist @@ -881,8 +881,8 @@ def handler(wrapped, trigger): 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) + with mockbot.sopel_wrapper(match_trigger, rule) as wrapped: + result = rule.execute(wrapped, match_trigger) assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') assert result == 'The return value' @@ -1556,7 +1556,7 @@ def handler(bot, trigger): wrapper = triggerfactory.wrapper( mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') - mocktrigger = wrapper._trigger + mocktrigger = wrapper.trigger regex = re.compile(r'.*') rule = rules.Rule( @@ -1582,7 +1582,7 @@ def handler(bot, trigger): wrapper = triggerfactory.wrapper( mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') - mocktrigger = wrapper._trigger + mocktrigger = wrapper.trigger regex = re.compile(r'.*') rule = rules.Rule( @@ -1608,7 +1608,7 @@ def handler(bot, trigger): wrapper = triggerfactory.wrapper( mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') - mocktrigger = wrapper._trigger + mocktrigger = wrapper.trigger regex = re.compile(r'.*') rule = rules.Rule( @@ -1635,7 +1635,7 @@ def handler(bot, trigger): wrapper = triggerfactory.wrapper( mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') - mocktrigger = wrapper._trigger + mocktrigger = wrapper.trigger regex = re.compile(r'.*') rule = rules.Rule( @@ -1664,7 +1664,7 @@ def handler(bot, trigger): wrapper = triggerfactory.wrapper( mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') - mocktrigger = wrapper._trigger + mocktrigger = wrapper.trigger regex = re.compile(r'.*') rule = rules.Rule( @@ -1687,7 +1687,7 @@ def handler(bot, trigger): wrapper = triggerfactory.wrapper( mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') - mocktrigger = wrapper._trigger + mocktrigger = wrapper.trigger regex = re.compile(r'.*') rule = rules.Rule( @@ -2927,8 +2927,8 @@ def handler(wrapped, trigger): 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) + with mockbot.sopel_wrapper(match_trigger, rule) as wrapped: + result = rule.execute(wrapped, match_trigger) assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') assert result == 'The return value: https://example.com/test' @@ -3034,8 +3034,8 @@ def handler(wrapped, trigger, match): # 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) + with mockbot.sopel_wrapper(match_trigger, rule) as wrapped: + result = rule.execute(wrapped, match_trigger) assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') assert result == 'The return value: https://example.com/test' @@ -3070,8 +3070,8 @@ def handler(wrapped, trigger): match_trigger = trigger.Trigger( mockbot.settings, pretrigger, results[0], account=None) - wrapped = bot.SopelWrapper(mockbot, match_trigger) - result = rule.execute(wrapped, match_trigger) + with mockbot.sopel_wrapper(match_trigger, rule) as wrapped: + result = rule.execute(wrapped, match_trigger) assert mockbot.backend.message_sent == rawlist('PRIVMSG #sopel :Hi!') assert result == 'The return value: https://example.com/test' diff --git a/test/test_bot.py b/test/test_bot.py index fe3bb770cf..b6aeb75cbe 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -15,7 +15,12 @@ if typing.TYPE_CHECKING: from sopel.config import Config - from sopel.tests.factories import BotFactory, IRCFactory, UserFactory + from sopel.tests.factories import ( + BotFactory, + IRCFactory, + TriggerFactory, + UserFactory, + ) from sopel.tests.mocks import MockIRCServer @@ -100,7 +105,25 @@ def mockplugin(tmpdir): # ----------------------------------------------------------------------------- -# sopel.bot.SopelWrapper +# sopel.bot.Sopel.bind_trigger + +def test_bind_trigger(mockbot: bot.Sopel, triggerfactory: TriggerFactory): + trigger = triggerfactory( + mockbot, ':Test!test@example.com PRIVMSG #channel :test message') + + assert mockbot.trigger is None + token = mockbot.bind_trigger(trigger) + assert mockbot.trigger == trigger + + with pytest.raises(RuntimeError): + mockbot.bind_trigger(trigger) + + mockbot.unbind_trigger(token) + assert mockbot.trigger is None + + +# ----------------------------------------------------------------------------- +# trigger bound def test_wrapper_default_destination(mockbot, triggerfactory): wrapper = triggerfactory.wrapper( @@ -124,7 +147,7 @@ def test_wrapper_default_destination_statusmsg(mockbot, triggerfactory): wrapper = triggerfactory.wrapper( mockbot, ':Test!test@example.com PRIVMSG +#channel :test message') - assert wrapper._trigger.sender == '#channel' + assert wrapper.trigger.sender == '#channel' assert wrapper.default_destination == '+#channel' @@ -143,7 +166,7 @@ def test_wrapper_say_statusmsg(mockbot, triggerfactory): STATUSMSG=tuple('+'), ) - wrapper: bot.SopelWrapper = triggerfactory.wrapper( + wrapper: bot.Sopel = triggerfactory.wrapper( mockbot, ':Test!test@example.com PRIVMSG +#channel :test message') wrapper.say('Hi!') @@ -177,7 +200,7 @@ def test_wrapper_notice_statusmsg(mockbot, triggerfactory): STATUSMSG=tuple('+'), ) - wrapper: bot.SopelWrapper = triggerfactory.wrapper( + wrapper: bot.Sopel = triggerfactory.wrapper( mockbot, ':Test!test@example.com PRIVMSG +#channel :test message') wrapper.notice('Hi!') @@ -211,7 +234,7 @@ def test_wrapper_action_statusmsg(mockbot, triggerfactory): STATUSMSG=tuple('+'), ) - wrapper: bot.SopelWrapper = triggerfactory.wrapper( + wrapper: bot.Sopel = triggerfactory.wrapper( mockbot, ':Test!test@example.com PRIVMSG +#channel :test message') wrapper.action('Hi!') @@ -245,7 +268,7 @@ def test_wrapper_reply_statusmsg(mockbot, triggerfactory): STATUSMSG=tuple('+'), ) - wrapper: bot.SopelWrapper = triggerfactory.wrapper( + wrapper: bot.Sopel = triggerfactory.wrapper( mockbot, ':Test!test@example.com PRIVMSG +#channel :test message') wrapper.reply('Hi!') @@ -702,10 +725,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -719,7 +741,7 @@ def testrule(bot, trigger): assert not rule_hello.is_global_rate_limited() # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed twice now assert mockbot.backend.message_sent == rawlist( @@ -757,10 +779,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -774,7 +795,7 @@ def testrule(bot, trigger): assert not rule_hello.is_global_rate_limited() # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert no new message assert mockbot.backend.message_sent == rawlist( @@ -811,10 +832,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -823,7 +843,7 @@ def testrule(bot, trigger): assert items == [1] # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert there is now a NOTICE assert mockbot.backend.message_sent == rawlist( @@ -860,10 +880,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -877,7 +896,7 @@ def testrule(bot, trigger): assert not rule_hello.is_global_rate_limited() # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert no new message assert mockbot.backend.message_sent == rawlist( @@ -914,10 +933,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -931,7 +949,7 @@ def testrule(bot, trigger): assert not rule_hello.is_global_rate_limited() # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert there is now a NOTICE assert mockbot.backend.message_sent == rawlist( @@ -968,10 +986,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -985,7 +1002,7 @@ def testrule(bot, trigger): assert rule_hello.is_global_rate_limited() # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert no new message assert mockbot.backend.message_sent == rawlist( @@ -1022,10 +1039,9 @@ def testrule(bot, trigger): # trigger and wrapper rule_trigger = trigger.Trigger( mockbot.settings, pretrigger, match, account=None) - wrapper = bot.SopelWrapper(mockbot, rule_trigger) # call rule - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert the rule has been executed assert mockbot.backend.message_sent == rawlist( @@ -1039,7 +1055,7 @@ def testrule(bot, trigger): assert rule_hello.is_global_rate_limited() # call rule again - mockbot.call_rule(rule_hello, wrapper, rule_trigger) + mockbot.call_rule(rule_hello, rule_trigger) # assert there is now a NOTICE assert mockbot.backend.message_sent == rawlist( diff --git a/test/test_module.py b/test/test_module.py index 0eed70e3dc..d8898b8032 100644 --- a/test/test_module.py +++ b/test/test_module.py @@ -31,12 +31,11 @@ def bot(configfactory, botfactory, triggerfactory, ircfactory): settings = configfactory('default.cfg', TMP_CONFIG) mockbot = botfactory.preloaded(settings) mockserver = ircfactory(mockbot) - - bot = triggerfactory.wrapper(mockbot, FOO_MESSAGE) mockserver.channel_joined('#Sopel') mockserver.join('Foo', '#Sopel') mockserver.mode_set('#Sopel', '+v', ['Foo']) + bot = triggerfactory.wrapper(mockbot, FOO_MESSAGE) return bot diff --git a/test/test_plugin.py b/test/test_plugin.py index 324a223897..c57e16c316 100644 --- a/test/test_plugin.py +++ b/test/test_plugin.py @@ -447,18 +447,18 @@ def test_require_bot_privilege(configfactory, settings = configfactory('default.cfg', TMP_CONFIG) mockbot = botfactory.preloaded(settings) mockserver = ircfactory(mockbot) - - bot = triggerfactory.wrapper(mockbot, BAN_MESSAGE) mockserver.channel_joined('#chan') mockserver.join('Foo', '#chan') - mockserver.mode_set('#chan', '+vo', ['Foo', bot.nick]) + mockserver.mode_set('#chan', '+vo', ['Foo', mockbot.nick]) + + bot = triggerfactory.wrapper(mockbot, BAN_MESSAGE) @plugin.command('ban') @plugin.require_bot_privilege(plugin.VOICE) def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is True, ( + assert mock(bot, bot.trigger) is True, ( 'Bot must meet the requirement when having a higher privilege level.') @plugin.command('ban') @@ -466,14 +466,14 @@ def mock(bot, trigger): def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is True + assert mock(bot, bot.trigger) is True @plugin.command('ban') @plugin.require_bot_privilege(plugin.OWNER) def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is not True + assert mock(bot, bot.trigger) is not True assert not bot.backend.message_sent @plugin.command('ban') @@ -481,7 +481,7 @@ def mock(bot, trigger): def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is not True + assert mock(bot, bot.trigger) is not True assert bot.backend.message_sent == rawlist('PRIVMSG #chan :Nope') @plugin.command('ban') @@ -489,7 +489,7 @@ def mock(bot, trigger): def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is not True + assert mock(bot, bot.trigger) is not True assert bot.backend.message_sent[1:] == rawlist('PRIVMSG #chan :Foo: Nope') @@ -501,29 +501,29 @@ def test_require_bot_privilege_private_message(configfactory, mockbot = botfactory.preloaded(settings) mockserver = ircfactory(mockbot) - bot = triggerfactory.wrapper(mockbot, BAN_PRIVATE_MESSAGE) mockserver.channel_joined('#chan') mockserver.join('Foo', '#chan') - mockserver.mode_set('#chan', '+vo', ['Foo', bot.nick]) + mockserver.mode_set('#chan', '+vo', ['Foo', mockbot.nick]) + bot = triggerfactory.wrapper(mockbot, BAN_PRIVATE_MESSAGE) @plugin.command('ban') @plugin.require_bot_privilege(plugin.VOICE) def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is True + assert mock(bot, bot.trigger) is True @plugin.command('ban') @plugin.require_bot_privilege(plugin.OP) def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is True + assert mock(bot, bot.trigger) is True @plugin.command('ban') @plugin.require_bot_privilege(plugin.OWNER) def mock(bot, trigger): return True - assert mock(bot, bot._trigger) is True, ( + assert mock(bot, bot.trigger) is True, ( 'There must not be privilege check for a private message.') From 928be77215112ffa530d83ac1e88f76a92b0a39f Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Tue, 30 May 2023 10:25:50 +0200 Subject: [PATCH 2/2] Update sopel/bot.py --- sopel/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sopel/bot.py b/sopel/bot.py index 247e30a18b..fc64e69fd9 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -219,7 +219,7 @@ def hostmask(self) -> Optional[str]: return None user: Optional[User] = self.users.get(self.nick) - if not user: + if user is None: return None return user.hostmask