Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check plugin settings #230

Merged
merged 9 commits into from Nov 10, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 19 additions & 3 deletions documentation/dev/plugins.rst
Expand Up @@ -150,12 +150,28 @@ This will find `css/styles.css` if you add that folder and file to your Blueprin
.. note:: This styling will only apply to the pages defined in your plugin (to pages based on your own base template). To apply a styling to all other pages which are served by FlexMeasures, consider using the config setting :ref:`extra-css-config`.


Adding config options
Adding config settings
^^^^^^^^^^^^^^^^^^^^^^^^

You might want to override some FlexMEasures configuration settings from within your plugin. Some examples for possible settings are named on this page, e.g. the custom style (see above) or custom logo (see below). There is a `record_once` function on Blueprints which can help with this. Example:
FlexMeasures can be made to check for any custom config settings your plugin is using.
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
Required and optional config options can be registered by setting the ``__settings__`` attribute on your plugin module:

.. code-block: python
.. code-block:: python

__settings__ = {
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"required": (
"MY_PLUGIN_SETTING_A",
"MY_PLUGIN_SETTING_B",
),
"optional": (
"MY_PLUGIN_SETTING_C",
"MY_PLUGIN_SETTING_D",
),
}

You might want to override some FlexMeasures configuration settings from within your plugin. Some examples for possible settings are named on this page, e.g. the custom style (see above) or custom logo (see below). There is a `record_once` function on Blueprints which can help with this. Example:

.. code-block:: python

@our_client_bp.record_once
def record_logo_path(setup_state):
Expand Down
3 changes: 2 additions & 1 deletion flexmeasures/app.py
Expand Up @@ -124,7 +124,8 @@ def create(
# If plugins register routes, they'll have precedence over standard UI
# routes (first registration wins). However, we want to control "/" separately.

from flexmeasures.utils.app_utils import root_dispatcher, register_plugins
from flexmeasures.utils.app_utils import root_dispatcher
from flexmeasures.utils.plugin_utils import register_plugins

app.add_url_rule("/", view_func=root_dispatcher)
register_plugins(app)
Expand Down
96 changes: 2 additions & 94 deletions flexmeasures/utils/app_utils.py
@@ -1,11 +1,9 @@
from typing import Union, Tuple, List, Optional
import os
import sys
import importlib.util
from importlib.abc import Loader

import click
from flask import Blueprint, Flask, current_app, redirect
from flask import Flask, current_app, redirect
from flask.cli import FlaskGroup, with_appcontext
from flask_security import current_user
import sentry_sdk
Expand Down Expand Up @@ -171,97 +169,7 @@ def parse_config_entry_by_account_roles(
]:
return entry
else:
app.logger.warn(
app.logger.warning(
f"Setting '{config}' in {setting_name} is neither a string nor two-part tuple. Ignoring ..."
)
return None


def register_plugins(app: Flask):
"""
Register FlexMeasures plugins as Blueprints.
This is configured by the config setting FLEXMEASURES_PLUGINS.

Assumptions:
- a setting EITHER points to a plugin folder containing an __init__.py file
OR it is the name of an installed module, which can be imported.
- each plugin defines at least one Blueprint object. These will be registered with the Flask app,
so their functionality (e.g. routes) becomes available.

If you load a plugin via a file path, we'll refer to the plugin with the name of your plugin folder
(last part of the path).
"""
plugins = app.config.get("FLEXMEASURES_PLUGINS", [])
if not plugins:
# this is deprecated behaviour which we should remove in version 1.0
app.logger.debug(
"No plugins configured. Attempting deprecated setting FLEXMEASURES_PLUGIN_PATHS ..."
)
plugins = app.config.get("FLEXMEASURES_PLUGIN_PATHS", [])
if not isinstance(plugins, list):
app.logger.error(
f"The value of FLEXMEASURES_PLUGINS is not a list: {plugins}. Cannot install plugins ..."
)
return
app.config["LOADED_PLUGINS"] = {}
for plugin in plugins:
plugin_name = plugin.split("/")[-1]
app.logger.info(f"Importing plugin {plugin_name} ...")
module = None
if not os.path.exists(plugin): # assume plugin is a package
pkg_name = os.path.split(plugin)[
-1
] # rule out attempts for relative package imports
app.logger.debug(
f"Attempting to import {pkg_name} as an installed package ..."
)
try:
module = importlib.import_module(pkg_name)
except ModuleNotFoundError:
app.logger.error(
f"Attempted to import module {pkg_name} (as it is not a valid file path), but it is not installed."
)
continue
else: # assume plugin is a file path
if not os.path.exists(os.path.join(plugin, "__init__.py")):
app.logger.error(
f"Plugin {plugin_name} is a valid file path, but does not contain an '__init__.py' file. Cannot load plugin {plugin_name}."
)
continue
spec = importlib.util.spec_from_file_location(
plugin_name, os.path.join(plugin, "__init__.py")
)
if spec is None:
app.logger.error(
f"Could not load specs for plugin {plugin_name} at {plugin}."
)
continue
module = importlib.util.module_from_spec(spec)
sys.modules[plugin_name] = module
assert isinstance(spec.loader, Loader)
spec.loader.exec_module(module)

if module is None:
app.logger.error(f"Plugin {plugin} could not be loaded.")
continue

plugin_version = getattr(module, "__version__", "0.1")

# Look for blueprints in the plugin's main __init__ module and register them
plugin_blueprints = [
getattr(module, a)
for a in dir(module)
if isinstance(getattr(module, a), Blueprint)
]
if not plugin_blueprints:
app.logger.warning(
f"No blueprints found for plugin {plugin_name} at {plugin}."
)
continue
for plugin_blueprint in plugin_blueprints:
app.logger.debug(f"Registering {plugin_blueprint} ...")
app.register_blueprint(plugin_blueprint)

app.config["LOADED_PLUGINS"][plugin_name] = plugin_version
app.logger.info(f"Loaded plugins: {app.config['LOADED_PLUGINS']}")
sentry_sdk.set_context("plugins", app.config.get("LOADED_PLUGINS", {}))
131 changes: 131 additions & 0 deletions flexmeasures/utils/plugin_utils.py
@@ -0,0 +1,131 @@
import importlib.util
import os
import sys
import warnings
from importlib.abc import Loader

import sentry_sdk
from flask import Flask, Blueprint


class ConfigurationWarning(Warning):
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
pass


class ConfigurationError(ValueError):
pass


def register_plugins(app: Flask):
"""
Register FlexMeasures plugins as Blueprints.
This is configured by the config setting FLEXMEASURES_PLUGINS.

Assumptions:
- a setting EITHER points to a plugin folder containing an __init__.py file
OR it is the name of an installed module, which can be imported.
- each plugin defines at least one Blueprint object. These will be registered with the Flask app,
so their functionality (e.g. routes) becomes available.

If you load a plugin via a file path, we'll refer to the plugin with the name of your plugin folder
(last part of the path).
"""
plugins = app.config.get("FLEXMEASURES_PLUGINS", [])
if not plugins:
# this is deprecated behaviour which we should remove in version 1.0
app.logger.debug(
"No plugins configured. Attempting deprecated setting FLEXMEASURES_PLUGIN_PATHS ..."
)
plugins = app.config.get("FLEXMEASURES_PLUGIN_PATHS", [])
if not isinstance(plugins, list):
app.logger.error(
f"The value of FLEXMEASURES_PLUGINS is not a list: {plugins}. Cannot install plugins ..."
)
return
app.config["LOADED_PLUGINS"] = {}
for plugin in plugins:
plugin_name = plugin.split("/")[-1]
app.logger.info(f"Importing plugin {plugin_name} ...")
module = None
if not os.path.exists(plugin): # assume plugin is a package
pkg_name = os.path.split(plugin)[
-1
] # rule out attempts for relative package imports
app.logger.debug(
f"Attempting to import {pkg_name} as an installed package ..."
)
try:
module = importlib.import_module(pkg_name)
except ModuleNotFoundError:
app.logger.error(
f"Attempted to import module {pkg_name} (as it is not a valid file path), but it is not installed."
)
continue
else: # assume plugin is a file path
if not os.path.exists(os.path.join(plugin, "__init__.py")):
app.logger.error(
f"Plugin {plugin_name} is a valid file path, but does not contain an '__init__.py' file. Cannot load plugin {plugin_name}."
)
continue
spec = importlib.util.spec_from_file_location(
plugin_name, os.path.join(plugin, "__init__.py")
)
if spec is None:
app.logger.error(
f"Could not load specs for plugin {plugin_name} at {plugin}."
)
continue
module = importlib.util.module_from_spec(spec)
sys.modules[plugin_name] = module
assert isinstance(spec.loader, Loader)
spec.loader.exec_module(module)

if module is None:
app.logger.error(f"Plugin {plugin} could not be loaded.")
continue

plugin_version = getattr(module, "__version__", "0.1")
plugin_settings = getattr(module, "__settings__", {})
check_config_settings(app, plugin_settings)

# Look for blueprints in the plugin's main __init__ module and register them
plugin_blueprints = [
getattr(module, a)
for a in dir(module)
if isinstance(getattr(module, a), Blueprint)
]
if not plugin_blueprints:
app.logger.warning(
f"No blueprints found for plugin {plugin_name} at {plugin}."
)
continue
for plugin_blueprint in plugin_blueprints:
app.logger.debug(f"Registering {plugin_blueprint} ...")
app.register_blueprint(plugin_blueprint)

app.config["LOADED_PLUGINS"][plugin_name] = plugin_version
app.logger.info(f"Loaded plugins: {app.config['LOADED_PLUGINS']}")
sentry_sdk.set_context("plugins", app.config.get("LOADED_PLUGINS", {}))


def check_config_settings(app, settings: dict):
"""Make sure expected config settings exist."""
missing_optional_config_settings = []
if "optional" in settings:
for setting in settings["optional"]:
if app.config.get(setting) is None:
missing_optional_config_settings.append(setting)
if missing_optional_config_settings:
warnings.warn(
f"Missing optional config setting(s): {missing_optional_config_settings}",
category=ConfigurationWarning,
)
if "required" in settings:
missing_required_config_settings = []
for setting in settings["required"]:
if app.config.get(setting) is None:
missing_required_config_settings.append(setting)
if missing_required_config_settings:
raise ConfigurationError(
f"Missing required config setting(s): {missing_required_config_settings}"
)