diff --git a/maubot/loader/__init__.py b/maubot/loader/__init__.py new file mode 100644 index 0000000..552e342 --- /dev/null +++ b/maubot/loader/__init__.py @@ -0,0 +1,2 @@ +from .abc import PluginLoader, PluginClass +from .zip import ZippedPluginLoader diff --git a/maubot/loader/abc.py b/maubot/loader/abc.py new file mode 100644 index 0000000..e7b323d --- /dev/null +++ b/maubot/loader/abc.py @@ -0,0 +1,37 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# 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 +from abc import ABC, abstractmethod +from ..plugin_base import Plugin + +PluginClass = TypeVar("PluginClass", bound=Plugin) + + +class PluginLoader(ABC): + id: str + version: str + + @abstractmethod + def load(self) -> Type[PluginClass]: + pass + + @abstractmethod + def reload(self) -> Type[PluginClass]: + pass + + @abstractmethod + def unload(self) -> None: + pass diff --git a/maubot/loader/zip.py b/maubot/loader/zip.py new file mode 100644 index 0000000..bb44979 --- /dev/null +++ b/maubot/loader/zip.py @@ -0,0 +1,144 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Dict, List, Type +from zipfile import ZipFile, BadZipFile +import sys +import configparser + +from ..lib.zipimport import zipimporter, ZipImportError +from ..plugin_base import Plugin +from .abc import PluginLoader, PluginClass + + +class MaubotZipImportError(Exception): + pass + + +class ZippedPluginLoader(PluginLoader): + path_cache: Dict[str, 'ZippedPluginLoader'] = {} + id_cache: Dict[str, 'ZippedPluginLoader'] = {} + + path: str + id: str + version: str + modules: List[str] + main_class: str + main_module: str + loaded: bool + _importer: zipimporter + + def __init__(self, path: str) -> None: + self.path = path + self.id = None + self.loaded = False + self._load_meta() + self._run_preload_checks(self._get_importer()) + self.path_cache[self.path] = self + self.id_cache[self.id] = self + + def __repr__(self) -> str: + return ("") + + def _load_meta(self) -> None: + try: + file = ZipFile(self.path) + data = file.read("maubot.ini") + except FileNotFoundError as e: + raise MaubotZipImportError(f"Maubot plugin not found at {self.path}") from e + except BadZipFile as e: + raise MaubotZipImportError(f"File at {self.path} is not a maubot plugin") from e + except KeyError as e: + raise MaubotZipImportError( + "File at {path} does not contain a maubot plugin definition") from e + config = configparser.ConfigParser() + try: + config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini") + 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 MaubotZipImportError( + f"Maubot plugin definition in file at {self.path} is invalid") from e + if self.id and meta_id != self.id: + raise MaubotZipImportError("Maubot plugin ID changed during reload") + self.id, self.version, self.modules = meta_id, version, modules + self.main_class, self.main_module = main_class, main_module + + def _get_importer(self, reset_cache: bool = False) -> zipimporter: + try: + importer = zipimporter(self.path) + if reset_cache: + importer.reset_cache() + return importer + except ZipImportError as e: + raise MaubotZipImportError( + f"File at {self.path} not found or not a maubot plugin") from e + + def _run_preload_checks(self, importer: zipimporter) -> None: + try: + code = importer.get_code(self.main_module.replace(".", "/")) + if self.main_class not in code.co_names: + raise MaubotZipImportError( + f"Main class {self.main_class} not in {self.main_module}") + except ZipImportError as e: + raise MaubotZipImportError( + f"Main module {self.main_module} not found in {self.path}") from e + for module in self.modules: + try: + importer.find_module(module) + except ZipImportError as e: + raise MaubotZipImportError(f"Module {module} not found in {self.path}") from e + + def load(self) -> Type[PluginClass]: + importer = self._get_importer(reset_cache=self.loaded) + self._run_preload_checks(importer) + for module in self.modules: + importer.load_module(module) + self.loaded = True + main_mod = sys.modules[self.main_module] + plugin = getattr(main_mod, self.main_class) + if not issubclass(plugin, Plugin): + raise MaubotZipImportError( + f"Main class of plugin at {self.path} does not extend maubot.Plugin") + return plugin + + def reload(self) -> Type[PluginClass]: + self.unload() + return self.load() + + def unload(self) -> None: + for name, mod in list(sys.modules.items()): + if getattr(mod, "__file__", "").startswith(self.path): + del sys.modules[name] + + def destroy(self) -> None: + self.unload() + try: + del self.path_cache[self.path] + except KeyError: + pass + try: + del self.id_cache[self.id] + except KeyError: + pass diff --git a/maubot/plugin.py b/maubot/plugin.py index b952af0..36823a0 100644 --- a/maubot/plugin.py +++ b/maubot/plugin.py @@ -13,140 +13,14 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, TypeVar, Type -from zipfile import ZipFile, BadZipFile +from typing import Dict, List import logging -import sys -import os -import io -import configparser from mautrix.types import UserID -from .lib.zipimport import zipimporter, ZipImportError from .db import DBPlugin -from .plugin_base import Plugin log = logging.getLogger("maubot.plugin") -PluginClass = TypeVar("PluginClass", bound=Plugin) - -class MaubotImportError(Exception): - pass - - -class ZippedModule: - path_cache: Dict[str, 'ZippedModule'] = {} - id_cache: Dict[str, 'ZippedModule'] = {} - - path: str - id: str - version: str - modules: List[str] - main_class: str - main_module: str - loaded: bool - _importer: zipimporter - - def __init__(self, path: str) -> None: - self.path = path - self.id = None - self.loaded = False - self._load_meta() - self._run_preload_checks(self._get_importer()) - self.path_cache[self.path] = self - self.id_cache[self.id] = self - - def __repr__(self) -> str: - return ("") - - def _load_meta(self) -> None: - try: - file = ZipFile(self.path) - data = file.read("maubot.ini") - except FileNotFoundError as e: - raise MaubotImportError(f"Maubot plugin not found at {self.path}") from e - except BadZipFile as e: - raise MaubotImportError(f"File at {self.path} is not a maubot plugin") from e - except KeyError as e: - raise MaubotImportError( - "File at {path} does not contain a maubot plugin definition") from e - config = configparser.ConfigParser() - try: - config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini") - 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 MaubotImportError( - f"Maubot plugin definition in file at {self.path} is invalid") from e - if self.id and meta_id != self.id: - raise MaubotImportError("Maubot plugin ID changed during reload") - self.id, self.version, self.modules = meta_id, version, modules - self.main_class, self.main_module = main_class, main_module - - def _get_importer(self, reset_cache: bool = False) -> zipimporter: - try: - importer = zipimporter(self.path) - if reset_cache: - importer.reset_cache() - return importer - except ZipImportError as e: - raise MaubotImportError(f"File at {self.path} not found or not a maubot plugin") from e - - def _run_preload_checks(self, importer: zipimporter) -> None: - try: - code = importer.get_code(self.main_module.replace(".", "/")) - if self.main_class not in code.co_names: - raise MaubotImportError(f"Main class {self.main_class} not in {self.main_module}") - except ZipImportError as e: - raise MaubotImportError( - f"Main module {self.main_module} not found in {self.path}") from e - for module in self.modules: - try: - importer.find_module(module) - except ZipImportError as e: - raise MaubotImportError(f"Module {module} not found in {self.path}") from e - - def load(self) -> Type[PluginClass]: - importer = self._get_importer(reset_cache=self.loaded) - self._run_preload_checks(importer) - for module in self.modules: - importer.load_module(module) - self.loaded = True - main_mod = sys.modules[self.main_module] - plugin = getattr(main_mod, self.main_class) - if not issubclass(plugin, Plugin): - raise MaubotImportError( - f"Main class of plugin at {self.path} does not extend maubot.Plugin") - return plugin - - def reload(self) -> Type[PluginClass]: - self.unload() - return self.load() - - def unload(self) -> None: - for name, mod in list(sys.modules.items()): - if getattr(mod, "__file__", "").startswith(self.path): - del sys.modules[name] - - def destroy(self) -> None: - self.unload() - try: - del self.path_cache[self.path] - except KeyError: - pass - try: - del self.id_cache[self.id] - except KeyError: - pass class PluginInstance: @@ -157,6 +31,8 @@ class PluginInstance: self.db_instance = db_instance self.cache[self.id] = self + # region Properties + @property def id(self) -> str: return self.db_instance.id @@ -188,3 +64,5 @@ class PluginInstance: @primary_user.setter def primary_user(self, value: UserID) -> None: self.db_instance.primary_user = value + + # endregion