Coverage for hookee/pluginmanager.py: 92.91%

# Coverage for hookee/pluginmanager.py : 92.91%

## 141 statements

Hot-keys on this page

r m x p toggle line displays

j k next/prev highlighted chunk

0 (zero) top of page

1 (one) first highlighted chunk

1 import importlib.util

2 import os

3

4 from flask import current_app

5 from pluginbase import PluginBase

6

7 from hookee import util

8 from hookee.exception import HookeePluginValidationError

9

10 __author__ = "Alex Laird"

11 __copyright__ = "Copyright 2020, Alex Laird"

12 __version__ = "2.0.0"

13

14 BLUEPRINT_PLUGIN = "blueprint"

15 REQUEST_PLUGIN = "request"

16 RESPONSE_PLUGIN = "response"

17

18 VALID_PLUGIN_TYPES = [BLUEPRINT_PLUGIN, REQUEST_PLUGIN, RESPONSE_PLUGIN]

19 REQUIRED_PLUGINS = ["blueprint_default"]

20

21

22 class 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

138 class 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()])