plugin_utils.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. """
  2. Utils for registering FlexMeasures plugins
  3. """
  4. from __future__ import annotations
  5. import importlib.util
  6. import os
  7. import sys
  8. from importlib.abc import Loader
  9. from types import ModuleType
  10. import sentry_sdk
  11. from flask import Flask, Blueprint
  12. from flexmeasures.utils.coding_utils import get_classes_module
  13. def register_plugins(app: Flask): # noqa: C901
  14. """
  15. Register FlexMeasures plugins as Blueprints.
  16. This is configured by the config setting FLEXMEASURES_PLUGINS.
  17. Assumptions:
  18. - a setting EITHER points to a plugin folder containing an __init__.py file
  19. OR it is the name of an installed module, which can be imported.
  20. - each plugin defines at least one Blueprint object. These will be registered with the Flask app,
  21. so their functionality (e.g. routes) becomes available.
  22. If you load a plugin via a file path, we'll refer to the plugin with the name of your plugin folder
  23. (last part of the path).
  24. """
  25. plugins = app.config.get("FLEXMEASURES_PLUGINS", [])
  26. if not plugins and "FLEXMEASURES_PLUGIN_PATHS" in app.config:
  27. app.logger.warning(
  28. "Plugins found via FLEXMEASURES_PLUGIN_PATHS. This setting will be sunset in v0.14. Please switch to FLEXMEASURES_PLUGINS."
  29. )
  30. plugins = app.config.get("FLEXMEASURES_PLUGIN_PATHS", [])
  31. if isinstance(plugins, str):
  32. plugins = [
  33. plugin.strip() for plugin in plugins.split(",") if len(plugin.strip()) > 0
  34. ]
  35. if not isinstance(plugins, list):
  36. app.logger.error(
  37. f"The value of FLEXMEASURES_PLUGINS is not a list: {plugins}. Cannot install plugins ..."
  38. )
  39. return
  40. app.config["LOADED_PLUGINS"] = {}
  41. for plugin in plugins:
  42. plugin_name = plugin.split("/")[-1]
  43. app.logger.info(f"Importing plugin {plugin_name} ...")
  44. module = None
  45. if not os.path.exists(plugin): # assume plugin is a package
  46. pkg_name = os.path.split(plugin)[
  47. -1
  48. ] # rule out attempts for relative package imports
  49. app.logger.debug(
  50. f"Attempting to import {pkg_name} as an installed package ..."
  51. )
  52. try:
  53. module = importlib.import_module(pkg_name)
  54. except ModuleNotFoundError:
  55. app.logger.error(
  56. f"Attempted to import module {pkg_name} (as it is not a valid file path), but it is not installed."
  57. )
  58. continue
  59. else: # assume plugin is a file path
  60. if not os.path.exists(os.path.join(plugin, "__init__.py")):
  61. app.logger.error(
  62. f"Plugin {plugin_name} is a valid file path, but does not contain an '__init__.py' file. Cannot load plugin {plugin_name}."
  63. )
  64. continue
  65. spec = importlib.util.spec_from_file_location(
  66. plugin_name, os.path.join(plugin, "__init__.py")
  67. )
  68. if spec is None:
  69. app.logger.error(
  70. f"Could not load specs for plugin {plugin_name} at {plugin}."
  71. )
  72. continue
  73. module = importlib.util.module_from_spec(spec)
  74. sys.modules[plugin_name] = module
  75. assert isinstance(spec.loader, Loader)
  76. spec.loader.exec_module(module)
  77. if module is None:
  78. app.logger.error(f"Plugin {plugin} could not be loaded.")
  79. continue
  80. plugin_version = getattr(module, "__version__", "0.1")
  81. plugin_settings = getattr(module, "__settings__", {})
  82. check_config_settings(app, plugin_settings)
  83. # Look for blueprints in the plugin's main __init__ module and register them
  84. plugin_blueprints = [
  85. getattr(module, a)
  86. for a in dir(module)
  87. if isinstance(getattr(module, a), Blueprint)
  88. ]
  89. if not plugin_blueprints:
  90. app.logger.warning(
  91. f"No blueprints found for plugin {plugin_name} at {plugin}."
  92. )
  93. continue
  94. for plugin_blueprint in plugin_blueprints:
  95. app.logger.debug(f"Registering {plugin_blueprint} ...")
  96. app.register_blueprint(plugin_blueprint)
  97. # Load reporters and schedulers
  98. from flexmeasures.data.models.reporting import Reporter
  99. from flexmeasures.data.models.planning import Scheduler
  100. plugin_reporters = get_classes_module(module.__name__, Reporter)
  101. plugin_schedulers = get_classes_module(module.__name__, Scheduler)
  102. # add DataGenerators
  103. if plugin_reporters:
  104. app.data_generators["reporter"].update(plugin_reporters)
  105. if plugin_schedulers:
  106. app.data_generators["scheduler"].update(plugin_schedulers)
  107. app.config["LOADED_PLUGINS"][plugin_name] = plugin_version
  108. app.logger.info(f"Loaded plugins: {app.config['LOADED_PLUGINS']}")
  109. sentry_sdk.set_context("plugins", app.config.get("LOADED_PLUGINS", {}))
  110. def check_config_settings(app, settings: dict[str, dict]):
  111. """Make sure expected config settings exist.
  112. For example:
  113. settings = {
  114. "MY_PLUGIN_URL": {
  115. "description": "URL used by my plugin for x.",
  116. "level": "error",
  117. },
  118. "MY_PLUGIN_TOKEN": {
  119. "description": "Token used by my plugin for y.",
  120. "level": "warning",
  121. "message": "Without this token, my plugin will not do y.",
  122. "parse_as": str,
  123. },
  124. "MY_PLUGIN_COLOR": {
  125. "description": "Color used to override the default plugin color.",
  126. "level": "info",
  127. },
  128. }
  129. """
  130. # Check config settings are in dict form, after possibly converting them from module variables
  131. if isinstance(settings, ModuleType):
  132. settings = {
  133. setting: settings.__dict__[setting]
  134. for setting in dir(settings)
  135. if not setting.startswith("__")
  136. }
  137. assert isinstance(settings, dict), f"{type(settings)} should be a dict"
  138. for setting_name, setting_fields in settings.items():
  139. assert isinstance(setting_fields, dict), f"{setting_name} should be a dict"
  140. missing_config_settings = []
  141. config_settings_with_wrong_type = []
  142. for setting_name, setting_fields in settings.items():
  143. setting = app.config.get(setting_name)
  144. if setting is None:
  145. missing_config_settings.append(setting_name)
  146. elif "parse_as" in setting_fields and not isinstance(
  147. setting, setting_fields["parse_as"]
  148. ):
  149. config_settings_with_wrong_type.append((setting_name, setting))
  150. for setting_name, setting in config_settings_with_wrong_type:
  151. log_wrong_type_for_config_setting(
  152. app, setting_name, settings[setting_name], type(setting)
  153. )
  154. for setting_name in missing_config_settings:
  155. log_missing_config_setting(app, setting_name, settings[setting_name])
  156. def log_wrong_type_for_config_setting(
  157. app, setting_name: str, setting_fields: dict, setting_type: type
  158. ):
  159. """Log a message for this config setting that has the wrong type."""
  160. app.logger.warning(
  161. f"Config setting '{setting_name}' is a {setting_type} whereas a {setting_fields['parse_as']} was expected."
  162. )
  163. def log_missing_config_setting(app, setting_name: str, setting_fields: dict):
  164. """Log a message for this missing config setting.
  165. The logging level is taken from the 'level' key. If missing, we default to error.
  166. If present, we also log the 'description' and the 'message_if_missing' keys.
  167. """
  168. message_if_missing = (
  169. f" {setting_fields['message_if_missing']}"
  170. if "message_if_missing" in setting_fields
  171. else ""
  172. )
  173. description = (
  174. f" ({setting_fields['description']})" if "description" in setting_fields else ""
  175. )
  176. level = setting_fields["level"] if "level" in setting_fields else "error"
  177. if not hasattr(app.logger, level):
  178. app.logger.warning(
  179. f"Unrecognized logger level '{level}' for config setting '{setting_name}'."
  180. )
  181. level = "error"
  182. getattr(app.logger, level)(
  183. f"Missing config setting '{setting_name}'{description}.{message_if_missing}",
  184. )