Browse Source

Add plugin loading to Misago (#1306)

* Add plugin system to Misago

* Small tweaks

* Support plugins in custom path (for plugin developers)
Rafał Pitoń 5 years ago
parent
commit
7a77532e85

+ 5 - 1
Dockerfile

@@ -21,7 +21,11 @@ RUN apt-get update && apt-get install -y \
 
 
 # Add requirements and install them. We do this unnecessasy rebuilding.
 # Add requirements and install them. We do this unnecessasy rebuilding.
 ADD requirements.txt /
 ADD requirements.txt /
-RUN pip install --upgrade pip && pip install -r requirements.txt
+ADD requirements-plugins.txt /
+
+RUN pip install --upgrade pip && \
+    pip install -r requirements.txt && \
+    pip install -r requirements-plugins.txt &&
 
 
 WORKDIR /srv/misago
 WORKDIR /srv/misago
 
 

+ 8 - 1
devproject/settings.py

@@ -13,6 +13,8 @@ https://docs.djangoproject.com/en/1.11/ref/settings/
 
 
 import os
 import os
 
 
+from misago import load_plugin_list_if_exists
+
 
 
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -155,7 +157,11 @@ AUTHENTICATION_BACKENDS = ["misago.users.authbackends.MisagoBackend"]
 
 
 CSRF_FAILURE_VIEW = "misago.core.errorpages.csrf_failure"
 CSRF_FAILURE_VIEW = "misago.core.errorpages.csrf_failure"
 
 
-INSTALLED_APPS = [
+PLUGINS_LIST_PATH = os.path.join(os.path.dirname(BASE_DIR), "plugins.txt")
+
+INSTALLED_PLUGINS = load_plugin_list_if_exists(PLUGINS_LIST_PATH) or []
+
+INSTALLED_APPS = INSTALLED_PLUGINS + [
     # Misago overrides for Django core feature
     # Misago overrides for Django core feature
     "misago",
     "misago",
     "misago.users",
     "misago.users",
@@ -195,6 +201,7 @@ INSTALLED_APPS = [
     "misago.faker",
     "misago.faker",
     "misago.menus",
     "misago.menus",
     "misago.sso",
     "misago.sso",
+    "misago.plugins",
 ]
 ]
 
 
 INTERNAL_IPS = ["127.0.0.1"]
 INTERNAL_IPS = ["127.0.0.1"]

+ 3 - 0
misago/__init__.py

@@ -1,2 +1,5 @@
+from .plugins.pluginlist import load_plugin_list_if_exists
+
+
 __version__ = "0.23"
 __version__ = "0.23"
 __released__ = True
 __released__ = True

+ 0 - 0
misago/plugins/__init__.py


+ 62 - 0
misago/plugins/pluginlist.py

@@ -0,0 +1,62 @@
+import os
+import sys
+from typing import Dict, List, Optional, Tuple
+
+
+def load_plugin_list_if_exists(path: str) -> Optional[List[str]]:
+    if not os.path.exists(path):
+        return None
+
+    plugins = []
+    for plugin in load_plugin_list(path):
+        if "@" in plugin:
+            plugin, path = plugin.split("@", 1)
+            sys.path.append(path)
+        plugins.append(plugin)
+
+    return plugins
+
+
+def load_plugin_list(path: str) -> List[str]:
+    with open(path, "r") as f:
+        data = f.read()
+        return parse_plugins_list(data)
+
+
+def parse_plugins_list(data: str) -> List[str]:
+    plugins: List[str] = []
+    modules: Dict[str, Tuple[int, str]] = {}
+
+    for line, entry in enumerate(data.splitlines()):
+        plugin = entry.strip()
+
+        if "#" in plugin:
+            comment_start = plugin.find("#")
+            plugin = plugin[:comment_start].strip()
+        if not plugin:
+            continue
+
+        if "@" in plugin:
+            module, path = map(lambda x: x.strip(), plugin.split("@", 1))
+            plugin = f"{module}@{path}"
+            validate_local_plugin(line, module, path)
+        else:
+            module = plugin
+        if module in modules:
+            first_line, first_entry = modules[module]
+            raise ValueError(
+                f"plugin '{module}' is listed more than once: "
+                f"at line {first_line} ('{first_entry}') and at {line} ('{entry}')"
+            )
+        modules[module] = line, entry
+
+        plugins.append(plugin)
+
+    return plugins
+
+
+def validate_local_plugin(line: int, module: str, path: str):
+    if not module:
+        raise ValueError(f"local plugin entry at line {line} is missing a module name")
+    if not path:
+        raise ValueError(f"local plugin entry at line {line} is missing a path")

+ 0 - 0
misago/plugins/tests/__init__.py


+ 45 - 0
misago/plugins/tests/test_parsing_plugin_list.py

@@ -0,0 +1,45 @@
+from ..pluginlist import parse_plugins_list
+
+
+def test_empty_plugins_str_is_parsed_to_empty_list():
+    assert parse_plugins_list("") == []
+
+
+def test_comment_str_is_parsed_to_empty_list():
+    assert parse_plugins_list("# comment") == []
+
+
+def test_line_containing_plugin_name_is_parsed():
+    assert parse_plugins_list("plugin") == ["plugin"]
+
+
+def test_line_containing_local_plugin_name_is_parsed():
+    assert parse_plugins_list("plugin@/local/") == ["plugin@/local/"]
+
+
+def test_whitespace_is_stripped_from_local_plugin_name():
+    assert parse_plugins_list("plugin @/local/") == ["plugin@/local/"]
+
+
+def test_whitespace_is_stripped_from_local_plugin_path():
+    assert parse_plugins_list("plugin@ /local/") == ["plugin@/local/"]
+
+
+def test_comment_is_removed_from_line_containing_plugin_name():
+    assert parse_plugins_list("plugin # comment") == ["plugin"]
+
+
+def test_multiple_lines_containing_plugin_names_are_parsed():
+    assert parse_plugins_list("plugin1\nplugin2") == ["plugin1", "plugin2"]
+
+
+def test_empty_lines_are_skipped_by_parser():
+    assert parse_plugins_list("plugin1\n\nplugin2") == ["plugin1", "plugin2"]
+
+
+def test_comments_are_filtered_from_plugin_list():
+    assert parse_plugins_list("plugin1\n# comment\nplugin2") == ["plugin1", "plugin2"]
+
+
+def test_whitespace_is_stripped_from_line_start_and_end_by_parser():
+    assert parse_plugins_list("plugin1\n  plugin2") == ["plugin1", "plugin2"]

+ 28 - 0
misago/plugins/tests/test_plugin_list_validation.py

@@ -0,0 +1,28 @@
+import pytest
+
+from ..pluginlist import parse_plugins_list
+
+
+def test_parser_raises_value_error_if_local_plugin_is_missing_path():
+    with pytest.raises(ValueError):
+        parse_plugins_list("plugin@")
+
+
+def test_parser_raises_value_error_if_local_plugin_is_missing_module_name():
+    with pytest.raises(ValueError):
+        parse_plugins_list("@/local/")
+
+
+def test_parser_raises_value_error_if_plugin_is_repeated():
+    with pytest.raises(ValueError):
+        parse_plugins_list("plugin\nplugin")
+
+
+def test_parser_raises_value_error_if_local_plugin_is_repeated():
+    with pytest.raises(ValueError):
+        parse_plugins_list("plugin@/local/\n@plugin/other/local/")
+
+
+def test_parser_raises_value_error_if_local_plugin_module_conflicts_with_other_plugin():
+    with pytest.raises(ValueError):
+        parse_plugins_list("plugin\nplugin@/local/")

+ 10 - 0
plugins.txt

@@ -0,0 +1,10 @@
+# List of enabled plugins
+# To enable plugin, simply enter its module name here:
+#
+# plugin_module
+#
+# If plugin is outside of Python path, you can follow its name with a path:
+#
+# plugin_module @ /app/my/custom/plugin
+#
+# To enable multiple plugins, list their names in separate lines!

+ 2 - 0
requirements-plugins.in

@@ -0,0 +1,2 @@
+# File purposefully left empty
+# Feel free to add plugins here

+ 6 - 0
requirements-plugins.txt

@@ -0,0 +1,6 @@
+#
+# This file is autogenerated by pip-compile
+# To update, run:
+#
+#    pip-compile --output-file=requirements-plugins.txt requirements-plugins.in
+#