Coverage for hookee/pluginmanager.py: 92.91%

141 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-04 15:06 +0000

1import importlib.util 

2import os 

3 

4from flask import current_app 

5from pluginbase import PluginBase 

6 

7from hookee import util 

8from hookee.exception import HookeePluginValidationError 

9 

10__author__ = "Alex Laird" 

11__copyright__ = "Copyright 2023, Alex Laird" 

12__version__ = "2.0.0" 

13 

14BLUEPRINT_PLUGIN = "blueprint" 

15REQUEST_PLUGIN = "request" 

16RESPONSE_PLUGIN = "response" 

17 

18VALID_PLUGIN_TYPES = [BLUEPRINT_PLUGIN, REQUEST_PLUGIN, RESPONSE_PLUGIN] 

19REQUIRED_PLUGINS = ["blueprint_default"] 

20 

21 

22class Plugin: 

23 """ 

24 An object that represents a validated and loaded ``hookee`` plugin. 

25 

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 """ 

37 

38 def __init__(self, module, plugin_type, name, has_setup, description=None): 

39 self.module = module 

40 

41 self.plugin_type = plugin_type 

42 self.name = name 

43 self.has_setup = has_setup 

44 self.description = description 

45 

46 if self.plugin_type == BLUEPRINT_PLUGIN: 

47 self.blueprint = self.module.blueprint 

48 

49 def setup(self, *args): 

50 """ 

51 Passes through to the underlying module's ``setup(*args)``, if it exists. 

52 

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) 

60 

61 def run(self, *args): 

62 """ 

63 Passes through to the underlying module's ``run(*args)``. 

64 

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) 

71 

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. 

77 

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) 

84 

85 functions_list = util.get_functions(module) 

86 attributes = dir(module) 

87 

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)) 

114 

115 has_setup = "setup" in functions_list and len(util.get_args(module.setup)) == 1 

116 

117 return Plugin(module, module.plugin_type, name, has_setup, getattr(module, "description", None)) 

118 

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. 

123 

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] 

130 

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) 

134 

135 return Plugin.build_from_module(module) 

136 

137 

138class PluginManager: 

139 """ 

140 An object that loads, validates, and manages available plugins. 

141 

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 """ 

160 

161 def __init__(self, hookee_manager): 

162 self.hookee_manager = hookee_manager 

163 self.config = self.hookee_manager.config 

164 

165 self.source = None 

166 self.response_callback = None 

167 

168 self.builtin_plugins_dir = os.path.normpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), "plugins")) 

169 

170 self.loaded_plugins = [] 

171 

172 self.source_plugins() 

173 

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") 

179 

180 plugin_base = PluginBase(package="hookee.plugins", 

181 searchpath=[self.builtin_plugins_dir]) 

182 self.source = plugin_base.make_plugin_source(searchpath=[plugins_dir]) 

183 

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() 

189 

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)) 

195 

196 self.source_plugins() 

197 

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) 

203 

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) 

209 

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) 

215 

216 response_body = self.config.get("response") 

217 response_content_type = self.config.get("content_type") 

218 

219 if response_content_type and not response_body: 

220 self.hookee_manager.fail("If `--content-type` is given, `--response` must also be given.") 

221 

222 self.response_callback = self.config.response_callback 

223 

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 

232 

233 self.response_callback = response_callback 

234 

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`.") 

239 

240 def get_plugins_by_type(self, plugin_type): 

241 """ 

242 Get loaded plugins by the given plugin type. 

243 

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)) 

250 

251 def run_request_plugins(self, request): 

252 """ 

253 Run all enabled request plugins. 

254 

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) 

262 

263 return request 

264 

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. 

268 

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) 

282 

283 if not response: 

284 response = current_app.response_class("") 

285 if self.response_callback: 

286 response = self.response_callback(request, response) 

287 

288 if response_info_plugin: 

289 response = response_info_plugin.run(request, response) 

290 

291 return response 

292 

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`. 

296 

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 

309 

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 

314 

315 self.hookee_manager.fail(str(e), e) 

316 

317 def enabled_plugins(self): 

318 """ 

319 Get a list of enabled plugins. 

320 

321 :return: The list of enabled plugins. 

322 :rtype: list[str] 

323 """ 

324 return list(str(p) for p in self.config.get("plugins")) 

325 

326 def available_plugins(self): 

327 """ 

328 Get a sorted list of available plugins. 

329 

330 :return: The list of available plugins. 

331 :rtype: list[str] 

332 """ 

333 return sorted([str(p) for p in self.source.list_plugins()])