Coverage for hookee/pluginmanager.py: 92.91%
141 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-23 03:26 +0000
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-23 03:26 +0000
1import importlib.util
2import os
4from flask import current_app
5from pluginbase import PluginBase
7from hookee import util
8from hookee.exception import HookeePluginValidationError
10__author__ = "Alex Laird"
11__copyright__ = "Copyright 2023, Alex Laird"
12__version__ = "2.0.0"
14BLUEPRINT_PLUGIN = "blueprint"
15REQUEST_PLUGIN = "request"
16RESPONSE_PLUGIN = "response"
18VALID_PLUGIN_TYPES = [BLUEPRINT_PLUGIN, REQUEST_PLUGIN, RESPONSE_PLUGIN]
19REQUIRED_PLUGINS = ["blueprint_default"]
22class Plugin:
23 """
24 An object that represents a validated and loaded ``hookee`` plugin.
26 :var module: The underlying plugin module.
27 :vartype module: types.ModuleType
28 :var plugin_type: The type of plugin.
29 :vartype plugin_type: str
30 :var name: The name of the plugin.
31 :vartype name: str
32 :var name: The description of the plugin.
33 :vartype name: str, optional
34 :var has_setup: ``True`` if the plugin has a ``setup(hookee_manager)`` method.
35 :vartype has_setup: bool
36 """
38 def __init__(self, module, plugin_type, name, has_setup, description=None):
39 self.module = module
41 self.plugin_type = plugin_type
42 self.name = name
43 self.has_setup = has_setup
44 self.description = description
46 if self.plugin_type == BLUEPRINT_PLUGIN:
47 self.blueprint = self.module.blueprint
49 def setup(self, *args):
50 """
51 Passes through to the underlying module's ``setup(*args)``, if it exists.
53 :param args: The args to pass through.
54 :type args: tuple
55 :return: The value returned by the module's function (or nothing if the module's function returns nothing).
56 :rtype: object
57 """
58 if self.has_setup:
59 return self.module.setup(*args)
61 def run(self, *args):
62 """
63 Passes through to the underlying module's ``run(*args)``.
65 :param args: The args to pass through.
66 :type args: tuple
67 :return: The value returned by the module's function (or nothing if the module's function returns nothing).
68 :rtype: object
69 """
70 return self.module.run(*args)
72 @staticmethod
73 def build_from_module(module):
74 """
75 Validate and build a ``hookee`` plugin for the given module. If the module is not a valid ``hookee`` plugin,
76 an exception will be thrown.
78 :param module: The module to validate as a valid plugin.
79 :type module: types.ModuleType
80 :return: An object representing the validated plugin.
81 :rtype: Plugin
82 """
83 name = util.get_module_name(module)
85 functions_list = util.get_functions(module)
86 attributes = dir(module)
88 if "plugin_type" not in attributes:
89 raise HookeePluginValidationError(
90 "Plugin \"{}\" does not conform to the plugin spec.".format(name))
91 elif module.plugin_type not in VALID_PLUGIN_TYPES:
92 raise HookeePluginValidationError(
93 "Plugin \"{}\" must specify a valid `plugin_type`.".format(name))
94 elif module.plugin_type == REQUEST_PLUGIN:
95 if "run" not in functions_list:
96 raise HookeePluginValidationError(
97 "Plugin \"{}\" must implement `run(request)`.".format(name))
98 elif len(util.get_args(module.run)) < 1:
99 raise HookeePluginValidationError(
100 "Plugin \"{}\" does not conform to the plugin spec, `run(request)` must be defined.".format(
101 name))
102 elif module.plugin_type == RESPONSE_PLUGIN:
103 if "run" not in functions_list:
104 raise HookeePluginValidationError(
105 "Plugin \"{}\" must implement `run(request, response)`.".format(name))
106 elif len(util.get_args(module.run)) < 2:
107 raise HookeePluginValidationError(
108 "Plugin \"{}\" does not conform to the plugin spec, `run(request, response)` must be defined.".format(
109 name))
110 elif module.plugin_type == BLUEPRINT_PLUGIN and "blueprint" not in attributes:
111 raise HookeePluginValidationError(
112 "Plugin \"{}\" must define `blueprint = Blueprint(\"plugin_name\", __name__)`.".format(
113 name))
115 has_setup = "setup" in functions_list and len(util.get_args(module.setup)) == 1
117 return Plugin(module, module.plugin_type, name, has_setup, getattr(module, "description", None))
119 @staticmethod
120 def build_from_file(path):
121 """
122 Import a Python script at the given path, then import it as a ``hookee`` plugin.
124 :param path: The path to the script to import.
125 :type path: str
126 :return: The imported script as a plugin.
127 :rtype: Plugin
128 """
129 module_name = os.path.splitext(os.path.basename(path))[0]
131 spec = importlib.util.spec_from_file_location(module_name, path)
132 module = importlib.util.module_from_spec(spec)
133 spec.loader.exec_module(module)
135 return Plugin.build_from_module(module)
138class PluginManager:
139 """
140 An object that loads, validates, and manages available plugins.
142 :var hookee_manager: Reference to the ``hookee`` Manager.
143 :vartype hookee_manager: HookeeManager
144 :var config: The ``hookee`` configuration.
145 :vartype config: Config
146 :var source: The ``hookee`` configuration.
147 :vartype source: pluginbase.PluginSource
148 :var request_script: A request plugin loaded from the script at ``--request_script``, run last.
149 :vartype request_script: Plugin
150 :var response_script: A response plugin loaded from the script at ``--response_script``, run last.
151 :vartype response_script: Plugin
152 :var response_callback: The response body loaded from either ``--response``, or the lambda defined in the config's
153 ``response_calback``. Overrides any body data from response plugins.
154 :vartype response_body: str
155 :var builtin_plugins_dir: The directory where built-in plugins reside.
156 :vartype builtin_plugins_dir: str
157 :var loaded_plugins: A list of plugins that have been validated and imported.
158 :vartype loaded_plugins: list[Plugin]
159 """
161 def __init__(self, hookee_manager):
162 self.hookee_manager = hookee_manager
163 self.config = self.hookee_manager.config
165 self.source = None
166 self.response_callback = None
168 self.builtin_plugins_dir = os.path.normpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), "plugins"))
170 self.loaded_plugins = []
172 self.source_plugins()
174 def source_plugins(self):
175 """
176 Source all paths to look for plugins (defined in the config) to prepare them for loading and validation.
177 """
178 plugins_dir = self.config.get("plugins_dir")
180 plugin_base = PluginBase(package="hookee.plugins",
181 searchpath=[self.builtin_plugins_dir])
182 self.source = plugin_base.make_plugin_source(searchpath=[plugins_dir])
184 def load_plugins(self):
185 """
186 Load and validate all built-in plugins and custom plugins from sources in the plugin base.
187 """
188 enabled_plugins = self.enabled_plugins()
190 for plugin_name in REQUIRED_PLUGINS:
191 if plugin_name not in enabled_plugins:
192 self.hookee_manager.fail(
193 "Sorry, the plugin {} is required. Run `hookee enable-plugin {}` before continuing.".format(
194 plugin_name, plugin_name))
196 self.source_plugins()
198 self.loaded_plugins = []
199 for plugin_name in enabled_plugins:
200 plugin = self.get_plugin(plugin_name)
201 plugin.setup(self.hookee_manager)
202 self.loaded_plugins.append(plugin)
204 request_script = self.config.get("request_script")
205 if request_script:
206 request_script = Plugin.build_from_file(request_script)
207 request_script.setup(self.hookee_manager)
208 self.loaded_plugins.append(request_script)
210 response_script = self.config.get("response_script")
211 if response_script:
212 response_script = Plugin.build_from_file(response_script)
213 response_script.setup(self.hookee_manager)
214 self.loaded_plugins.append(response_script)
216 response_body = self.config.get("response")
217 response_content_type = self.config.get("content_type")
219 if response_content_type and not response_body:
220 self.hookee_manager.fail("If `--content-type` is given, `--response` must also be given.")
222 self.response_callback = self.config.response_callback
224 if self.response_callback and response_body:
225 self.hookee_manager.fail("If `response_callback` is given, `response` cannot also be given.")
226 elif response_body and not self.response_callback:
227 def response_callback(request, response):
228 response.data = response_body
229 response.headers[
230 "Content-Type"] = response_content_type if response_content_type else "text/plain"
231 return response
233 self.response_callback = response_callback
235 if len(self.get_plugins_by_type(RESPONSE_PLUGIN)) == 0 and not self.response_callback:
236 self.hookee_manager.fail(
237 "No response plugin was loaded. Enable a pluing like `response_echo`, or pass `--response` "
238 "or `--response-script`.")
240 def get_plugins_by_type(self, plugin_type):
241 """
242 Get loaded plugins by the given plugin type.
244 :param plugin_type: The plugin type for filtering.
245 :type plugin_type: str
246 :return: The filtered list of plugins.
247 :rtype: list[Plugin]
248 """
249 return list(filter(lambda p: p.plugin_type == plugin_type, self.loaded_plugins))
251 def run_request_plugins(self, request):
252 """
253 Run all enabled request plugins.
255 :param request: The request object being processed.
256 :type request: flask.Request
257 :return: The processed request.
258 :rtype: flask.Request
259 """
260 for plugin in self.get_plugins_by_type(REQUEST_PLUGIN):
261 request = plugin.run(request)
263 return request
265 def run_response_plugins(self, request=None, response=None):
266 """
267 Run all enabled response plugins, running the ``response_info`` plugin (if enabled) last.
269 :param request: The request object being processed.
270 :type request: flask.Request, optional
271 :param response: The response object being processed.
272 :type response: flask.Response, optional
273 :return: The processed response.
274 :rtype: flask.Response
275 """
276 response_info_plugin = None
277 for plugin in self.get_plugins_by_type(RESPONSE_PLUGIN):
278 if plugin.name == "response_info":
279 response_info_plugin = plugin
280 else:
281 response = plugin.run(request, response)
283 if not response:
284 response = current_app.response_class("")
285 if self.response_callback:
286 response = self.response_callback(request, response)
288 if response_info_plugin:
289 response = response_info_plugin.run(request, response)
291 return response
293 def get_plugin(self, plugin_name, throw_error=False):
294 """
295 Get the given plugin name from modules parsed by :func:`~hookee.pluginmanager.PluginManager.source_plugins`.
297 :param plugin_name: The name of the plugin to load.
298 :type plugin_name: str
299 :param throw_error: ``True`` if errors encountered should be thrown to the caller, ``False`` if
300 :func:`~hookee.hookeemanager.HookeeManager.fail` should be called.
301 :return: The loaded plugin.
302 :rtype: Plugin
303 """
304 try:
305 return Plugin.build_from_module(self.source.load_plugin(plugin_name))
306 except ImportError as e:
307 if throw_error:
308 raise e
310 self.hookee_manager.fail("Plugin \"{}\" could not be found.".format(plugin_name))
311 except HookeePluginValidationError as e:
312 if throw_error:
313 raise e
315 self.hookee_manager.fail(str(e), e)
317 def enabled_plugins(self):
318 """
319 Get a list of enabled plugins.
321 :return: The list of enabled plugins.
322 :rtype: list[str]
323 """
324 return list(str(p) for p in self.config.get("plugins"))
326 def available_plugins(self):
327 """
328 Get a sorted list of available plugins.
330 :return: The list of available plugins.
331 :rtype: list[str]
332 """
333 return sorted([str(p) for p in self.source.list_plugins()])