Plugins WIP
This commit is contained in:
parent
8f261f96e9
commit
612dc8eeec
|
@ -1,6 +1,59 @@
|
|||
"""
|
||||
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
|
||||
],
|
||||
)
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
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])
|
|
@ -0,0 +1,106 @@
|
|||
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
|
||||
|
|
Loading…
Reference in New Issue