diff --git a/maubot/instance.py b/maubot/instance.py
index 494ac4b..3939c2a 100644
--- a/maubot/instance.py
+++ b/maubot/instance.py
@@ -135,7 +135,7 @@ class PluginInstance:
self.db_instance.enabled = False
return
self.started = True
- self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} "
+ self.log.info(f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} "
f"with user {self.client.id}")
async def stop(self) -> None:
@@ -199,12 +199,12 @@ class PluginInstance:
except KeyError:
return False
await self.stop()
- self.db_instance.type = loader.id
+ self.db_instance.type = loader.meta.id
self.loader.references.remove(self)
self.loader = loader
self.loader.references.add(self)
await self.start()
- self.log.debug(f"Type switched to {self.loader.id}")
+ self.log.debug(f"Type switched to {self.loader.meta.id}")
return True
async def update_started(self, started: bool) -> None:
diff --git a/maubot/loader/abc.py b/maubot/loader/abc.py
index 111e469..24bd622 100644
--- a/maubot/loader/abc.py
+++ b/maubot/loader/abc.py
@@ -13,10 +13,15 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING
+from typing import TypeVar, Type, Dict, Set, List, TYPE_CHECKING
from abc import ABC, abstractmethod
import asyncio
+from attr import dataclass
+from packaging.version import Version, InvalidVersion
+from mautrix.client.api.types.util import (SerializableAttrs, SerializerError, serializer,
+ deserializer)
+
from ..plugin_base import Plugin
if TYPE_CHECKING:
@@ -29,12 +34,36 @@ class IDConflictError(Exception):
pass
+@serializer(Version)
+def serialize_version(version: Version) -> str:
+ return str(version)
+
+
+@deserializer(Version)
+def deserialize_version(version: str) -> Version:
+ try:
+ return Version(version)
+ except InvalidVersion as e:
+ raise SerializerError("Invalid version") from e
+
+
+@dataclass
+class PluginMeta(SerializableAttrs['PluginMeta']):
+ id: str
+ version: Version
+ license: str
+ modules: List[str]
+ main_class: str
+ extra_files: List[str] = []
+ dependencies: List[str] = []
+ soft_dependencies: List[str] = []
+
+
class PluginLoader(ABC):
id_cache: Dict[str, 'PluginLoader'] = {}
+ meta: PluginMeta
references: Set['PluginInstance']
- id: str
- version: str
def __init__(self):
self.references = set()
@@ -45,8 +74,8 @@ class PluginLoader(ABC):
def to_dict(self) -> dict:
return {
- "id": self.id,
- "version": self.version,
+ "id": self.meta.id,
+ "version": str(self.meta.version),
"instances": [instance.to_dict() for instance in self.references],
}
diff --git a/maubot/loader/zip.py b/maubot/loader/zip.py
index 3f63f76..e341cff 100644
--- a/maubot/loader/zip.py
+++ b/maubot/loader/zip.py
@@ -16,15 +16,20 @@
from typing import Dict, List, Type, Tuple, Optional
from zipfile import ZipFile, BadZipFile
from time import time
-import configparser
import logging
import sys
import os
+from ruamel.yaml import YAML, YAMLError
+from packaging.version import Version
+from mautrix.client.api.types.util import SerializerError
+
from ..lib.zipimport import zipimporter, ZipImportError
from ..plugin_base import Plugin
from ..config import Config
-from .abc import PluginLoader, PluginClass, IDConflictError
+from .abc import PluginLoader, PluginClass, PluginMeta, IDConflictError
+
+yaml = YAML()
class MaubotZipImportError(Exception):
@@ -50,9 +55,7 @@ class ZippedPluginLoader(PluginLoader):
directories: List[str] = []
path: str
- id: str
- version: str
- modules: List[str]
+ meta: PluginMeta
main_class: str
main_module: str
_loaded: Type[PluginClass]
@@ -62,20 +65,21 @@ class ZippedPluginLoader(PluginLoader):
def __init__(self, path: str) -> None:
super().__init__()
self.path = path
- self.id = None
+ self.meta = None
self._loaded = None
self._importer = None
self._file = None
self._load_meta()
self._run_preload_checks(self._get_importer())
try:
- existing = self.id_cache[self.id]
- raise IDConflictError(f"Plugin with id {self.id} already loaded from {existing.source}")
+ existing = self.id_cache[self.meta.id]
+ raise IDConflictError(
+ f"Plugin with id {self.meta.id} already loaded from {existing.source}")
except KeyError:
pass
self.path_cache[self.path] = self
- self.id_cache[self.id] = self
- self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
+ self.id_cache[self.meta.id] = self
+ self.log.debug(f"Preloaded plugin {self.meta.id} from {self.path}")
def to_dict(self) -> dict:
return {
@@ -98,57 +102,48 @@ class ZippedPluginLoader(PluginLoader):
def __repr__(self) -> str:
return ("")
async def read_file(self, path: str) -> bytes:
return self._file.read(path)
@staticmethod
- def _open_meta(source) -> Tuple[ZipFile, configparser.ConfigParser]:
+ def _read_meta(source) -> Tuple[ZipFile, PluginMeta]:
try:
file = ZipFile(source)
- data = file.read("maubot.ini")
+ data = file.read("maubot.yaml")
except FileNotFoundError as e:
raise MaubotZipMetaError("Maubot plugin not found") from e
except BadZipFile as e:
raise MaubotZipMetaError("File is not a maubot plugin") from e
except KeyError as e:
raise MaubotZipMetaError("File does not contain a maubot plugin definition") from e
- config = configparser.ConfigParser()
try:
- config.read_string(data.decode("utf-8"))
- except (configparser.Error, KeyError, IndexError, ValueError) as e:
+ meta_dict = yaml.load(data)
+ except (YAMLError, KeyError, IndexError, ValueError) as e:
+ raise MaubotZipMetaError("Maubot plugin definition file is not valid YAML") from e
+ try:
+ meta = PluginMeta.deserialize(meta_dict)
+ except SerializerError as e:
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
- return file, config
+ return file, meta
@classmethod
- def _read_meta(cls, config: configparser.ConfigParser) -> Tuple[str, str, List[str], str, str]:
- try:
- meta = config["maubot"]
- meta_id = meta["ID"]
- version = meta["Version"]
- modules = [mod.strip() for mod in meta["Modules"].split(",")]
- main_class = meta["MainClass"]
- main_module = modules[-1]
- if "/" in main_class:
- main_module, main_class = main_class.split("/")[:2]
- except (configparser.Error, KeyError, IndexError, ValueError) as e:
- raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
- return meta_id, version, modules, main_class, main_module
-
- @classmethod
- def verify_meta(cls, source) -> Tuple[str, str]:
- _, config = cls._open_meta(source)
- meta = cls._read_meta(config)
- return meta[0], meta[1]
+ def verify_meta(cls, source) -> Tuple[str, Version]:
+ _, meta = cls._read_meta(source)
+ return meta.id, meta.version
def _load_meta(self) -> None:
- file, config = self._open_meta(self.path)
- meta = self._read_meta(config)
- if self.id and meta[0] != self.id:
+ file, meta = self._read_meta(self.path)
+ if self.meta and meta.id != self.meta.id:
raise MaubotZipMetaError("Maubot plugin ID changed during reload")
- self.id, self.version, self.modules, self.main_class, self.main_module = meta
+ self.meta = meta
+ if "/" in meta.main_class:
+ self.main_module, self.main_class = meta.main_class.split("/")[:2]
+ else:
+ self.main_module = meta.modules[0]
+ self.main_class = meta.main_class
self._file = file
def _get_importer(self, reset_cache: bool = False) -> zipimporter:
@@ -170,7 +165,7 @@ class ZippedPluginLoader(PluginLoader):
except ZipImportError as e:
raise MaubotZipPreLoadError(
f"Main module {self.main_module} not found in file") from e
- for module in self.modules:
+ for module in self.meta.modules:
try:
importer.find_module(module)
except ZipImportError as e:
@@ -180,7 +175,7 @@ class ZippedPluginLoader(PluginLoader):
try:
return self._load(reset_cache)
except MaubotZipImportError:
- self.log.exception(f"Failed to load {self.id} v{self.version}")
+ self.log.exception(f"Failed to load {self.meta.id} v{self.meta.version}")
raise
def _load(self, reset_cache: bool = False) -> Type[PluginClass]:
@@ -190,8 +185,8 @@ class ZippedPluginLoader(PluginLoader):
importer = self._get_importer(reset_cache=reset_cache)
self._run_preload_checks(importer)
if reset_cache:
- self.log.debug(f"Re-preloaded plugin {self.id} from {self.path}")
- for module in self.modules:
+ self.log.debug(f"Re-preloaded plugin {self.meta.id} from {self.meta.path}")
+ for module in self.meta.modules:
try:
importer.load_module(module)
except ZipImportError:
@@ -209,7 +204,7 @@ class ZippedPluginLoader(PluginLoader):
if not issubclass(plugin, Plugin):
raise MaubotZipLoadError("Main class of plugin does not extend maubot.Plugin")
self._loaded = plugin
- self.log.debug(f"Loaded and imported plugin {self.id} from {self.path}")
+ self.log.debug(f"Loaded and imported plugin {self.meta.id} from {self.path}")
return plugin
async def reload(self, new_path: Optional[str] = None) -> Type[PluginClass]:
@@ -223,7 +218,7 @@ class ZippedPluginLoader(PluginLoader):
if getattr(mod, "__file__", "").startswith(self.path):
del sys.modules[name]
self._loaded = None
- self.log.debug(f"Unloaded plugin {self.id} at {self.path}")
+ self.log.debug(f"Unloaded plugin {self.meta.id} at {self.path}")
async def delete(self) -> None:
await self.unload()
@@ -232,7 +227,7 @@ class ZippedPluginLoader(PluginLoader):
except KeyError:
pass
try:
- del self.id_cache[self.id]
+ del self.id_cache[self.meta.id]
except KeyError:
pass
if self._importer:
@@ -240,10 +235,8 @@ class ZippedPluginLoader(PluginLoader):
self._importer = None
self._loaded = None
self.trash(self.path, reason="delete")
- self.id = None
+ self.meta = None
self.path = None
- self.version = None
- self.modules = None
@classmethod
def trash(cls, file_path: str, new_name: Optional[str] = None, reason: str = "error") -> None:
diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py
index 3113124..421584d 100644
--- a/maubot/management/api/plugin.py
+++ b/maubot/management/api/plugin.py
@@ -13,7 +13,6 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from http import HTTPStatus
from io import BytesIO
from time import time
import traceback
@@ -21,6 +20,7 @@ import os.path
import re
from aiohttp import web
+from packaging.version import Version
from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
from .responses import resp
@@ -108,7 +108,7 @@ async def upload_plugin(request: web.Request) -> web.Response:
return resp.unsupported_plugin_loader
-async def upload_new_plugin(content: bytes, pid: str, version: str) -> web.Response:
+async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.Response:
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
with open(path, "wb") as p:
p.write(content)
@@ -120,8 +120,8 @@ async def upload_new_plugin(content: bytes, pid: str, version: str) -> web.Respo
return resp.created(plugin.to_dict())
-async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes, new_version: str
- ) -> web.Response:
+async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
+ new_version: Version) -> web.Response:
dirname = os.path.dirname(plugin.path)
old_filename = os.path.basename(plugin.path)
if plugin.version in old_filename:
diff --git a/requirements.txt b/requirements.txt
index ed45a6d..4067af6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,3 +6,4 @@ Markdown
ruamel.yaml
attrs
bcrypt
+packaging
diff --git a/setup.py b/setup.py
index 066de36..8f50457 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@ setuptools.setup(
"ruamel.yaml>=0.15.35,<0.16",
"attrs>=18.1.0,<19",
"bcrypt>=3.1.4,<4",
+ "packaging>=10",
],
classifiers=[