Verified Commit 612dc8ee authored by Agate's avatar Agate 💬

Plugins WIP

parent 8f261f96
"""
Ideal API:
# myplugin/apps.py
from funkwhale_api import plugins
class Plugin(plugins.Plugin):
name = 'scrobbler'
config_options = [
{
'id': 'user_agent',
'verbose_name': 'User agent string',
'help_text': 'The user agent string used by this plugin for external HTTP request',
'default': None,
},
{
'id': 'timeout',
'type': 'int',
'verbose_name': 'Timeout (in seconds)'
'help_text': 'Max timeout for HTTP calls',
'default': 10,
},
]
def get_user_options(self):
from . import options
return [
options.ListenBrainz,
options.LastFm,
]
# myplugin/hooks.py
from .apps import Plugin
@Plugin.register_action('history.listening_created')
def scrobble(plugin, user, listening, **kwargs):
user_options = plugin.get_options(user)
if len(options) == 0:
return
for option in user_options:
if option.id == 'listenbrainz':
broadcast_to_listenbrainz()
"""
from django.apps import AppConfig, apps
from . import mutations
from . import plugins
class CommonConfig(AppConfig):
......@@ -11,3 +64,12 @@ class CommonConfig(AppConfig):
app_names = [app.name for app in apps.app_configs.values()]
mutations.registry.autodiscover(app_names)
plugins.init(
plugins.registry,
[
app
for app in apps.app_configs.values()
if getattr(app, "_is_funkwhale_plugin", False) is True
],
)
import logging
import persisting_theory
from django.apps import AppConfig
logger = logging.getLogger("funkwhale.plugins")
class PluginsRegistry(persisting_theory.Registry):
look_into = "hooks"
def prepare_name(self, data, name=None):
return data.name
def prepare_data(self, data):
data.plugins_registry = self
return data
def dispatch_action(self, action_name, **kwargs):
logger.debug("Dispatching plugin action %s", action_name)
for plugin in self.values():
try:
handler = plugin.hooked_actions[action_name]
except KeyError:
continue
logger.debug("Hook found for plugin %s", plugin.name)
try:
handler(plugin=plugin, **kwargs)
except Exception:
logger.exception(
"Hook for action %s from plugin %s failed. The plugin may be misconfigured.",
action_name,
plugin.name,
)
else:
logger.info(
"Hook for action %s from plugin %s successful",
action_name,
plugin.name,
)
registry = PluginsRegistry()
class Plugin(AppConfig):
_is_funkwhale_plugin = True
is_initialized = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.config = {}
self.hooked_actions = {}
def get_config(self, config):
"""
Called with config options extracted from env vars, if any specified
Returns a transformed dict
"""
return config
def set_config(self, config):
"""
Simply persist the given config on the plugin
"""
self.config = config
def initialize(self):
pass
def register_action(self, action_name, func):
logger.debug(
"Registered hook for action %s via plugin %s", action_name, self.name
)
self.hooked_actions[action_name] = func
def init(registry, plugins):
logger.debug("Initializing plugins...")
for plugin in plugins:
logger.info("Initializing plugin %s", plugin.name)
try:
config = plugin.get_config({})
except Exception:
logger.exception(
"Error while getting configuration, plugin %s disabled", plugin.name
)
continue
try:
plugin.set_config(config)
except Exception:
logger.exception(
"Error while setting configuration, plugin %s disabled", plugin.name
)
continue
try:
plugin.initialize()
except Exception:
logger.exception(
"Error while initializing, plugin %s disabled", plugin.name
)
continue
plugin.is_initialized = True
# initialization complete, now we can log the "hooks.py" file in each
# plugin directory
registry.autodiscover([p.name for p in plugins if p.is_initialized])
from funkwhale_api.common import plugins
# setup code to populate plugins registry
# plugin-to-user -> enable and configure
# plugin preferences
def test_plugin_register(plugins_registry):
class TestPlugin(plugins.Plugin):
name = "scrobbler"
verbose_name = "Audio Scrobbler"
inst = TestPlugin(app_name="scrobbler", app_module="")
plugins_registry.register(inst)
assert inst.plugins_registry == plugins_registry
assert inst.is_initialized is False
assert plugins_registry["scrobbler"] == inst
assert inst.config == {}
def test_plugin_get_config(plugins_registry):
class TestPlugin(plugins.Plugin):
name = "scrobbler"
verbose_name = "Audio Scrobbler"
plugin = TestPlugin(app_name="", app_module="")
assert plugin.get_config({"hello": "world"}) == {"hello": "world"}
def test_plugin_set_config(plugins_registry):
class TestPlugin(plugins.Plugin):
name = "scrobbler"
verbose_name = "Audio Scrobbler"
plugin = TestPlugin(app_name="", app_module="")
plugin.set_config({"hello": "world"})
assert plugin.config == {"hello": "world"}
def test_plugin_initialize(plugins_registry):
class TestPlugin(plugins.Plugin):
name = "scrobbler"
verbose_name = "Audio Scrobbler"
plugin = TestPlugin(app_name="", app_module="")
assert plugin.initialize() is None
def test_action(mocker, plugins_registry):
class TestPlugin(plugins.Plugin):
name = "scrobbler"
verbose_name = "Audio Scrobbler"
inst = TestPlugin(app_name="scrobbler", app_module="")
plugins_registry.register(inst)
stub = mocker.stub()
# nothing hooked, so stub is not called
plugins_registry.dispatch_action("hello", user="test", arg1="value1", arg2="value2")
stub.assert_not_called()
# now we hook the stub on the action
inst.register_action("hello", stub)
assert inst.hooked_actions == {"hello": stub}
plugins_registry.dispatch_action("hello", user="test", arg1="value1", arg2="value2")
stub.assert_called_once_with(plugin=inst, user="test", arg1="value1", arg2="value2")
def test_plugins_init(plugins_registry, mocker):
class TestPlugin1(plugins.Plugin):
name = "scrobbler"
verbose_name = "Audio Scrobbler"
class TestPlugin2(plugins.Plugin):
name = "webhooks"
verbose_name = "Webhooks"
plugin1 = TestPlugin1(app_name="scrobbler", app_module="")
plugin2 = TestPlugin2(app_name="webhooks", app_module="")
mocks = {}
for plugin in [plugin1, plugin2]:
d = {
"get_config": mocker.patch.object(plugin, "get_config"),
"set_config": mocker.patch.object(plugin, "set_config"),
"initialize": mocker.patch.object(plugin, "initialize"),
}
mocks[plugin.name] = d
autodiscover = mocker.patch.object(plugins_registry, "autodiscover")
plugins.init(plugins_registry, [plugin1, plugin2])
autodiscover.assert_called_once_with([plugin1.name, plugin2.name])
for mock_conf in mocks.values():
mock_conf["get_config"].assert_called_once_with({})
mock_conf["set_config"].assert_called_once_with(
mock_conf["get_config"].return_value
)
mock_conf["initialize"].assert_called_once_with()
assert plugin1.is_initialized is True
assert plugin2.is_initialized is True
......@@ -28,6 +28,7 @@ from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory
from funkwhale_api.activity import record
from funkwhale_api.common import plugins
from funkwhale_api.federation import actors
from funkwhale_api.moderation import mrf
......@@ -437,3 +438,12 @@ def mrf_outbox_registry(mocker):
registry = mrf.Registry()
mocker.patch("funkwhale_api.moderation.mrf.outbox", registry)
return registry
@pytest.fixture
def plugins_registry(mocker):
mocker.patch.dict(plugins.registry, {})
mocker.patch.object(
plugins.Plugin, "_path_from_module", return_value="/tmp/dummypath"
)
return plugins.registry
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment