Skip to content
Snippets Groups Projects
Verified Commit 612dc8ee authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Plugins WIP

parent 8f261f96
No related branches found
No related tags found
No related merge requests found
"""
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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment