Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Implement PluginManager and HelpPlugin #326

Merged
merged 29 commits into from
Aug 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6dff453
Implement PluginManager interface
unode Jul 15, 2022
1794e42
Simplify Plugin code by use of PluginManager
unode Jul 15, 2022
3d36f2e
Use PluginManager in Bot class
unode Jul 15, 2022
ee4a18d
Implement PluginManager interface in EventHandler
unode Jul 15, 2022
67f9689
Fix type checking and propagation of docstrings through Function
unode Jul 15, 2022
39c0502
Use PluginManager in webhook example
unode Jul 15, 2022
6f8eb1f
Add PluginManager tests and fix existing ones
unode Jul 15, 2022
a0e9f43
Bump black to newer version
unode Aug 19, 2022
b413e1d
Ignore snapshots in flake8
unode Aug 19, 2022
26c2ed1
Refactor get_help_string
unode Aug 19, 2022
f14b713
Collect help from pluginmanager listeners
unode Aug 19, 2022
6ff1a4d
Fix type checking and propagation of docstrings through Function
unode Jul 15, 2022
adb843a
Implement help text formatting as a plugin
unode Jul 15, 2022
0c721db
Remove help functions superseeded by HelpPlugin
unode Jul 15, 2022
1f2190c
PluginManager and HelpPlugin should be included in __init__
unode Jul 15, 2022
0ba9885
Enable HelpPlugin by default
unode Jul 15, 2022
c9a7ca3
Add category metadata to example plugin for HelpPlugin formatting
unode Jul 15, 2022
4c48012
Fix tests missing PluginManager instance
unode Aug 19, 2022
c44e072
Refactor generate_plugin_help
unode Aug 19, 2022
226ad94
Refactor Bot PluginManager configuration
unode Aug 19, 2022
fa6ec20
Refactor HelpPlugin get_help_string
unode Aug 19, 2022
55ca455
Revert to re.search (#289) (#290)
unode Aug 19, 2022
92f9381
Remove unused snapshot test result
unode Aug 19, 2022
61dc19d
Fix test initializing drivers multiple times
unode Aug 19, 2022
222ba12
Merge branch 'main' into helpplugin
attzonko Aug 21, 2022
f0a7039
Fixing problems introduced by conflict resolution merge
attzonko Aug 21, 2022
bf55db7
Fixing typo
attzonko Aug 21, 2022
2265b9a
Fixing black
attzonko Aug 21, 2022
6c0b55c
Up the version of python to 3.10 for black
attzonko Aug 21, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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:
Expand Down
48 changes: 30 additions & 18 deletions mmpy_bot/bot.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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,
HelpPlugin,
Plugin,
PluginManager,
WebHookExample,
)
from mmpy_bot.settings import Settings
from mmpy_bot.webhook_server import WebHookServer

Expand All @@ -22,18 +28,18 @@ 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._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(
{
Expand All @@ -48,9 +54,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

Expand All @@ -59,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(
**{
Expand All @@ -80,11 +96,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
Expand All @@ -110,8 +121,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()
Expand All @@ -129,9 +140,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
21 changes: 5 additions & 16 deletions mmpy_bot/event_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,27 +18,18 @@ 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
the appropriate response function to each event."""
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)
Expand Down Expand Up @@ -87,7 +76,7 @@ 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():
for matcher, functions in self.plugin_manager.message_listeners.items():
match = matcher.search(message.text)
if match:
groups = list([group for group in match.groups() if group != ""])
Expand All @@ -107,7 +96,7 @@ 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():
for matcher, functions in self.plugin_manager.webhook_listeners.items():
match = matcher.search(event.webhook_id)
if match:
for function in functions:
Expand Down
78 changes: 26 additions & 52 deletions mmpy_bot/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@
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

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

if TYPE_CHECKING:
from mmpy_bot.plugins import Plugin


log = logging.getLogger("mmpy.function")


class Function(ABC):
def __init__(
self,
function: Union[Function, click.command],
function: Union[Function, click.Command],
matcher: re.Pattern,
**metadata,
):
Expand All @@ -32,7 +36,11 @@ 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 ValueError(
"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
Expand All @@ -46,21 +54,14 @@ def __init__(
self.function.invoke = None

# 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):
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
Expand Down Expand Up @@ -95,7 +96,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
Expand All @@ -109,9 +109,8 @@ 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)}"

if _function is not None:
self.name = _function.__qualname__

Expand Down Expand Up @@ -169,38 +168,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,
Expand Down Expand Up @@ -238,7 +205,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,
Expand All @@ -249,6 +216,10 @@ def wrapped_func(func):
**metadata,
)

# Preserve docstring
new_func.__doc__ = func.__doc__
return new_func

return wrapped_func


Expand All @@ -267,7 +238,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"]:
Expand Down Expand Up @@ -305,10 +275,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
5 changes: 3 additions & 2 deletions mmpy_bot/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading