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