Switch to yaml for plugin metadata. Fixes #33

This commit is contained in:
Tulir Asokan 2018-12-09 15:49:57 +02:00
parent 55685dfd6e
commit 07fe46e7f9
6 changed files with 86 additions and 62 deletions

View File

@ -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:

View File

@ -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],
}

View File

@ -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:

View File

@ -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:

View File

@ -6,3 +6,4 @@ Markdown
ruamel.yaml
attrs
bcrypt
packaging

View File

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