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 django.apps import AppConfig, apps
|
||||||
|
|
||||||
from . import mutations
|
from . import mutations
|
||||||
|
from . import plugins
|
||||||
|
|
||||||
|
|
||||||
class CommonConfig(AppConfig):
|
class CommonConfig(AppConfig):
|
||||||
|
@ -11,3 +64,12 @@ class CommonConfig(AppConfig):
|
||||||
|
|
||||||
app_names = [app.name for app in apps.app_configs.values()]
|
app_names = [app.name for app in apps.app_configs.values()]
|
||||||
mutations.registry.autodiscover(app_names)
|
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 rest_framework.test import APIClient, APIRequestFactory
|
||||||
|
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
|
from funkwhale_api.common import plugins
|
||||||
from funkwhale_api.federation import actors
|
from funkwhale_api.federation import actors
|
||||||
from funkwhale_api.moderation import mrf
|
from funkwhale_api.moderation import mrf
|
||||||
|
|
||||||
|
@ -437,3 +438,12 @@ def mrf_outbox_registry(mocker):
|
||||||
registry = mrf.Registry()
|
registry = mrf.Registry()
|
||||||
mocker.patch("funkwhale_api.moderation.mrf.outbox", registry)
|
mocker.patch("funkwhale_api.moderation.mrf.outbox", registry)
|
||||||
return 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