Switch to yaml for plugin metadata. Fixes #33
This commit is contained in:
parent
55685dfd6e
commit
07fe46e7f9
6 changed files with 86 additions and 62 deletions
|
@ -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:
|
||||
|
|
|
@ -13,10 +13,15 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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],
|
||||
}
|
||||
|
||||
|
|
|
@ -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 ("<ZippedPlugin "
|
||||
f"path='{self.path}' "
|
||||
f"id='{self.id}' "
|
||||
f"meta={self.meta} "
|
||||
f"loaded={self._loaded is not None}>")
|
||||
|
||||
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:
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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:
|
||||
|
|
|
@ -6,3 +6,4 @@ Markdown
|
|||
ruamel.yaml
|
||||
attrs
|
||||
bcrypt
|
||||
packaging
|
||||
|
|
1
setup.py
1
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=[
|
||||
|
|
Loading…
Reference in a new issue