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