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=[