From 6dff4532a4b0058ce6f6e2a092c5434fa4c0e6e4 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:05:57 +0200 Subject: [PATCH 01/28] Implement PluginManager interface This interface splits up code from Plugin and separates the role of EventHandler from Plugins. (cherry picked from commit 91c2fec22a547e3e8b0a74de105efb9cf9152a50) --- mmpy_bot/plugins/base.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py index 6a437206..e9b3680f 100644 --- a/mmpy_bot/plugins/base.py +++ b/mmpy_bot/plugins/base.py @@ -167,3 +167,70 @@ def generate_plugin_help( ) return plug_help + + +class PluginManager: + """PluginManager is responsible for initializing all plugins and display aggregated + help from each of them. + + It is supposed to be transparent to EventHandler that interacts directly with each + individual Plugin. + """ + + def __init__( + self, + plugins: Sequence[Plugin], + ): + self.settings: Optional[Settings] = None + self.plugins = plugins + + self.message_listeners: Dict[re.Pattern, List[MessageFunction]] = defaultdict( + list + ) + self.webhook_listeners: Dict[re.Pattern, List[WebHookFunction]] = defaultdict( + list + ) + + def initialize(self, driver: Driver, settings: Settings): + for plugin in self.plugins: + plugin.initialize(driver, self, settings) + + # Register listeners for any listener functions in the plugin + for attribute in dir(plugin): + attribute = getattr(plugin, attribute) + if not isinstance(attribute, Function): + continue + + # Register this function and any potential siblings + for function in [attribute] + attribute.siblings: + # Plugin message/webhook handlers can be decorated multiple times + # resulting in multiple siblings that do not have .plugin defined + # or where the relationship with the parent plugin is incorrect + function.plugin = plugin + if isinstance(function, MessageFunction): + self.message_listeners[function.matcher].append(function) + elif isinstance(function, WebHookFunction): + self.webhook_listeners[function.matcher].append(function) + else: + raise TypeError( + f"{plugin.__class__.__name__} has a function of unsupported" + f" type {type(function)}." + ) + + def start(self): + """Trigger on_start() on every registered plugin.""" + for plugin in self.plugins: + plugin.on_start() + + def stop(self): + """Trigger on_stop() on every registered plugin.""" + for plugin in self.plugins: + plugin.on_stop() + + def get_help(self) -> List[FunctionInfo]: + """Returns a list of FunctionInfo items for every registered message and webhook + listener.""" + plug_help = generate_plugin_help(self.message_listeners) + plug_help += generate_plugin_help(self.webhook_listeners) + + return plug_help From 1794e427ef7758f18d771d54ac953c9f487e1e14 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:27:04 +0200 Subject: [PATCH 02/28] Simplify Plugin code by use of PluginManager --- mmpy_bot/plugins/base.py | 48 ++++++++++++---------------------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py index e9b3680f..7533b52e 100644 --- a/mmpy_bot/plugins/base.py +++ b/mmpy_bot/plugins/base.py @@ -5,10 +5,10 @@ from abc import ABC from collections import defaultdict from dataclasses import dataclass -from typing import Any, Dict, List, MutableSequence, Optional, Sequence, Union +from typing import Any, Dict, List, Optional, Sequence, Union from mmpy_bot.driver import Driver -from mmpy_bot.function import Function, MessageFunction, WebHookFunction, listen_to +from mmpy_bot.function import Function, MessageFunction, WebHookFunction from mmpy_bot.settings import Settings from mmpy_bot.utils import split_docstring from mmpy_bot.wrappers import EventWrapper, Message @@ -27,39 +27,19 @@ class Plugin(ABC): def __init__(self): self.driver: Optional[Driver] = None - self.message_listeners: Dict[ - re.Pattern, MutableSequence[MessageFunction] - ] = defaultdict(list) - self.webhook_listeners: Dict[ - re.Pattern, MutableSequence[WebHookFunction] - ] = defaultdict(list) - - # We have to register the help function listeners at runtime to prevent the - # Function object from being shared across different Plugins. - self.help = listen_to("^help$", needs_mention=True)(Plugin.help) - self.help = listen_to("^!help$")(self.help) - - def initialize(self, driver: Driver, settings: Optional[Settings] = None): - self.driver = driver - - # Register listeners for any listener functions we might have - for attribute in dir(self): - attribute = getattr(self, attribute) - if isinstance(attribute, Function): - # Register this function and any potential siblings - for function in [attribute] + attribute.siblings: - function.plugin = self - if isinstance(function, MessageFunction): - self.message_listeners[function.matcher].append(function) - elif isinstance(function, WebHookFunction): - self.webhook_listeners[function.matcher].append(function) - else: - raise TypeError( - f"{self.__class__.__name__} has a function of unsupported" - f" type {type(function)}." - ) + self.plugin_manager: Optional[PluginManager] = None + self.settings: Optional[Settings] = None + self.docstring = self.__doc__ if self.__doc__ != Plugin.__doc__ else None - return self + def initialize( + self, + driver: Driver, + plugin_manager: PluginManager, + settings: Settings, + ): + self.driver = driver + self.plugin_manager = plugin_manager + self.settings = settings def on_start(self): """Will be called after initialization. From 3d36f2ee1df169bcb7da0159128b5850132e8606 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:10:22 +0200 Subject: [PATCH 03/28] Use PluginManager in Bot class --- mmpy_bot/bot.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/mmpy_bot/bot.py b/mmpy_bot/bot.py index 7559c2db..33f60991 100644 --- a/mmpy_bot/bot.py +++ b/mmpy_bot/bot.py @@ -1,11 +1,11 @@ import asyncio import logging import sys -from typing import Optional, Sequence +from typing import List, Optional, Union from mmpy_bot.driver import Driver from mmpy_bot.event_handler import EventHandler -from mmpy_bot.plugins import ExamplePlugin, Plugin, WebHookExample +from mmpy_bot.plugins import ExamplePlugin, Plugin, PluginManager, WebHookExample from mmpy_bot.settings import Settings from mmpy_bot.webhook_server import WebHookServer @@ -22,11 +22,16 @@ class Bot: def __init__( self, settings: Optional[Settings] = None, - plugins: Optional[Sequence[Plugin]] = None, + plugins: Optional[Union[List[Plugin], PluginManager]] = None, enable_logging: bool = True, ): if plugins is None: - plugins = [ExamplePlugin(), WebHookExample()] + self.plugin_manager = PluginManager([ExamplePlugin(), WebHookExample()]) + elif isinstance(plugins, PluginManager): + self.plugin_manager = plugins + else: + self.plugin_manager = PluginManager(plugins) + # Use default settings if none were specified. self.settings = settings or Settings() @@ -48,9 +53,9 @@ def __init__( } ) self.driver.login() - self.plugins = self._initialize_plugins(plugins) + self.plugin_manager.initialize(self.driver, self.settings) self.event_handler = EventHandler( - self.driver, settings=self.settings, plugins=self.plugins + self.driver, settings=self.settings, plugin_manager=self.plugin_manager ) self.webhook_server = None @@ -80,11 +85,6 @@ def _register_logger(self): ) logging.getLogger("").addHandler(self.console) - def _initialize_plugins(self, plugins: Sequence[Plugin]): - for plugin in plugins: - plugin.initialize(self.driver, self.settings) - return plugins - def _initialize_webhook_server(self): self.webhook_server = WebHookServer( url=self.settings.WEBHOOK_HOST_URL, port=self.settings.WEBHOOK_HOST_PORT @@ -110,8 +110,8 @@ def run(self): if self.settings.WEBHOOK_HOST_ENABLED: self.driver.threadpool.start_webhook_server_thread(self.webhook_server) - for plugin in self.plugins: - plugin.on_start() + # Trigger "start" methods on every plugin + self.plugin_manager.start() # Start listening for events self.event_handler.start() @@ -129,9 +129,10 @@ def stop(self): return log.info("Stopping bot.") + # Shutdown the running plugins - for plugin in self.plugins: - plugin.on_stop() + self.plugin_manager.stop() + # Stop the threadpool self.driver.threadpool.stop() self.running = False From ee4a18dd8bece2fa1fc4844aad95cc2ace139bfe Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:11:27 +0200 Subject: [PATCH 04/28] Implement PluginManager interface in EventHandler --- mmpy_bot/event_handler.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/mmpy_bot/event_handler.py b/mmpy_bot/event_handler.py index e0318d15..28f0a791 100644 --- a/mmpy_bot/event_handler.py +++ b/mmpy_bot/event_handler.py @@ -3,11 +3,9 @@ import logging import queue import re -from collections import defaultdict -from typing import Sequence from mmpy_bot.driver import Driver -from mmpy_bot.plugins import Plugin +from mmpy_bot.plugins import PluginManager from mmpy_bot.settings import Settings from mmpy_bot.webhook_server import NoResponse from mmpy_bot.wrappers import Message, WebHookEvent @@ -20,7 +18,7 @@ def __init__( self, driver: Driver, settings: Settings, - plugins: Sequence[Plugin], + plugin_manager: PluginManager, ignore_own_messages=True, ): """The EventHandler class takes care of the connection to mattermost and calling @@ -28,19 +26,10 @@ def __init__( self.driver = driver self.settings = settings self.ignore_own_messages = ignore_own_messages - self.plugins = plugins + self.plugin_manager = plugin_manager self._name_matcher = re.compile(rf"^@?{self.driver.username}\:?\s?") - # Collect the listeners from all plugins - self.message_listeners = defaultdict(list) - self.webhook_listeners = defaultdict(list) - for plugin in self.plugins: - for matcher, functions in plugin.message_listeners.items(): - self.message_listeners[matcher].extend(functions) - for matcher, functions in plugin.webhook_listeners.items(): - self.webhook_listeners[matcher].extend(functions) - def start(self): # This is blocking, will loop forever self.driver.init_websocket(self._handle_event) @@ -87,8 +76,8 @@ async def _handle_post(self, post): # Find all the listeners that match this message, and have their plugins handle # the rest. tasks = [] - for matcher, functions in self.message_listeners.items(): - match = matcher.search(message.text) + for matcher, functions in self.plugin_manager.message_listeners.items(): + match = matcher.match(message.text) if match: groups = list([group for group in match.groups() if group != ""]) for function in functions: @@ -107,8 +96,8 @@ async def _handle_webhook(self, event: WebHookEvent): # Find all the listeners that match this webhook id, and have their plugins # handle the rest. tasks = [] - for matcher, functions in self.webhook_listeners.items(): - match = matcher.search(event.webhook_id) + for matcher, functions in self.plugin_manager.webhook_listeners.items(): + match = matcher.match(event.webhook_id) if match: for function in functions: # Create an asyncio task to handle this callback From 67f968916a43ce4e04028a46d18148b633faa153 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:25:35 +0200 Subject: [PATCH 05/28] Fix type checking and propagation of docstrings through Function --- mmpy_bot/function.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py index dee34035..08212897 100644 --- a/mmpy_bot/function.py +++ b/mmpy_bot/function.py @@ -5,7 +5,7 @@ import logging import re from abc import ABC, abstractmethod -from typing import Callable, Optional, Sequence +from typing import TYPE_CHECKING, Callable, Optional, Sequence, Union import click @@ -13,13 +13,17 @@ from mmpy_bot.webhook_server import NoResponse from mmpy_bot.wrappers import Message, WebHookEvent +if TYPE_CHECKING: + from mmpy_bot.plugins import Plugin + + log = logging.getLogger("mmpy.function") class Function(ABC): def __init__( self, - function: Callable, + function: Union[Callable, Function], matcher: re.Pattern, **metadata, ): @@ -38,9 +42,9 @@ def __init__( self.metadata = metadata # To be set in the child class or from the parent plugin - self.plugin = None + self.plugin: Optional[Plugin] = None self.name: Optional[str] = None - self.docstring: Optional[str] = None + self.docstring = self.function.__doc__ or "" @abstractmethod def __call__(self, *args): @@ -97,12 +101,9 @@ def __init__( info_name=self.matcher.pattern.strip("^").split(" (.*)?")[0], ) as ctx: # Get click help string and do some extra formatting - self.docstring = self.function.get_help(ctx).replace( - "\n", f"\n{spaces(8)}" - ) + self.docstring += f"\n\n{self.function.get_help(ctx)}" else: _function = self.function - self.docstring = self.function.__doc__ self.name = _function.__qualname__ @@ -229,7 +230,7 @@ def wrapped_func(func): reg = f"^{reg.strip('^')} (.*)?" # noqa pattern = re.compile(reg, regexp_flag) - return MessageFunction( + new_func = MessageFunction( func, matcher=pattern, direct_only=direct_only, @@ -240,6 +241,10 @@ def wrapped_func(func): **metadata, ) + # Preserve docstring + new_func.__doc__ = func.__doc__ + return new_func + return wrapped_func @@ -258,7 +263,6 @@ def __init__( ) self.name = self.function.__qualname__ - self.docstring = self.function.__doc__ argspec = list(inspect.signature(self.function).parameters.keys()) if not argspec == ["self", "event"]: @@ -296,10 +300,14 @@ def listen_webhook( def wrapped_func(func): pattern = re.compile(regexp) - return WebHookFunction( + new_func = WebHookFunction( func, matcher=pattern, **metadata, ) + # Preserve docstring + new_func.__doc__ = func.__doc__ + return new_func + return wrapped_func From 39c05024dcdede5de0f34e0939c3ba69ac9e3498 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:31:41 +0200 Subject: [PATCH 06/28] Use PluginManager in webhook example --- mmpy_bot/plugins/webhook_example.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mmpy_bot/plugins/webhook_example.py b/mmpy_bot/plugins/webhook_example.py index f577a4ee..8a152d25 100644 --- a/mmpy_bot/plugins/webhook_example.py +++ b/mmpy_bot/plugins/webhook_example.py @@ -1,6 +1,6 @@ from mmpy_bot.driver import Driver from mmpy_bot.function import listen_to, listen_webhook -from mmpy_bot.plugins.base import Plugin +from mmpy_bot.plugins.base import Plugin, PluginManager from mmpy_bot.settings import Settings from mmpy_bot.wrappers import ActionEvent, Message, WebHookEvent @@ -8,8 +8,10 @@ class WebHookExample(Plugin): """Webhook plugin with examples of webhook server functionality.""" - def initialize(self, driver: Driver, settings: Settings): - super().initialize(driver, settings) + def initialize( + self, driver: Driver, plugin_manager: PluginManager, settings: Settings + ): + super().initialize(driver, plugin_manager, settings) self.webhook_host_url = settings.WEBHOOK_HOST_URL self.webhook_host_port = settings.WEBHOOK_HOST_PORT return self From 6f8eb1f6bc9cf496c27e72c534bc61fe3b3026df Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:32:47 +0200 Subject: [PATCH 07/28] Add PluginManager tests and fix existing ones --- mmpy_bot/plugins/example.py | 3 +- tests/unit_tests/bot_test.py | 8 +- tests/unit_tests/event_handler_test.py | 58 +++----- tests/unit_tests/function_test.py | 14 +- tests/unit_tests/plugin_manager_test.py | 169 ++++++++++++++++++++++++ tests/unit_tests/plugins_test.py | 60 +-------- 6 files changed, 214 insertions(+), 98 deletions(-) create mode 100644 tests/unit_tests/plugin_manager_test.py diff --git a/mmpy_bot/plugins/example.py b/mmpy_bot/plugins/example.py index ee1f10a4..ae2b68eb 100644 --- a/mmpy_bot/plugins/example.py +++ b/mmpy_bot/plugins/example.py @@ -6,7 +6,8 @@ import click import mattermostdriver -from mmpy_bot.plugins.base import Plugin, listen_to +from mmpy_bot.function import listen_to +from mmpy_bot.plugins.base import Plugin from mmpy_bot.scheduler import schedule from mmpy_bot.wrappers import Message diff --git a/tests/unit_tests/bot_test.py b/tests/unit_tests/bot_test.py index 7d6ecb71..60032ce2 100644 --- a/tests/unit_tests/bot_test.py +++ b/tests/unit_tests/bot_test.py @@ -3,6 +3,7 @@ import pytest from mmpy_bot import Bot, ExamplePlugin, Settings +from mmpy_bot.plugins import PluginManager from ..integration_tests.utils import TestPlugin @@ -29,7 +30,8 @@ def test_init(self, login): ) assert bot.driver.options["url"] == "test_url.org" assert bot.driver.options["token"] == "random_token" - assert bot.plugins == plugins + assert isinstance(bot.plugin_manager, PluginManager) + assert bot.plugin_manager.plugins == plugins login.assert_called_once() # Verify that all of the passed plugins were initialized @@ -42,10 +44,10 @@ def test_run(self, bot, **mocks): bot.run() init_websocket.assert_called_once() - for plugin in bot.plugins: + for plugin in bot.plugin_manager.plugins: plugin.on_start.assert_called_once() bot.stop() - for plugin in bot.plugins: + for plugin in bot.plugin_manager.plugins: plugin.on_stop.assert_called_once() diff --git a/tests/unit_tests/event_handler_test.py b/tests/unit_tests/event_handler_test.py index 7959eedb..832f7f9d 100644 --- a/tests/unit_tests/event_handler_test.py +++ b/tests/unit_tests/event_handler_test.py @@ -5,6 +5,7 @@ from mmpy_bot import ExamplePlugin, Message, Settings, WebHookExample from mmpy_bot.driver import Driver from mmpy_bot.event_handler import EventHandler +from mmpy_bot.plugins import PluginManager from mmpy_bot.wrappers import WebHookEvent @@ -59,45 +60,20 @@ class TestEventHandler: @mock.patch("mmpy_bot.driver.Driver.username", new="my_username") def test_init(self): handler = EventHandler( - Driver(), Settings(), plugins=[ExamplePlugin(), WebHookExample()] + Driver(), + Settings(), + plugin_manager=PluginManager([ExamplePlugin(), WebHookExample()]), ) # Test the name matcher regexp assert handler._name_matcher.match("@my_username are you there?") assert not handler._name_matcher.match("@other_username are you there?") - # Test that all listeners from the individual plugins are now registered on - # the handler - for plugin in handler.plugins: - for pattern, listener in plugin.message_listeners.items(): - assert listener in handler.message_listeners[pattern] - for pattern, listener in plugin.webhook_listeners.items(): - assert listener in handler.webhook_listeners[pattern] - - # And vice versa, check that any listeners on the handler come from the - # registered plugins - for pattern, listeners in handler.message_listeners.items(): - for listener in listeners: - assert any( - [ - pattern in plugin.message_listeners - and listener in plugin.message_listeners[pattern] - for plugin in handler.plugins - ] - ) - for pattern, listeners in handler.webhook_listeners.items(): - for listener in listeners: - assert any( - [ - pattern in plugin.webhook_listeners - and listener in plugin.webhook_listeners[pattern] - for plugin in handler.plugins - ] - ) - @mock.patch("mmpy_bot.driver.Driver.username", new="my_username") def test_should_ignore(self): handler = EventHandler( - Driver(), Settings(IGNORE_USERS=["ignore_me"]), plugins=[] + Driver(), + Settings(IGNORE_USERS=["ignore_me"]), + plugin_manager=PluginManager([]), ) # We shouldn't ignore a message from betty, since she is not listed assert not handler._should_ignore(create_message(sender_name="betty")) @@ -110,14 +86,14 @@ def test_should_ignore(self): handler = EventHandler( Driver(), Settings(IGNORE_USERS=["ignore_me"]), - plugins=[], + plugin_manager=PluginManager([]), ignore_own_messages=False, ) assert not handler._should_ignore(create_message(sender_name="my_username")) @mock.patch("mmpy_bot.event_handler.EventHandler._handle_post") def test_handle_event(self, handle_post): - handler = EventHandler(Driver(), Settings(), plugins=[]) + handler = EventHandler(Driver(), Settings(), plugin_manager=PluginManager([])) # This event should trigger _handle_post asyncio.run(handler._handle_event(json.dumps(create_message().body))) # This event should not @@ -128,10 +104,14 @@ def test_handle_event(self, handle_post): @mock.patch("mmpy_bot.driver.Driver.username", new="my_username") def test_handle_post(self): # Create an initialized plugin so its listeners are registered + plugin = ExamplePlugin() driver = Driver() - plugin = ExamplePlugin().initialize(driver) + plugin_manager = PluginManager([plugin]) + settings = Settings() + # Initialization should happen via PluginManager + plugin_manager.initialize(driver, settings) # Construct a handler with it - handler = EventHandler(driver, Settings(), plugins=[plugin]) + handler = EventHandler(driver, settings, plugin_manager) # Mock the call_function of the plugin so we can make some asserts async def mock_call_function(function, message, groups): @@ -154,10 +134,14 @@ async def mock_call_function(function, message, groups): def test_handle_webhook(self): # Create an initialized plugin so its listeners are registered + plugin = WebHookExample() driver = Driver() - plugin = WebHookExample().initialize(driver, Settings()) + plugin_manager = PluginManager([plugin]) + settings = Settings() + # Initialization should happen via PluginManager + plugin_manager.initialize(driver, settings) # Construct a handler with it - handler = EventHandler(driver, Settings(), plugins=[plugin]) + handler = EventHandler(driver, settings, plugin_manager) # Mock the call_function of the plugin so we can make some asserts async def mock_call_function(function, event, groups): diff --git a/tests/unit_tests/function_test.py b/tests/unit_tests/function_test.py index 3097251d..a63c6b08 100644 --- a/tests/unit_tests/function_test.py +++ b/tests/unit_tests/function_test.py @@ -8,6 +8,7 @@ from mmpy_bot import ExamplePlugin, Settings, listen_to from mmpy_bot.driver import Driver from mmpy_bot.function import Function, MessageFunction, WebHookFunction, listen_webhook +from mmpy_bot.plugins import PluginManager from mmpy_bot.webhook_server import NoResponse from mmpy_bot.wrappers import WebHookEvent @@ -124,7 +125,8 @@ def mocked_reply(message, response): assert "no such option: --nonexistent-arg" in response.lower() assert f.docstring in response - f.plugin = ExamplePlugin().initialize(Driver(), Settings()) + f.plugin = ExamplePlugin() + f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings()) with mock.patch.object( f.plugin.driver, "reply_to", wraps=mocked_reply ) as mock_function: @@ -136,7 +138,8 @@ def test_needs_mention(self): # noqa wrapped = mock.create_autospec(example_listener) wrapped.__qualname__ = "wrapped" f = listen_to("", needs_mention=True)(wrapped) - f.plugin = ExamplePlugin().initialize(Driver()) + f.plugin = ExamplePlugin() + f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings()) # The default message mentions the specified user ID, so should be called f(create_message(mentions=["qmw86q7qsjriura9jos75i4why"])) @@ -176,7 +179,8 @@ def fake_reply(message, text): driver.reply_to = mock.Mock(wraps=fake_reply) f = listen_to("", allowed_users=["Betty"])(wrapped) - f.plugin = ExamplePlugin().initialize(driver) + f.plugin = ExamplePlugin() + f.plugin.initialize(driver, PluginManager([f.plugin]), Settings()) # This is fine, the names are not caps sensitive f(create_message(sender_name="betty")) @@ -286,7 +290,9 @@ def test_click_exception(self): listen_webhook("")(click.command()(example_webhook_listener)) def test_ensure_response(self): - p = ExamplePlugin().initialize(Driver()) + p = ExamplePlugin() + plugin_manager = PluginManager([p]) + p.initialize(Driver(), plugin_manager, Settings()) def mock_respond(event, response): event.responded = True diff --git a/tests/unit_tests/plugin_manager_test.py b/tests/unit_tests/plugin_manager_test.py new file mode 100644 index 00000000..f08617df --- /dev/null +++ b/tests/unit_tests/plugin_manager_test.py @@ -0,0 +1,169 @@ +import re +from unittest import mock + +import click + +from mmpy_bot import Plugin, Settings, listen_to, listen_webhook +from mmpy_bot.driver import Driver +from mmpy_bot.plugins import PluginManager + + +# Used in the plugin tests below +class FakePlugin(Plugin): + """Hello FakePlugin. + + This is a plugin level docstring + """ + + @listen_to("pattern", needs_mention=True) + def my_function(self, message, another_arg=True): + """This is the docstring of my_function.""" + pass + + @listen_to("direct_pattern", direct_only=True, allowed_users=["admin"]) + def direct_function(self, message): + """Help direct function.""" + pass + + @listen_to("async_pattern") + @listen_to("another_async_pattern", direct_only=True) + async def my_async_function(self, message): + """Async function docstring.""" + pass + + @listen_to("click_command") + @click.command(help="Help string for the entire function.") + @click.option( + "--option", type=int, default=0, help="Help string for the optional argument." + ) + def click_commmand(self, message, option): + """Extended docstring. + + This docstring will have 'click --help' appended + """ + pass + + @listen_to("hi_custom", custom="Custom attribute") + def hi_custom(self, message): + """A custom function.""" + pass + + @listen_webhook("webhook_id") + def webhook_listener(self, event): + """A webhook function.""" + pass + + +def expand_func_names(f): + return [f] + f.siblings + + +msg_listeners = { + re.compile("pattern"): expand_func_names(FakePlugin.my_function), + re.compile("direct_pattern"): expand_func_names(FakePlugin.direct_function), + re.compile("another_async_pattern"): expand_func_names( + FakePlugin.my_async_function + ), + re.compile("async_pattern"): expand_func_names(FakePlugin.my_async_function), + re.compile("hi_custom"): expand_func_names(FakePlugin.hi_custom), + # Click commands construct a regex pattern from the listen_to pattern + re.compile("^click_command (.*)?"): expand_func_names(FakePlugin.click_commmand), +} + +hook_listeners = { + re.compile("webhook_id"): expand_func_names(FakePlugin.webhook_listener) +} + + +class TestPlugin: + def test_init_plugin(self): + p = FakePlugin() + m = PluginManager([p]) + with mock.patch.object(p, "initialize") as mocked: + m.initialize(Driver(), Settings()) + + mocked.assert_called_once() + + def test_initialize(self): + m = PluginManager([FakePlugin()]) + m.initialize(Driver(), Settings()) + + # Test whether the function was registered properly + assert m.message_listeners[re.compile("pattern")] == [ + FakePlugin.my_function, + ] + + # This function should be registered twice, once for each listener + assert len(m.message_listeners[re.compile("async_pattern")]) == 1 + assert ( + m.message_listeners[re.compile("async_pattern")][0].function + == FakePlugin.my_async_function.function + ) + + assert len(m.message_listeners[re.compile("another_async_pattern")]) == 1 + assert ( + m.message_listeners[re.compile("another_async_pattern")][0].function + == FakePlugin.my_async_function.function + ) + + assert len(m.webhook_listeners) == 1 + assert m.webhook_listeners[re.compile("webhook_id")] == [ + FakePlugin.webhook_listener + ] + + +class TestPluginManager: + def setup_method(self): + self.p = FakePlugin() + self.plugin_manager = PluginManager([self.p]) + + def test_init(self): + self.plugin_manager.initialize(Driver(), Settings()) + + # Test that listeners of individual plugins are now registered on the plugin_manager + assert len(msg_listeners) == len(self.plugin_manager.message_listeners) + for pattern, listeners in self.plugin_manager.message_listeners.items(): + for listener in listeners: + assert pattern in msg_listeners + assert listener in msg_listeners[pattern] + + assert len(hook_listeners) == len(self.plugin_manager.webhook_listeners) + for pattern, listeners in self.plugin_manager.webhook_listeners.items(): + for listener in listeners: + assert pattern in hook_listeners + assert listener in hook_listeners[pattern] + + def test_get_help(self): + # Prior to initialization there is no help + assert self.plugin_manager.get_help() == [] + + self.plugin_manager.initialize(Driver(), Settings()) + + assert len(self.plugin_manager.get_help()) != 0 + + for hlp in self.plugin_manager.get_help(): + assert hlp.location == "FakePlugin" + assert ( + hlp.function.plugin.__doc__ + == """Hello FakePlugin. + + This is a plugin level docstring + """ + ) + assert hlp.is_click == hlp.function.is_click_function + assert hlp.docfull.startswith(hlp.function.__doc__) + + if hlp.pattern in ["direct_pattern", "another_async_pattern"]: + assert hlp.direct + else: + assert not hlp.direct + + if hlp.pattern in ["pattern"]: + assert hlp.mention + else: + assert not hlp.mention + + if hlp.help_type == "message": + assert hlp.pattern in map(lambda x: x.pattern, msg_listeners) + elif hlp.help_type == "webhook": + assert hlp.pattern in map(lambda x: x.pattern, hook_listeners) diff --git a/tests/unit_tests/plugins_test.py b/tests/unit_tests/plugins_test.py index 3df4b501..a32fbadd 100644 --- a/tests/unit_tests/plugins_test.py +++ b/tests/unit_tests/plugins_test.py @@ -1,79 +1,33 @@ import asyncio -import re from unittest import mock -import click - -from mmpy_bot import Plugin, listen_to, listen_webhook +from mmpy_bot import Plugin, Settings, listen_to from mmpy_bot.driver import Driver +from mmpy_bot.plugins import PluginManager from .event_handler_test import create_message # Used in the plugin tests below class FakePlugin(Plugin): - @listen_to("pattern") - def my_function(self, message, needs_mention=True): + @listen_to("pattern", needs_mention=True) + def my_function(self, message, another_arg=True): """This is the docstring of my_function.""" pass - @listen_to("direct_pattern", direct_only=True, allowed_users=["admin"]) - def direct_function(self, message): - pass - @listen_to("async_pattern") @listen_to("another_async_pattern", direct_only=True) async def my_async_function(self, message): """Async function docstring.""" pass - @listen_to("click_command") - @click.command(help="Help string for the entire function.") - @click.option( - "--option", type=int, default=0, help="Help string for the optional argument." - ) - def click_commmand(self, message, option): - """Ignored docstring. - - Just for code readability. - """ - pass - - @listen_webhook("webhook_id") - def webhook_listener(self, event): - """A webhook function.""" - pass - class TestPlugin: - def test_initialize(self): - p = FakePlugin().initialize(Driver()) - # Test whether the function was registered properly - assert p.message_listeners[re.compile("pattern")] == [ - FakePlugin.my_function, - ] - - # This function should be registered twice, once for each listener - assert len(p.message_listeners[re.compile("async_pattern")]) == 1 - assert ( - p.message_listeners[re.compile("async_pattern")][0].function - == FakePlugin.my_async_function.function - ) - - assert len(p.message_listeners[re.compile("another_async_pattern")]) == 1 - assert ( - p.message_listeners[re.compile("another_async_pattern")][0].function - == FakePlugin.my_async_function.function - ) - - assert len(p.webhook_listeners) == 1 - assert p.webhook_listeners[re.compile("webhook_id")] == [ - FakePlugin.webhook_listener - ] - @mock.patch("mmpy_bot.driver.ThreadPool.add_task") def test_call_function(self, add_task): - p = FakePlugin().initialize(Driver()) + p = FakePlugin() + # Functions only listen for events when initialized via PluginManager + PluginManager([p]).initialize(Driver(), Settings()) # Since this is not an async function, a task should be added to the threadpool message = create_message(text="pattern") From a0e9f4385a69771ba6eda118460263c99e6103c0 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 15:13:46 +0200 Subject: [PATCH 08/28] Bump black to newer version --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13cb0f43..acc8c1f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - "--filter-files" - repo: https://github.com/psf/black # Code style formatting - rev: 21.5b1 + rev: 22.3.0 hooks: - id: black exclude: (.*/)*snapshots/ From b413e1df417e4ea7a32134f14ac7edb2c0d8fbef Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 15:16:09 +0200 Subject: [PATCH 09/28] Ignore snapshots in flake8 --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index acc8c1f7..2edb7a14 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,6 +31,7 @@ repos: rev: 3.9.0 hooks: - id: flake8 + exclude: (.*/)*snapshots/ - repo: https://github.com/mattseymour/pre-commit-pytype rev: '2020.10.8' hooks: From 26c2ed1c6a2e0b99ef404e881f808cb5d0a510bf Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 15:16:24 +0200 Subject: [PATCH 10/28] Refactor get_help_string --- mmpy_bot/plugins/base.py | 21 ++++++++++++------- .../unit_tests/snapshots/snap_plugins_test.py | 6 ------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py index 7533b52e..75075831 100644 --- a/mmpy_bot/plugins/base.py +++ b/mmpy_bot/plugins/base.py @@ -16,6 +16,16 @@ log = logging.getLogger("mmpy.plugin_base") +def collect_listener_help(values): + chunks = [] + + for functions in values: + for function in functions: + chunks.append(f"- {function.get_help_string()}") + + return "".join(chunks) + + class Plugin(ABC): """A Plugin is a self-contained class that defines what functions should be executed given different inputs. @@ -73,15 +83,12 @@ async def call_function( def get_help_string(self): string = f"Plugin {self.__class__.__name__} has the following functions:\n" string += "----\n" - for functions in self.message_listeners.values(): - for function in functions: - string += f"- {function.get_help_string()}" - string += "----\n" + string += collect_listener_help(self.message_listeners.values()) + string += "----\n" + if len(self.webhook_listeners) > 0: string += "### Registered webhooks:\n" - for functions in self.webhook_listeners.values(): - for function in functions: - string += f"- {function.get_help_string()}" + string += collect_listener_help(self.webhook_listeners.values()) return string diff --git a/tests/unit_tests/snapshots/snap_plugins_test.py b/tests/unit_tests/snapshots/snap_plugins_test.py index faa0502a..39c75e5c 100644 --- a/tests/unit_tests/snapshots/snap_plugins_test.py +++ b/tests/unit_tests/snapshots/snap_plugins_test.py @@ -16,29 +16,23 @@ Options: --option INTEGER Help string for the optional argument. --help Show this message and exit. ----- - `direct_pattern`: No description provided. Additional information: - Needs to be a direct message. - Restricted to certain users. ----- - `^!help$`: Prints the list of functions registered on every active plugin. ----- - `^help$`: Prints the list of functions registered on every active plugin. Additional information: - Needs to either mention @ or be a direct message. ----- - `async_pattern`: Async function docstring. ----- - `another_async_pattern`: Async function docstring. Additional information: - Needs to be a direct message. ----- - `pattern`: This is the docstring of my_function. ---- From f14b71304e14f5db2b2122cd24fc8afac3f7abf8 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 15:50:19 +0200 Subject: [PATCH 11/28] Collect help from pluginmanager listeners --- mmpy_bot/plugins/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py index 75075831..b74d74b9 100644 --- a/mmpy_bot/plugins/base.py +++ b/mmpy_bot/plugins/base.py @@ -83,12 +83,14 @@ async def call_function( def get_help_string(self): string = f"Plugin {self.__class__.__name__} has the following functions:\n" string += "----\n" - string += collect_listener_help(self.message_listeners.values()) + string += collect_listener_help(self.plugin_manager.message_listeners.values()) string += "----\n" - if len(self.webhook_listeners) > 0: + if len(self.plugin_manager.webhook_listeners) > 0: string += "### Registered webhooks:\n" - string += collect_listener_help(self.webhook_listeners.values()) + string += collect_listener_help( + self.plugin_manager.webhook_listeners.values() + ) return string From 6ff1a4d29e70181202d10179e0f2f20846ea0d5f Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:25:35 +0200 Subject: [PATCH 12/28] Fix type checking and propagation of docstrings through Function --- mmpy_bot/function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py index 08212897..33f95149 100644 --- a/mmpy_bot/function.py +++ b/mmpy_bot/function.py @@ -9,7 +9,7 @@ import click -from mmpy_bot.utils import completed_future, spaces +from mmpy_bot.utils import completed_future from mmpy_bot.webhook_server import NoResponse from mmpy_bot.wrappers import Message, WebHookEvent From adb843a51b78829d325bae8e2e7e040db9e03483 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:08:58 +0200 Subject: [PATCH 13/28] Implement help text formatting as a plugin --- mmpy_bot/plugins/help_example.py | 93 ++++++++++++++++++++++++++++++++ mmpy_bot/settings.py | 2 + 2 files changed, 95 insertions(+) create mode 100644 mmpy_bot/plugins/help_example.py diff --git a/mmpy_bot/plugins/help_example.py b/mmpy_bot/plugins/help_example.py new file mode 100644 index 00000000..53b3d902 --- /dev/null +++ b/mmpy_bot/plugins/help_example.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from mmpy_bot.driver import Driver +from mmpy_bot.function import listen_to +from mmpy_bot.plugins.base import Plugin, PluginManager +from mmpy_bot.settings import Settings +from mmpy_bot.wrappers import Message + + +class HelpPlugin(Plugin): + """Provide a `help` command that lists functions provided by all plugins. + + With a few plugins enabled the help text can become quite verbose. For this reason, + this plugin defaults to sending a private/direct message with the information. + + If you wish to disable this behavior pass `direct_help=False` and the help text will + be displayed in the channel where it is requested. + """ + + def __init__( + self, + direct_help: bool = True, + ): + super().__init__() + self.direct_help: bool = direct_help + + def initialize( + self, + driver: Driver, + plugin_manager: PluginManager, + settings: Settings, + ): + super().initialize(driver, plugin_manager, settings) + + if self.settings.RESPOND_CHANNEL_HELP: + self.help = listen_to("^!help$")(self.help) + + def get_help_string(self, message: Message) -> str: + """Renders help information (FunctionInfo objects) into a markdown string. + + Help information is presented in a condensed format, grouped into categories + """ + + def custom_sort(rec): + return ( + rec.metadata.get("category", ""), # No categories first + rec.help_type, + rec.pattern.lstrip("^[(-"), + ) + + help_function_info = sorted(self.get_help(message), key=custom_sort) + + string = "### The following functions have been registered:\n\n" + string += "###### `(*)` require the use of `@botname`, " + string += "`(+)` can only be used in direct message\n" + old_category = None + + for h in help_function_info: + # If categories are defined, group functions accordingly + category = h.metadata.get("category") + if category != old_category: + old_category = category + category = "uncategorized" if category is None else category + string += f"Category `{category}`:\n" + + cmd = h.metadata.get("human_description", h.pattern) + direct = "`(*)`" if h.direct else "" + mention = "`(+)`" if h.mention else "" + + if h.help_type == "webhook": + string += f"- `{cmd}` {direct} {mention} - (webhook) {h.docheader}\n" + else: + if not h.docheader: + string += f"- `{cmd}` {direct} {mention}\n" + else: + string += f"- `{cmd}` {direct} {mention} - {h.docheader}\n" + + return string + + def get_help(self, message: Message): + """Obtain Help info from PluginManager. + + Override this method if you need to customize which listeners will be included + in the help. + """ + return self.plugin_manager.get_help() + + @listen_to("^help$", needs_mention=True) + async def help(self, message: Message): + """Shows this help information.""" + self.driver.reply_to( + message, self.get_help_string(message), direct=self.direct_help + ) diff --git a/mmpy_bot/settings.py b/mmpy_bot/settings.py index 0025c522..0021c88f 100644 --- a/mmpy_bot/settings.py +++ b/mmpy_bot/settings.py @@ -56,6 +56,8 @@ class Settings: WEBHOOK_HOST_URL: str = "http://127.0.0.1" WEBHOOK_HOST_PORT: int = 8579 DEBUG: bool = False + # Respond to channel message "!help" (without @bot) + RESPOND_CHANNEL_HELP: bool = False LOG_FILE: Optional[str] = None LOG_FORMAT: str = "[%(asctime)s][%(name)s][%(levelname)s] %(message)s" LOG_DATE_FORMAT: str = "%m/%d/%Y %H:%M:%S" From 0c721dbd7965d0af3539b5bcc67f410887109d65 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:19:16 +0200 Subject: [PATCH 14/28] Remove help functions superseeded by HelpPlugin --- mmpy_bot/function.py | 39 -------------------------------- mmpy_bot/plugins/base.py | 30 +----------------------- tests/unit_tests/plugins_test.py | 5 ---- 3 files changed, 1 insertion(+), 73 deletions(-) diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py index 33f95149..478e5827 100644 --- a/mmpy_bot/function.py +++ b/mmpy_bot/function.py @@ -50,13 +50,6 @@ def __init__( def __call__(self, *args): pass - def get_help_string(self): - string = f"`{self.matcher.pattern}`:\n" - # Add a docstring - doc = self.docstring or "No description provided." - string += f"{spaces(8)}{doc}\n" - return string - class MessageFunction(Function): """Wrapper around a Plugin class method that should respond to certain Mattermost @@ -161,38 +154,6 @@ def __call__(self, message: Message, *args): return self.function(self.plugin, message, *args) - def get_help_string(self): - string = super().get_help_string() - if any( - [ - self.needs_mention, - self.direct_only, - self.allowed_users, - self.allowed_channels, - self.silence_fail_msg, - ] - ): - # Print some information describing the usage settings. - string += f"{spaces(4)}Additional information:\n" - if self.needs_mention: - string += ( - f"{spaces(4)}- Needs to either mention @{self.plugin.driver.username}" - " or be a direct message.\n" - ) - if self.direct_only: - string += f"{spaces(4)}- Needs to be a direct message.\n" - - if self.allowed_users: - string += f"{spaces(4)}- Restricted to certain users.\n" - - if self.allowed_channels: - string += f"{spaces(4)}- Restricted to certain channels.\n" - - if self.silence_fail_msg: - string += f"{spaces(4)}- If it should reply to a non privileged user / in a non privileged channel.\n" - - return string - def listen_to( regexp: str, diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py index b74d74b9..c0b21a08 100644 --- a/mmpy_bot/plugins/base.py +++ b/mmpy_bot/plugins/base.py @@ -11,21 +11,11 @@ from mmpy_bot.function import Function, MessageFunction, WebHookFunction from mmpy_bot.settings import Settings from mmpy_bot.utils import split_docstring -from mmpy_bot.wrappers import EventWrapper, Message +from mmpy_bot.wrappers import EventWrapper log = logging.getLogger("mmpy.plugin_base") -def collect_listener_help(values): - chunks = [] - - for functions in values: - for function in functions: - chunks.append(f"- {function.get_help_string()}") - - return "".join(chunks) - - class Plugin(ABC): """A Plugin is a self-contained class that defines what functions should be executed given different inputs. @@ -80,24 +70,6 @@ async def call_function( # a plugin-specific thread or process pool if we wanted. self.driver.threadpool.add_task(function, event, *groups) - def get_help_string(self): - string = f"Plugin {self.__class__.__name__} has the following functions:\n" - string += "----\n" - string += collect_listener_help(self.plugin_manager.message_listeners.values()) - string += "----\n" - - if len(self.plugin_manager.webhook_listeners) > 0: - string += "### Registered webhooks:\n" - string += collect_listener_help( - self.plugin_manager.webhook_listeners.values() - ) - - return string - - async def help(self, message: Message): - """Prints the list of functions registered on every active plugin.""" - self.driver.reply_to(message, self.get_help_string()) - @dataclass class FunctionInfo: diff --git a/tests/unit_tests/plugins_test.py b/tests/unit_tests/plugins_test.py index a32fbadd..fdce1692 100644 --- a/tests/unit_tests/plugins_test.py +++ b/tests/unit_tests/plugins_test.py @@ -45,8 +45,3 @@ def test_call_function(self, add_task): p.call_function(FakePlugin.my_async_function, message, groups=[]) ) mock_function.assert_called_once_with(p, message) - - def test_help_string(self, snapshot): - p = FakePlugin().initialize(Driver()) - # Compare the help string with the snapshotted version. - snapshot.assert_match(p.get_help_string()) From 1f2190c9c9e48d0c256b6e2bc8371c39268b7be2 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:26:28 +0200 Subject: [PATCH 15/28] PluginManager and HelpPlugin should be included in __init__ (cherry picked from commit 2e6bf84ab859c5e659c158f33aabbf7d2d026693) --- mmpy_bot/plugins/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mmpy_bot/plugins/__init__.py b/mmpy_bot/plugins/__init__.py index 52712e70..59970445 100644 --- a/mmpy_bot/plugins/__init__.py +++ b/mmpy_bot/plugins/__init__.py @@ -1,5 +1,6 @@ -from mmpy_bot.plugins.base import Plugin +from mmpy_bot.plugins.base import Plugin, PluginManager from mmpy_bot.plugins.example import ExamplePlugin +from mmpy_bot.plugins.help_example import HelpPlugin from mmpy_bot.plugins.webhook_example import WebHookExample -__all__ = ["Plugin", "ExamplePlugin", "WebHookExample"] +__all__ = ["Plugin", "PluginManager", "HelpPlugin", "ExamplePlugin", "WebHookExample"] From 0ba9885afeae54cbc922b7e21e313e54805a2b31 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Sat, 16 Jul 2022 00:09:47 +0200 Subject: [PATCH 16/28] Enable HelpPlugin by default --- mmpy_bot/bot.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/mmpy_bot/bot.py b/mmpy_bot/bot.py index 33f60991..0623e2ff 100644 --- a/mmpy_bot/bot.py +++ b/mmpy_bot/bot.py @@ -5,7 +5,13 @@ from mmpy_bot.driver import Driver from mmpy_bot.event_handler import EventHandler -from mmpy_bot.plugins import ExamplePlugin, Plugin, PluginManager, WebHookExample +from mmpy_bot.plugins import ( + ExamplePlugin, + HelpPlugin, + Plugin, + PluginManager, + WebHookExample, +) from mmpy_bot.settings import Settings from mmpy_bot.webhook_server import WebHookServer @@ -26,7 +32,9 @@ def __init__( enable_logging: bool = True, ): if plugins is None: - self.plugin_manager = PluginManager([ExamplePlugin(), WebHookExample()]) + self.plugin_manager = PluginManager( + [HelpPlugin(), ExamplePlugin(), WebHookExample()] + ) elif isinstance(plugins, PluginManager): self.plugin_manager = plugins else: From c9a7ca37227578f61aef70310368c4cf839b918f Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 15 Jul 2022 22:30:19 +0200 Subject: [PATCH 17/28] Add category metadata to example plugin for HelpPlugin formatting --- mmpy_bot/plugins/example.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/mmpy_bot/plugins/example.py b/mmpy_bot/plugins/example.py index ae2b68eb..1fce9962 100644 --- a/mmpy_bot/plugins/example.py +++ b/mmpy_bot/plugins/example.py @@ -15,17 +15,19 @@ class ExamplePlugin(Plugin): """Default plugin with examples of bot functionality and usage.""" - @listen_to("^admin$", direct_only=True, allowed_users=["admin", "root"]) + @listen_to( + "^admin$", direct_only=True, allowed_users=["admin", "root"], category="admin" + ) async def users_access(self, message: Message): """Showcases a function with restricted access.""" self.driver.reply_to(message, "Access allowed!") - @listen_to("^offtopic_channel$", allowed_channels=["off-topic"]) + @listen_to("^offtopic_channel$", allowed_channels=["off-topic"], category="admin") async def channels_access(self, message: Message): """Showcases a function which can only be used in specific channels.""" self.driver.reply_to(message, "Access allowed!") - @listen_to("^busy|jobs$", re.IGNORECASE, needs_mention=True) + @listen_to("^busy|jobs$", re.IGNORECASE, needs_mention=True, category="admin") async def busy_reply(self, message: Message): """Show the number of busy worker threads.""" busy = self.driver.threadpool.get_busy_workers() @@ -34,7 +36,7 @@ async def busy_reply(self, message: Message): f"Number of busy worker threads: {busy}", ) - @listen_to("hello_click", needs_mention=True) + @listen_to("hello_click", needs_mention=True, category="click") @click.command(help="An example click command with various arguments.") @click.argument("POSITIONAL_ARG", type=str) @click.option("--keyword-arg", type=float, default=5.0, help="A keyword arg.") @@ -42,6 +44,7 @@ async def busy_reply(self, message: Message): def hello_click( self, message: Message, positional_arg: str, keyword_arg: float, flag: bool ): + """A click function documented via docstring.""" response = ( "Received the following arguments:\n" f"- positional_arg: {positional_arg}\n" @@ -79,8 +82,9 @@ async def hello_file(self, message: Message): file.write_text("Hello from this file!") self.driver.reply_to(message, "Here you go", file_paths=[file]) - @listen_to("^!hello_webhook$", re.IGNORECASE) + @listen_to("^!hello_webhook$", re.IGNORECASE, category="webhook") async def hello_webhook(self, message: Message): + """A webhook that says hello.""" self.driver.webhooks.call_webhook( "eauegoqk4ibxigfybqrsfmt48r", options={ @@ -116,7 +120,9 @@ async def ping_reply(self, message: Message): """Pong.""" self.driver.reply_to(message, "pong") - @listen_to("^reply at (.*)$", re.IGNORECASE, needs_mention=True) + @listen_to( + "^reply at (.*)$", re.IGNORECASE, needs_mention=True, category="schedule" + ) def schedule_once(self, message: Message, trigger_time: str): """Schedules a reply to be sent at the given time. @@ -133,7 +139,12 @@ def schedule_once(self, message: Message, trigger_time: str): except ValueError as e: self.driver.reply_to(message, str(e)) - @listen_to("^schedule every ([0-9]+)$", re.IGNORECASE, needs_mention=True) + @listen_to( + "^schedule every ([0-9]+)$", + re.IGNORECASE, + needs_mention=True, + category="schedule", + ) def schedule_every(self, message: Message, seconds: int): """Schedules a reply every x seconds. Use the `cancel jobs` command to stop. @@ -144,7 +155,7 @@ def schedule_every(self, message: Message, seconds: int): self.driver.reply_to, message, f"Scheduled message every {seconds} seconds!" ) - @listen_to("^cancel jobs$", re.IGNORECASE, needs_mention=True) + @listen_to("^cancel jobs$", re.IGNORECASE, needs_mention=True, category="schedule") def cancel_jobs(self, message: Message): """Cancels all scheduled jobs, including recurring and one-time events.""" schedule.clear() From 4c480125796d7e5c465779b2ac5a5991c8c5e77e Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 16:11:47 +0200 Subject: [PATCH 18/28] Fix tests missing PluginManager instance --- tests/unit_tests/function_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/function_test.py b/tests/unit_tests/function_test.py index a63c6b08..0b35e0a9 100644 --- a/tests/unit_tests/function_test.py +++ b/tests/unit_tests/function_test.py @@ -204,7 +204,8 @@ def fake_reply(message, text): driver.reply_to = mock.Mock(wraps=fake_reply) f = listen_to("", allowed_channels=["off-topic"])(wrapped) - f.plugin = ExamplePlugin().initialize(driver) + f.plugin = ExamplePlugin() + f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings()) # This is fine, the names are not caps sensitive f(create_message(channel_name="off-topic")) @@ -230,7 +231,8 @@ def fake_reply(message, text): f = listen_to("", allowed_channels=["off-topic"], silence_fail_msg=True)( wrapped ) - f.plugin = ExamplePlugin().initialize(driver) + f.plugin = ExamplePlugin() + f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings()) # This is fine, the names are not caps sensitive f(create_message(channel_name="off-topic")) From c44e0720c6c66e0fddad37db0f3e255cb8f0e825 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 16:22:20 +0200 Subject: [PATCH 19/28] Refactor generate_plugin_help --- mmpy_bot/plugins/base.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/mmpy_bot/plugins/base.py b/mmpy_bot/plugins/base.py index c0b21a08..37f24bf6 100644 --- a/mmpy_bot/plugins/base.py +++ b/mmpy_bot/plugins/base.py @@ -85,6 +85,21 @@ class FunctionInfo: metadata: Dict +def get_function_characteristics(function): + """Returns a tuple describing the function user interface. + + Returns (direct_only, needs_mention, help_type) + """ + if isinstance(function, MessageFunction): + return (function.direct_only, function.needs_mention, "message") + elif isinstance(function, WebHookFunction): + return (False, False, "webhook") + else: + raise NotImplementedError( + f"Unknown/Unsupported listener type: '{type(function)}'" + ) + + def generate_plugin_help( listeners: Dict[re.Pattern[Any], List[Union[MessageFunction, WebHookFunction]]], ): @@ -99,18 +114,7 @@ def generate_plugin_help( for function in functions: plug_head, plug_full = split_docstring(function.plugin.__doc__) func_head, func_full = split_docstring(function.docstring) - - if isinstance(function, MessageFunction): - direct = function.direct_only - mention = function.needs_mention - help_type = "message" - elif isinstance(function, WebHookFunction): - direct = mention = False - help_type = "webhook" - else: - raise NotImplementedError( - f"Unknown/Unsupported listener type: '{type(function)}'" - ) + direct, mention, help_type = get_function_characteristics(function) plug_help.append( FunctionInfo( From 226ad9459ef78b2c16c0c22aa610a5bdf8882849 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 16:27:13 +0200 Subject: [PATCH 20/28] Refactor Bot PluginManager configuration --- mmpy_bot/bot.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/mmpy_bot/bot.py b/mmpy_bot/bot.py index 0623e2ff..6a5b5f69 100644 --- a/mmpy_bot/bot.py +++ b/mmpy_bot/bot.py @@ -31,22 +31,15 @@ def __init__( plugins: Optional[Union[List[Plugin], PluginManager]] = None, enable_logging: bool = True, ): - if plugins is None: - self.plugin_manager = PluginManager( - [HelpPlugin(), ExamplePlugin(), WebHookExample()] - ) - elif isinstance(plugins, PluginManager): - self.plugin_manager = plugins - else: - self.plugin_manager = PluginManager(plugins) + self._setup_plugin_manager(plugins) # Use default settings if none were specified. self.settings = settings or Settings() + self.console = None + if enable_logging: self._register_logger() - else: - self.console = None self.driver = Driver( { @@ -72,6 +65,16 @@ def __init__( self.running = False + def _setup_plugin_manager(self, plugins): + if plugins is None: + self.plugin_manager = PluginManager( + [HelpPlugin(), ExamplePlugin(), WebHookExample()] + ) + elif isinstance(plugins, PluginManager): + self.plugin_manager = plugins + else: + self.plugin_manager = PluginManager(plugins) + def _register_logger(self): logging.basicConfig( **{ From fa6ec203ad441f6ed1139cb11b24144b34134d55 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 16:42:12 +0200 Subject: [PATCH 21/28] Refactor HelpPlugin get_help_string --- mmpy_bot/plugins/help_example.py | 45 ++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/mmpy_bot/plugins/help_example.py b/mmpy_bot/plugins/help_example.py index 53b3d902..e9108970 100644 --- a/mmpy_bot/plugins/help_example.py +++ b/mmpy_bot/plugins/help_example.py @@ -7,6 +7,30 @@ from mmpy_bot.wrappers import Message +def _custom_help_sort(rec): + return ( + rec.metadata.get("category", ""), # No categories first + rec.help_type, + rec.pattern.lstrip("^[(-"), + ) + + +def _prepare_function_help_message(h, string): + cmd = h.metadata.get("human_description", h.pattern) + direct = "`(*)`" if h.direct else "" + mention = "`(+)`" if h.mention else "" + + if h.help_type == "webhook": + string += f"- `{cmd}` {direct} {mention} - (webhook) {h.docheader}\n" + else: + if not h.docheader: + string += f"- `{cmd}` {direct} {mention}\n" + else: + string += f"- `{cmd}` {direct} {mention} - {h.docheader}\n" + + return string + + class HelpPlugin(Plugin): """Provide a `help` command that lists functions provided by all plugins. @@ -41,14 +65,7 @@ def get_help_string(self, message: Message) -> str: Help information is presented in a condensed format, grouped into categories """ - def custom_sort(rec): - return ( - rec.metadata.get("category", ""), # No categories first - rec.help_type, - rec.pattern.lstrip("^[(-"), - ) - - help_function_info = sorted(self.get_help(message), key=custom_sort) + help_function_info = sorted(self.get_help(message), key=_custom_help_sort) string = "### The following functions have been registered:\n\n" string += "###### `(*)` require the use of `@botname`, " @@ -63,17 +80,7 @@ def custom_sort(rec): category = "uncategorized" if category is None else category string += f"Category `{category}`:\n" - cmd = h.metadata.get("human_description", h.pattern) - direct = "`(*)`" if h.direct else "" - mention = "`(+)`" if h.mention else "" - - if h.help_type == "webhook": - string += f"- `{cmd}` {direct} {mention} - (webhook) {h.docheader}\n" - else: - if not h.docheader: - string += f"- `{cmd}` {direct} {mention}\n" - else: - string += f"- `{cmd}` {direct} {mention} - {h.docheader}\n" + string = _prepare_function_help_message(h, string) return string From 55ca45521310d784a0b728472e147d1ec67600c8 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 16:57:26 +0200 Subject: [PATCH 22/28] Revert to re.search (#289) (#290) --- mmpy_bot/event_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mmpy_bot/event_handler.py b/mmpy_bot/event_handler.py index 28f0a791..ae873cb3 100644 --- a/mmpy_bot/event_handler.py +++ b/mmpy_bot/event_handler.py @@ -77,7 +77,7 @@ async def _handle_post(self, post): # the rest. tasks = [] for matcher, functions in self.plugin_manager.message_listeners.items(): - match = matcher.match(message.text) + match = matcher.search(message.text) if match: groups = list([group for group in match.groups() if group != ""]) for function in functions: @@ -97,7 +97,7 @@ async def _handle_webhook(self, event: WebHookEvent): # handle the rest. tasks = [] for matcher, functions in self.plugin_manager.webhook_listeners.items(): - match = matcher.match(event.webhook_id) + match = matcher.search(event.webhook_id) if match: for function in functions: # Create an asyncio task to handle this callback From 92f9381d8f18145a4bc13cf62877404c46c720d3 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 17:03:02 +0200 Subject: [PATCH 23/28] Remove unused snapshot test result --- .../unit_tests/snapshots/snap_plugins_test.py | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 tests/unit_tests/snapshots/snap_plugins_test.py diff --git a/tests/unit_tests/snapshots/snap_plugins_test.py b/tests/unit_tests/snapshots/snap_plugins_test.py deleted file mode 100644 index 39c75e5c..00000000 --- a/tests/unit_tests/snapshots/snap_plugins_test.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots['TestPlugin.test_help_string 1'] = '''Plugin FakePlugin has the following functions: ----- -- `^click_command (.*)?`: - Usage: click_command [OPTIONS] - - Help string for the entire function. - - Options: - --option INTEGER Help string for the optional argument. - --help Show this message and exit. -- `direct_pattern`: - No description provided. - Additional information: - - Needs to be a direct message. - - Restricted to certain users. -- `^!help$`: - Prints the list of functions registered on every active plugin. -- `^help$`: - Prints the list of functions registered on every active plugin. - Additional information: - - Needs to either mention @ or be a direct message. -- `async_pattern`: - Async function docstring. -- `another_async_pattern`: - Async function docstring. - Additional information: - - Needs to be a direct message. -- `pattern`: - This is the docstring of my_function. ----- -### Registered webhooks: -- `webhook_id`: - A webhook function. -''' From 61dc19d948380eaff474ce5078812a8c8dbceb52 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Fri, 19 Aug 2022 17:11:43 +0200 Subject: [PATCH 24/28] Fix test initializing drivers multiple times --- tests/unit_tests/function_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/function_test.py b/tests/unit_tests/function_test.py index 0b35e0a9..4f621b20 100644 --- a/tests/unit_tests/function_test.py +++ b/tests/unit_tests/function_test.py @@ -205,7 +205,7 @@ def fake_reply(message, text): f = listen_to("", allowed_channels=["off-topic"])(wrapped) f.plugin = ExamplePlugin() - f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings()) + f.plugin.initialize(driver, PluginManager([f.plugin]), Settings()) # This is fine, the names are not caps sensitive f(create_message(channel_name="off-topic")) @@ -232,7 +232,7 @@ def fake_reply(message, text): wrapped ) f.plugin = ExamplePlugin() - f.plugin.initialize(Driver(), PluginManager([f.plugin]), Settings()) + f.plugin.initialize(driver, PluginManager([f.plugin]), Settings()) # This is fine, the names are not caps sensitive f(create_message(channel_name="off-topic")) From f0a703983f8d881823b7ac54fedfb07356d5fa2e Mon Sep 17 00:00:00 2001 From: Alex Tzonkov <4975715+attzonko@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:18:43 -0700 Subject: [PATCH 25/28] Fixing problems introduced by conflict resolution merge --- mmpy_bot/function.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py index dea3e68b..275fb42a 100644 --- a/mmpy_bot/function.py +++ b/mmpy_bot/function.py @@ -5,7 +5,7 @@ import logging import re from abc import ABC, abstractmethod -from typing import Callable, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Optional, Sequence, Union import click @@ -23,7 +23,7 @@ class Function(ABC): def __init__( self, - function: Union[Function, click.command], + function: Union[Function, click.Command], matcher: re.Pattern, **metadata, ): @@ -36,7 +36,9 @@ def __init__( self.siblings.append(function) function = function.function - # FIXME: After this while loop it is possible that function is not a Function, do we really want to assign self.function to something which is not a Function? Check if this is needed for the click.Command case + if function is None: + raise ValueRrror("ERROR: Possible bug, inside the Function class function should not end up being None") + self.function = function self.is_coroutine = asyncio.iscoroutinefunction(function) self.is_click_function: bool = False @@ -92,7 +94,6 @@ def __init__( # Default for non-click functions _function: Union[Callable, click.Command] = self.function - self.docstring = self.function.__doc__ if self.is_click_function: _function = self.function.callback @@ -106,11 +107,8 @@ def __init__( info_name=self.matcher.pattern.strip("^").split(" (.*)?")[0], ) as ctx: # Get click help string and do some extra formatting + self.docstring += f"\n\n{self.function.get_help(ctx)}" - self.docstring = self.function.get_help(ctx).replace( - "\n", f"\n{spaces(8)}" - ) - if _function is not None: self.name = _function.__qualname__ From bf55db72e032b0b3e7bb9e6a4c8f096dbe6d1f6f Mon Sep 17 00:00:00 2001 From: Alex Tzonkov <4975715+attzonko@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:20:15 -0700 Subject: [PATCH 26/28] Fixing typo --- mmpy_bot/function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py index 275fb42a..a61f2699 100644 --- a/mmpy_bot/function.py +++ b/mmpy_bot/function.py @@ -37,7 +37,7 @@ def __init__( function = function.function if function is None: - raise ValueRrror("ERROR: Possible bug, inside the Function class function should not end up being None") + raise ValueError("ERROR: Possible bug, inside the Function class function should not end up being None") self.function = function self.is_coroutine = asyncio.iscoroutinefunction(function) From 2265b9a1d2469dca5b1af9065a8982f1f8ba1831 Mon Sep 17 00:00:00 2001 From: Alex Tzonkov <4975715+attzonko@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:32:20 -0700 Subject: [PATCH 27/28] Fixing black --- mmpy_bot/function.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mmpy_bot/function.py b/mmpy_bot/function.py index a61f2699..52bbc1b7 100644 --- a/mmpy_bot/function.py +++ b/mmpy_bot/function.py @@ -37,7 +37,9 @@ def __init__( function = function.function if function is None: - raise ValueError("ERROR: Possible bug, inside the Function class function should not end up being None") + raise ValueError( + "ERROR: Possible bug, inside the Function class function should not end up being None" + ) self.function = function self.is_coroutine = asyncio.iscoroutinefunction(function) From 6c0b55cf8899bc0d4efbaf367868bf3d2d1f0a95 Mon Sep 17 00:00:00 2001 From: Alex Tzonkov <4975715+attzonko@users.noreply.github.com> Date: Sun, 21 Aug 2022 14:34:02 -0700 Subject: [PATCH 28/28] Up the version of python to 3.10 for black --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 841659f5..3352e702 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,7 +35,7 @@ jobs: - name: Run Flake8 run: flake8 - name: Black code style - run: black . --check --target-version py38 --exclude '\.mypy_cache/|\.venv/|env/|(.*/)*snapshots/|.pytype/' + run: black . --check --target-version py310 --exclude '\.mypy_cache/|\.venv/|env/|(.*/)*snapshots/|.pytype/' - name: Docstring formatting run: docformatter -c -r . --wrap-summaries 88 --wrap-descriptions 88 - name: Check import order with isort