From 76f660c160838fb562185a302337d1950015c0cb Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 23 Dec 2021 02:45:51 +0100 Subject: [PATCH] feat(plugins): Identify plugins by id, migrations must be provided at defined location, add utils for plugin functions --- flaschengeist/app.py | 85 +++++++------------- flaschengeist/database.py | 9 ++- flaschengeist/plugins/__init__.py | 34 ++++++-- flaschengeist/plugins/auth/__init__.py | 4 +- flaschengeist/plugins/auth_ldap/__init__.py | 2 + flaschengeist/plugins/auth_plain/__init__.py | 2 + flaschengeist/plugins/balance/__init__.py | 5 +- flaschengeist/plugins/message_mail.py | 2 + flaschengeist/plugins/pricelist/__init__.py | 4 +- flaschengeist/plugins/roles/__init__.py | 4 +- flaschengeist/plugins/users/__init__.py | 4 +- flaschengeist/utils/plugin.py | 46 +++++++++++ 12 files changed, 126 insertions(+), 75 deletions(-) create mode 100644 flaschengeist/utils/plugin.py diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 0d7e8b8..d717b81 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,7 +1,6 @@ import enum -import pkg_resources -from flask import Flask, current_app +from flask import Flask from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify @@ -10,7 +9,8 @@ from werkzeug.exceptions import HTTPException from flaschengeist import logger from flaschengeist.utils.hook import Hook -from flaschengeist.plugins import AuthPlugin +from flaschengeist.plugins import AuthPlugin, Plugin +from flaschengeist.utils.plugin import get_plugins from flaschengeist.controller import roleController from flaschengeist.config import config, configure_app @@ -37,65 +37,36 @@ class CustomJSONEncoder(JSONEncoder): @Hook("plugins.loaded") -def __load_plugins(app): - logger.debug("Search for plugins") +def load_plugins(app: Flask): + def load_plugin(cls: type[Plugin]): + logger.debug(f"Load plugin {cls.id}") + # Initialize plugin with config section + plugin = cls(config.get(plugin_class.id, config.get(plugin_class.id.split(".")[-1], {}))) + # Register blueprint if provided + if plugin.blueprint is not None: + app.register_blueprint(plugin.blueprint) + # Save plugin application context + app.config.setdefault("FG_PLUGINS", {})[plugin.id] = plugin + return plugin - app.config["FG_PLUGINS"] = {} - for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): - logger.debug(f"Found plugin: >{entry_point.name}<") - - if entry_point.name == config["FLASCHENGEIST"]["auth"] or ( - entry_point.name in config and config[entry_point.name].get("enabled", False) - ): - logger.debug(f"Load plugin {entry_point.name}") - try: - plugin = entry_point.load() - if not hasattr(plugin, "name"): - setattr(plugin, "name", entry_point.name) - plugin = plugin(config.get(entry_point.name, {})) - if hasattr(plugin, "blueprint") and plugin.blueprint is not None: - app.register_blueprint(plugin.blueprint) - except: - logger.error( - f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", - exc_info=True, - ) - continue - if isinstance(plugin, AuthPlugin): - if entry_point.name != config["FLASCHENGEIST"]["auth"]: - logger.debug(f"Unload not configured AuthPlugin {entry_point.name}") - del plugin - continue - else: - logger.info(f"Using authentication plugin: {entry_point.name}") - app.config["FG_AUTH_BACKEND"] = plugin - else: - logger.info(f"Using plugin: {entry_point.name}") - app.config["FG_PLUGINS"][entry_point.name] = plugin + for plugin_class in get_plugins(): + names = [plugin_class.id, plugin_class.id.split(".")[-1]] + if config["FLASCHENGEIST"]["auth"] in names: + # Load authentification plugin + app.config["FG_AUTH_BACKEND"] = load_plugin(plugin_class) + logger.info(f"Using authentication plugin: {plugin_class.id}") + elif any([i in config and config[i].get("enabled", False) for i in names]): + # Load all other enabled plugins + load_plugin(plugin_class) + logger.info(f"Using plugin: {plugin_class.id}") else: - logger.debug(f"Skip disabled plugin {entry_point.name}") + logger.debug(f"Skip disabled plugin {plugin_class.id}") if "FG_AUTH_BACKEND" not in app.config: - logger.error("No authentication plugin configured or authentication plugin not found") + logger.fatal("No authentication plugin configured or authentication plugin not found") raise RuntimeError("No authentication plugin configured or authentication plugin not found") -@Hook("plugins.installed") -def install_all(): - from flaschengeist.database import db - - db.create_all() - db.session.commit() - for name, plugin in current_app.config["FG_PLUGINS"].items(): - if not plugin: - logger.debug(f"Skip disabled plugin: {name}") - continue - logger.info(f"Install plugin {name}") - plugin.install() - if plugin.permissions: - roleController.create_permissions(plugin.permissions) - - -def create_app(test_config=None): +def create_app(test_config=None, cli=False): app = Flask("flaschengeist") app.json_encoder = CustomJSONEncoder CORS(app) @@ -106,7 +77,7 @@ def create_app(test_config=None): configure_app(app, test_config) db.init_app(app) migrate.init_app(app, db, compare_type=True) - __load_plugins(app) + load_plugins(app) @app.route("/", methods=["GET"]) def __get_state(): diff --git a/flaschengeist/database.py b/flaschengeist/database.py index 560f7ed..ccafd8d 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -24,9 +24,16 @@ migrate = Migrate() def configure_alembic(config): """Alembic configuration hook + Inject all migrations paths into the ``version_locations`` config option. + This includes even disabled plugins, as simply disabling a plugin without + uninstall can break the alembic version management. """ + import inspect, pathlib + from flaschengeist.utils.plugin import get_plugins + # Load migration paths from plugins - migrations = [str(p.migrations_path) for p in current_app.config["FG_PLUGINS"].values() if p and p.migrations_path] + migrations = [(pathlib.Path(inspect.getfile(p)).parent / "migrations") for p in get_plugins()] + migrations = [str(m.resolve()) for m in migrations if m.exists()] if len(migrations) > 0: # Get configured paths paths = config.get_main_option("version_locations") diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index f61ac52..a08c3e7 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -24,9 +24,7 @@ For more information, please refer to - `flaschengeist.utils.hook.HookAfter` """ -import pkg_resources from werkzeug.exceptions import MethodNotAllowed, NotFound -from flaschengeist.models.user import _Avatar, User from flaschengeist.utils.hook import HookBefore, HookAfter __all__ = [ @@ -105,9 +103,16 @@ class Plugin: - *version*: Version of your plugin, can also be guessed by Flaschengeist """ - blueprint = None # You have to override + id: str = None + """Override with the unique ID of the plugin + + Hint: Use a fully qualified name like "dev.flaschengeist.plugin" + """ + + blueprint = None """Override with a `flask.blueprint` if the plugin uses custom routes""" - permissions = [] # You have to override + + permissions: list[str] = [] # You have to override """Override to add custom permissions used by the plugin A good style is to name the permissions with a prefix related to the plugin name, @@ -131,7 +136,6 @@ class Plugin: Args: config: Dict configuration containing the plugin section """ - self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version def install(self): """Installation routine @@ -140,6 +144,17 @@ class Plugin: """ pass + def uninstall(self): + """Uninstall routine + + If the plugin has custom database tables, make sure to remove them. + This can be either done by downgrading the plugin *head* to the *base*. + Or use custom migrations for the uninstall and *stamp* some version. + + Is always called with Flask application context. + """ + pass + def get_setting(self, name: str, **kwargs): """Get plugin setting from database @@ -192,6 +207,11 @@ class Plugin: class AuthPlugin(Plugin): + """Base class for all authentification plugins + + See also `Plugin` + """ + def login(self, user, pw): """Login routine, MUST BE IMPLEMENTED! @@ -254,7 +274,7 @@ class AuthPlugin(Plugin): """ raise MethodNotAllowed - def get_avatar(self, user: User) -> _Avatar: + def get_avatar(self, user): """Retrieve avatar for given user (if supported by auth backend) Default behavior is to use native Image objects, @@ -286,7 +306,7 @@ class AuthPlugin(Plugin): user.avatar_ = imageController.upload_image(file) - def delete_avatar(self, user: User): + def delete_avatar(self, user): """Delete the avatar for given user (if supported by auth backend) Default behavior is to use the imageController and native Image objects. diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 9c08463..66d77be 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -13,8 +13,8 @@ from flaschengeist.controller import sessionController, userController class AuthRoutePlugin(Plugin): - name = "auth" - blueprint = Blueprint(name, __name__) + id = "dev.flaschengeist.auth" + blueprint = Blueprint("auth", __name__) @AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 697e972..20fb46f 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -17,6 +17,8 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): + id = "auth_ldap" + def __init__(self, config): super().__init__() app.config.update( diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 10d72d3..e73b98c 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -14,6 +14,8 @@ from flaschengeist import logger class AuthPlain(AuthPlugin): + id = "auth_plain" + def install(self): plugins_installed(self.post_install) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 73841f7..ea08857 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -57,9 +57,10 @@ def service_debit(): class BalancePlugin(Plugin): - name = "balance" + """Balance Plugin""" + id = "dev.flaschengeist.balance" - blueprint = Blueprint(name, __name__) + blueprint = Blueprint("balance", __name__) permissions = permissions.permissions plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) models = models diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 79b6a64..d0cebdb 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -12,6 +12,8 @@ from . import Plugin class MailMessagePlugin(Plugin): + id = "dev.flaschengeist.mail_plugin" + def __init__(self, config): super().__init__() self.server = config["SERVER"] diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index dac2f82..70fe1f7 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -17,9 +17,9 @@ from . import pricelist_controller, permissions class PriceListPlugin(Plugin): - name = "pricelist" + id = "dev.flaschengeist.pricelist" permissions = permissions.permissions - blueprint = Blueprint(name, __name__, url_prefix="/pricelist") + blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist") plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name]) models = models diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 954ba21..3a48ba1 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -16,8 +16,8 @@ from . import permissions class RolesPlugin(Plugin): - name = "roles" - blueprint = Blueprint(name, __name__) + id = "dev.flaschengeist.roles" + blueprint = Blueprint("roles", __name__) permissions = permissions.permissions diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index eef0041..3afbd64 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -18,8 +18,8 @@ from flaschengeist.utils.datetime import from_iso_format class UsersPlugin(Plugin): - name = "users" - blueprint = Blueprint(name, __name__) + id = "dev.flaschengeist.users" + blueprint = Blueprint("users", __name__) permissions = permissions.permissions diff --git a/flaschengeist/utils/plugin.py b/flaschengeist/utils/plugin.py new file mode 100644 index 0000000..b37e582 --- /dev/null +++ b/flaschengeist/utils/plugin.py @@ -0,0 +1,46 @@ +"""Plugin utils + +Utilities for handling Flaschengeist plugins +""" + +import pkg_resources +from flaschengeist import logger +from flaschengeist.plugins import Plugin + + +def get_plugins() -> list[type[Plugin]]: + """Get all installed plugins for Flaschengeist + + Returns: + list of classes implementing `flaschengeist.plugins.Plugin` + """ + logger.debug("Search for plugins") + + plugins = [] + for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): + try: + logger.debug(f"Found plugin: >{entry_point.name}<") + plugin_class = entry_point.load() + if issubclass(plugin_class, Plugin): + plugins.append(plugin_class) + except TypeError: + logger.error(f"Invalid entry point for plugin {entry_point.name} found.") + return plugins + + +def plugin_version(plugin: type[Plugin]) -> str: + """Get version of plugin + + Returns the version of a plugin, if plugin does not set the + version property, the version of the package providing the + plugin is taken. + + Args: + plugin: Plugin or Plugin class + + Returns: + Version as string + """ + if plugin.version: + return plugin.version + return pkg_resources.get_distribution(plugin.__module__.split(".", 1)[0]).version