Moar plugins polishing and sugar

This commit is contained in:
Agate 2020-06-17 21:49:45 +02:00
parent 4c4ab5919a
commit 8c587d07c4
No known key found for this signature in database
GPG Key ID: 6B501DFD73514E14
16 changed files with 242 additions and 18 deletions

View File

@ -14,9 +14,25 @@ class ConfigError(ValueError):
class Plugin(AppConfig): class Plugin(AppConfig):
conf = {} conf = {}
path = "noop" path = "noop"
conf_serializer = None
def get_conf(self): def get_conf(self):
return {"enabled": self.plugin_settings.enabled} return self.instance.conf
def set_conf(self, data):
if self.conf_serializer:
s = self.conf_serializer(data=data)
s.is_valid(raise_exception=True)
data = s.validated_data
instance = self.instance()
instance.conf = data
instance.save(update_fields=["conf"])
def instance(self):
"""Return the DB object that match the plugin"""
from funkwhale_api.common import models
return models.PodPlugin.objects.get_or_create(code=self.name)[0]
def plugin_settings(self): def plugin_settings(self):
""" """
@ -94,7 +110,13 @@ plugins_manager.add_hookspecs(HookSpec())
def register(plugin_class): def register(plugin_class):
return plugins_manager.register(plugin_class("noop", "noop")) return plugins_manager.register(plugin_class(plugin_class.name, "noop"))
def save(plugin_class):
from funkwhale_api.common.models import PodPlugin
return PodPlugin.objects.get_or_create(code=plugin_class.name)[0]
def trigger_hook(name, *args, **kwargs): def trigger_hook(name, *args, **kwargs):
@ -104,6 +126,13 @@ def trigger_hook(name, *args, **kwargs):
@register @register
class DefaultPlugin(Plugin): class DefaultPlugin(Plugin):
name = "default"
verbose_name = "Default plugin"
@plugin_hook @plugin_hook
def database_engine(self): def database_engine(self):
return "django.db.backends.postgresql" return "django.db.backends.postgresql"
@plugin_hook
def urls(self):
return []

View File

@ -24,7 +24,11 @@ class Plugins(persisting_theory.Registry):
look_into = "entrypoint" look_into = "entrypoint"
PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p] PLUGINS = [
"funkwhale_plugin_{}".format(p)
for p in env.list("FUNKWHALE_PLUGINS", default=[])
if p
]
""" """
List of Funkwhale plugins to load. List of Funkwhale plugins to load.
""" """
@ -33,8 +37,6 @@ from config import plugins # noqa
plugins_registry = Plugins() plugins_registry = Plugins()
plugins_registry.autodiscover(PLUGINS) plugins_registry.autodiscover(PLUGINS)
# plugins.plugins_manager.register(Plugin("noop", "noop"))
LOGLEVEL = env("LOGLEVEL", default="info").upper() LOGLEVEL = env("LOGLEVEL", default="info").upper()
""" """
Default logging level for the Funkwhale processes""" # pylint: disable=W0105 Default logging level for the Funkwhale processes""" # pylint: disable=W0105
@ -272,7 +274,7 @@ List of Django apps to load in addition to Funkwhale plugins and apps.
PLUGINS_APPS = tuple() PLUGINS_APPS = tuple()
for p in plugins.trigger_hook("register_apps"): for p in plugins.trigger_hook("register_apps"):
PLUGINS_APPS += (p,) PLUGINS_APPS += tuple(p)
INSTALLED_APPS = ( INSTALLED_APPS = (
DJANGO_APPS DJANGO_APPS

View File

@ -1,5 +1,8 @@
import os import os
import shutil
import subprocess import subprocess
import sys
import tempfile
import click import click
@ -8,12 +11,27 @@ from django.conf import settings
from . import base from . import base
PIP = os.path.join(sys.prefix, "bin", "pip")
@base.cli.group() @base.cli.group()
def plugins(): def plugins():
"""Install, configure and remove plugins""" """Install, configure and remove plugins"""
pass pass
def get_all_plugins():
plugins = [
f.path
for f in os.scandir(settings.FUNKWHALE_PLUGINS_PATH)
if "/funkwhale_plugin_" in f.path
]
plugins = [
p.split("-")[0].split("/")[-1].replace("funkwhale_plugin_", "") for p in plugins
]
return plugins
@plugins.command("install") @plugins.command("install")
@click.argument("name_or_url", nargs=-1) @click.argument("name_or_url", nargs=-1)
@click.option("--builtins", is_flag=True) @click.option("--builtins", is_flag=True)
@ -21,17 +39,56 @@ def plugins():
def install(name_or_url, builtins, pip_args): def install(name_or_url, builtins, pip_args):
""" """
Installed the specified plug using their name. Installed the specified plug using their name.
If --builtins is provided, it will also install
plugins present at FUNKWHALE_PLUGINS_PATH
""" """
pip_args = pip_args or "" pip_args = pip_args or ""
target_path = settings.FUNKWHALE_PLUGINS_PATH all_plugins = []
builtins_path = os.path.join(settings.APPS_DIR, "plugins") for p in name_or_url:
builtins_plugins = [f.path for f in os.scandir(builtins_path) if f.is_dir()] builtin_path = os.path.join(
command = "pip install {} --target={} {}".format( settings.APPS_DIR, "plugins", "funkwhale_plugin_{}".format(p)
pip_args, target_path, " ".join(builtins_plugins) )
if os.path.exists(builtin_path):
all_plugins.append(builtin_path)
else:
all_plugins.append(p)
install_plugins(pip_args, all_plugins)
click.echo(
"Installation completed, ensure FUNKWHALE_PLUGINS={} is present in your .env file".format(
",".join(get_all_plugins())
)
) )
def install_plugins(pip_args, all_plugins):
with tempfile.TemporaryDirectory() as tmpdirname:
command = "{} install {} --target {} --build={} {}".format(
PIP,
pip_args,
settings.FUNKWHALE_PLUGINS_PATH,
tmpdirname,
" ".join(all_plugins),
)
subprocess.run(
command, shell=True, check=True,
)
@plugins.command("uninstall")
@click.argument("name", nargs=-1)
def uninstall(name):
"""
Remove plugins
"""
to_remove = ["funkwhale_plugin_{}".format(n) for n in name]
command = "{} uninstall -y {}".format(PIP, " ".join(to_remove))
subprocess.run( subprocess.run(
command, shell=True, check=True, command, shell=True, check=True,
) )
for f in os.scandir(settings.FUNKWHALE_PLUGINS_PATH):
for n in name:
if "/funkwhale_plugin_{}".format(n) in f.path:
shutil.rmtree(f.path)
click.echo(
"Removal completed, set FUNKWHALE_PLUGINS={} in your .env file".format(
",".join(get_all_plugins())
)
)

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.6 on 2020-06-17 19:02
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('common', '0007_auto_20200116_1610'),
]
operations = [
migrations.CreateModel(
name='PodPlugin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('conf', django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True, blank=True)),
('code', models.CharField(max_length=100, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.AlterField(
model_name='attachment',
name='url',
field=models.URLField(blank=True, max_length=500, null=True),
),
]

View File

@ -18,6 +18,7 @@ from django.urls import reverse
from versatileimagefield.fields import VersatileImageField from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from config import plugins
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from . import utils from . import utils
@ -363,3 +364,17 @@ def remove_attached_content(sender, instance, **kwargs):
getattr(instance, field).delete() getattr(instance, field).delete()
except Content.DoesNotExist: except Content.DoesNotExist:
pass pass
class PodPlugin(models.Model):
conf = JSONField(default=None, null=True, blank=True)
code = models.CharField(max_length=100, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
@property
def plugin(self):
"""Links to the Plugin instance in entryposint.py"""
candidates = plugins.plugins_manager.get_plugins()
for p in candidates:
if p.name == self.code:
return p

View File

@ -339,3 +339,21 @@ class NullToEmptDict(object):
if not v: if not v:
return v return v
return super().to_representation(v) return super().to_representation(v)
class PodPluginSerializer(serializers.Serializer):
code = serializers.CharField(read_only=True)
enabled = serializers.BooleanField()
conf = serializers.JSONField()
label = serializers.SerializerMethodField()
class Meta:
fields = [
"code",
"label",
"enabled",
"conf",
]
def get_label(self, o):
return o.plugin.verbose_name

View File

@ -5,7 +5,8 @@ from config import plugins
@plugins.register @plugins.register
class Plugin(plugins.Plugin): class Plugin(plugins.Plugin):
name = "prometheus_exporter" name = "funkwhale_plugin_prometheus"
verbose_name = "Prometheus metrics exporter"
@plugins.plugin_hook @plugins.plugin_hook
def database_engine(self): def database_engine(self):
@ -13,7 +14,7 @@ class Plugin(plugins.Plugin):
@plugins.plugin_hook @plugins.plugin_hook
def register_apps(self): def register_apps(self):
return "django_prometheus" return ["django_prometheus"]
@plugins.plugin_hook @plugins.plugin_hook
def middlewares_before(self): def middlewares_before(self):

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
name = funkwhale-prometheus name = funkwhale-plugin-prometheus
description = "A prometheus metric exporter for your Funkwhale pod" description = "A prometheus metrics exporter for your Funkwhale pod"
version = 0.1.dev0 version = 0.1.dev0
author = Agate Blue author = Agate Blue
author_email = me@agate.blue author_email = me@agate.blue

View File

@ -0,0 +1,34 @@
import pytest
from rest_framework import serializers
from config import plugins
from funkwhale_api.common import models
def test_plugin_validate_set_conf():
class S(serializers.Serializer):
test = serializers.CharField()
foo = serializers.BooleanField()
class P(plugins.Plugin):
conf_serializer = S
p = P("noop", "noop")
with pytest.raises(serializers.ValidationError):
assert p.set_conf({"test": "hello", "foo": "bar"})
def test_plugin_validate_set_conf_persists():
class S(serializers.Serializer):
test = serializers.CharField()
foo = serializers.BooleanField()
class P(plugins.Plugin):
name = "test_plugin"
conf_serializer = S
p = P("noop", "noop")
p.set_conf({"test": "hello", "foo": False})
assert p.instance() == models.PodPlugin.objects.latest("id")
assert p.instance().conf == {"test": "hello", "foo": False}

View File

@ -6,6 +6,7 @@ from django.urls import reverse
import django_filters import django_filters
from config import plugins
from funkwhale_api.common import serializers from funkwhale_api.common import serializers
from funkwhale_api.common import utils from funkwhale_api.common import utils
from funkwhale_api.users import models from funkwhale_api.users import models
@ -267,3 +268,21 @@ def test_content_serializer(factories):
serializer = serializers.ContentSerializer(content) serializer = serializers.ContentSerializer(content)
assert serializer.data == expected assert serializer.data == expected
def test_plugin_serializer():
class TestPlugin(plugins.Plugin):
name = "test_plugin"
verbose_name = "A test plugin"
plugins.register(TestPlugin)
instance = plugins.save(TestPlugin)
assert isinstance(instance.plugin, TestPlugin)
expected = {
"code": "test_plugin",
"label": "A test plugin",
"enabled": True,
"conf": None,
}
assert serializers.PodPluginSerializer(instance).data == expected

View File

@ -24,6 +24,7 @@ from aioresponses import aioresponses
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
@ -429,3 +430,13 @@ def clear_license_cache(db):
@pytest.fixture @pytest.fixture
def faker(): def faker():
return factory.Faker._get_faker() return factory.Faker._get_faker()
@pytest.fixture
def plugins_manager():
return plugins.PluginManager("tests")
@pytest.fixture
def hook(plugins_manager):
return plugins.HookimplMarker("tests")

View File

@ -13,5 +13,6 @@ Reference
architecture architecture
../api ../api
./authentication ./authentication
./plugins
../federation/index ../federation/index
subsonic subsonic

View File

@ -0,0 +1,8 @@
Funkwhale Plugins
=================
With version 1.0, Funkwhale makes it possible for third party to write plugins
and distribute them.
Funkwhale plugins are regular django apps, that can register models, API
endpoints, and react to specific events (e.g a son was listened, a federation message was delivered, etc.)