No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

plugins.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. #-*- coding: utf-8 -*-
  2. import sys
  3. import os.path
  4. import importlib
  5. import copy
  6. from importlib.machinery import SourceFileLoader, SourcelessFileLoader
  7. import plugins
  8. from .exceptions import *
  9. ## @package lodel.plugins Lodel2 plugins management
  10. #
  11. # Lodel2 plugins are stored in directories
  12. # A typicall lodel2 plugin directory structure looks like :
  13. # - {{__init__.py}}} containing informations like full_name, authors, licence etc.
  14. # - main.py containing hooks registration etc
  15. # - confspec.py containing a configuration specification dictionary named CONFSPEC
  16. ##@brief The package in which we will load plugins modules
  17. VIRTUAL_PACKAGE_NAME = 'lodel.plugins'
  18. INIT_FILENAME = '__init__.py' # Loaded with settings
  19. CONFSPEC_FILENAME_VARNAME = '__confspec__'
  20. CONFSPEC_VARNAME = 'CONFSPEC'
  21. LOADER_FILENAME_VARNAME = '__loader__'
  22. PLUGIN_DEPS_VARNAME = '__plugin_deps__'
  23. ACTIVATE_METHOD_NAME = '_activate'
  24. ##@brief Handle plugins
  25. #
  26. # An instance represent a loaded plugin. Class methods allow to load/preload
  27. # plugins.
  28. #
  29. # Typicall Plugins load sequence is :
  30. # 1. Settings call start method to instanciate all plugins found in confs
  31. # 2. Settings fetch all confspecs
  32. # 3. the loader call load_all to register hooks etc
  33. class Plugin(object):
  34. ##@brief Stores plugin directories paths
  35. _plugin_directories = None
  36. ##@brief Stores Plugin instances indexed by name
  37. _plugin_instances = dict()
  38. ##@brief Attribute used by load_all and load methods to detect circular
  39. #dependencies
  40. _load_called = []
  41. ##@brief Plugin class constructor
  42. #
  43. # Called by setting in early stage of lodel2 boot sequence using classmethod
  44. # register
  45. #
  46. # @param plugin_name str : plugin name
  47. # @throw PluginError
  48. def __init__(self, plugin_name):
  49. self.started()
  50. self.name = plugin_name
  51. self.path = self.plugin_path(plugin_name)
  52. ##@brief Stores the plugin module
  53. self.module = None
  54. ##@breif Stores the plugin loader module
  55. self.__loader_module = None
  56. self.__confspecs = dict()
  57. self.loaded = False
  58. # Importing __init__.py
  59. plugin_module = '%s.%s' % (VIRTUAL_PACKAGE_NAME,
  60. plugin_name)
  61. init_source = self.path + INIT_FILENAME
  62. try:
  63. loader = SourceFileLoader(plugin_module, init_source)
  64. self.module = loader.load_module()
  65. except (ImportError,FileNotFoundError) as e:
  66. raise PluginError("Failed to load plugin '%s'. It seems that the plugin name is not valid or the plugin do not exists" % plugin_name)
  67. # loading confspecs
  68. try:
  69. # Loading confspec directly from __init__.py
  70. self.__confspecs = getattr(self.module, CONFSPEC_VARNAME)
  71. except AttributeError:
  72. # Loading file in __confspec__ var in __init__.py
  73. try:
  74. module = self._import_from_init_var(CONFSPEC_FILENAME_VARNAME)
  75. except AttributeError:
  76. msg = "Malformed plugin {plugin} . No {varname} not {filevar} found in __init__.py"
  77. msg = msg.format(
  78. plugin = self.name,
  79. varname = CONFSPEC_VARNAME,
  80. filevar = CONFSPEC_FILENAME_VARNAME)
  81. raise PluginError(msg)
  82. except ImportError as e:
  83. msg = "Broken plugin {plugin} : {expt}"
  84. msg = msg.format(
  85. plugin = self.name,
  86. expt = str(e))
  87. raise PluginError(msg)
  88. try:
  89. # loading confpsecs from file
  90. self.__confspecs = getattr(module, CONFSPEC_VARNAME)
  91. except AttributeError:
  92. msg = "Broken plugin. {varname} not found in '{filename}'"
  93. msg = msg.format(
  94. varname = CONFSPEC_VARNAME,
  95. filename = confspec_filename)
  96. raise PluginError(msg)
  97. ##@brief Try to import a file from a variable in __init__.py
  98. #@param varname str : The variable name
  99. #@return loaded module
  100. #@throw AttributeError if varname not found
  101. #@throw ImportError if the file fails to be imported
  102. #@throw PluginError if the filename was not valid
  103. def _import_from_init_var(self, varname):
  104. # Read varname
  105. try:
  106. filename = getattr(self.module, varname)
  107. except AttributeError:
  108. msg = "Malformed plugin {plugin}. No {varname} found in __init__.py"
  109. msg = msg.format(
  110. plugin = self.name,
  111. varname = LOADER_FILENAME_VARNAME)
  112. raise PluginError(msg)
  113. #Path are not allowed
  114. if filename != os.path.basename(filename):
  115. msg = "Invalid {varname} content : '{fname}' for plugin {name}"
  116. msg = msg.format(
  117. varname = varname,
  118. fname = filename,
  119. name = self.name)
  120. raise PluginError(msg)
  121. # importing the file in varname
  122. module_name = self.module.__name__+"."+varname
  123. filename = self.path + filename
  124. loader = SourceFileLoader(module_name, filename)
  125. return loader.load_module()
  126. ##@brief Check dependencies of plugin
  127. #@return A list of plugin name to be loaded before
  128. def check_deps(self):
  129. try:
  130. res = getattr(self.module, PLUGIN_DEPS_VARNAME)
  131. except AttributeError:
  132. return list()
  133. result = list()
  134. errors = list()
  135. for plugin_name in res:
  136. try:
  137. result.append(self.get(plugin_name))
  138. except PluginError:
  139. errors.append(plugin_name)
  140. if len(errors) > 0:
  141. raise PluginError( "Bad dependencie for '%s' :"%self.name,
  142. ', '.join(errors))
  143. return result
  144. ##@brief Check if the plugin should be activated
  145. #
  146. #Try to fetch a function called @ref ACTIVATE_METHOD_NAME in __init__.py
  147. #of a plugin. If none found assert that the plugin can be loaded, else
  148. #the method is called. If it returns anything else that True, the plugin
  149. #is noted as not activable
  150. #
  151. # @note Maybe we have to exit everything if a plugin cannot be loaded...
  152. def activable(self):
  153. from lodel import logger
  154. try:
  155. test_fun = getattr(self.module, ACTIVATE_METHOD_NAME)
  156. except AttributeError:
  157. msg = "No %s method found for plugin %s. Assuming plugin is ready to be loaded"
  158. msg %= (ACTIVATE_METHOD_NAME, self.name)
  159. logger.debug(msg)
  160. test_fun = lambda:True
  161. return test_fun()
  162. ##@brief Load a plugin
  163. #
  164. #Loading a plugin means importing a file. The filename is defined in the
  165. #plugin's __init__.py file in a LOADER_FILENAME_VARNAME variable.
  166. #
  167. #The loading process has to take care of other things :
  168. #- loading dependencies (other plugins)
  169. #- check that the plugin can be activated using Plugin.activate() method
  170. #- avoid circular dependencies infinite loop
  171. def _load(self):
  172. if self.loaded:
  173. return
  174. from lodel import logger
  175. #Test that plugin "wants" to be activated
  176. activable = self.activable()
  177. if not(activable is True):
  178. msg = "Plugin %s is not activable : %s"
  179. msg %= (self.name, activable)
  180. raise PluginError(activable)
  181. #Circular dependencie detection
  182. if self.name in self._load_called:
  183. raise PluginError("Circular dependencie in Plugin detected. Abording")
  184. else:
  185. self._load_called.append(self.name)
  186. #Dependencie load
  187. for dependencie in self.check_deps():
  188. activable = dependencie.activable()
  189. if activable is True:
  190. dependencie._load()
  191. else:
  192. msg = "Plugin {plugin_name} not activable because it depends on plugin {dep_name} that is not activable : {reason}"
  193. msg = msg.format(
  194. plugin_name = self.name,
  195. dep_name = dependencie.name,
  196. reason = activable)
  197. #Loading the plugin
  198. try:
  199. self.__loader_module = self._import_from_init_var(LOADER_FILENAME_VARNAME)
  200. except PluginError as e:
  201. raise e
  202. except ImportError as e:
  203. msg = "Broken plugin {plugin} : {expt}"
  204. msg = msg.format(
  205. plugin = self.name,
  206. expt = str(e))
  207. raise PluginError(msg)
  208. logger.debug("Plugin '%s' loaded" % self.name)
  209. self.loaded = True
  210. def loader_module(self):
  211. if not self.loaded:
  212. raise RuntimeError("Plugin %s not loaded yet."%self.name)
  213. return self.__loader_module
  214. ##@brief Call load method on every pre-loaded plugins
  215. #
  216. # Called by loader to trigger hooks registration.
  217. # This method have to avoid circular dependencies infinite loops. For this
  218. # purpose a class attribute _load_called exists.
  219. # @throw PluginError
  220. @classmethod
  221. def load_all(cls):
  222. errors = dict()
  223. cls._load_called = []
  224. for name, plugin in cls._plugin_instances.items():
  225. try:
  226. plugin._load()
  227. except PluginError as e:
  228. errors[name] = e
  229. if len(errors) > 0:
  230. msg = "Errors while loading plugins :"
  231. for name, e in errors.items():
  232. msg += "\n\t%20s : %s" % (name,e)
  233. msg += "\n"
  234. raise PluginError(msg)
  235. from lodel.plugin.hooks import LodelHook
  236. LodelHook.call_hook(
  237. "lodel2_plugins_loaded", cls, cls._plugin_instances)
  238. ##@return a copy of __confspecs attr
  239. @property
  240. def confspecs(self):
  241. return copy.copy(self.__confspecs)
  242. ##@brief Register a new plugin
  243. #
  244. #@param plugin_name str : The plugin name
  245. #@return a Plugin instance
  246. #@throw PluginError
  247. @classmethod
  248. def register(cls, plugin_name):
  249. if plugin_name in cls._plugin_instances:
  250. msg = "Plugin allready registered with same name %s"
  251. msg %= plugin_name
  252. raise PluginError(msg)
  253. plugin = cls(plugin_name)
  254. cls._plugin_instances[plugin_name] = plugin
  255. return plugin
  256. ##@brief Plugins instances accessor
  257. #
  258. #@param plugin_name str: The plugin name
  259. #@return a Plugin instance
  260. #@throw PluginError if plugin not found
  261. @classmethod
  262. def get(cls, plugin_name):
  263. try:
  264. return cls._plugin_instances[plugin_name]
  265. except KeyError:
  266. msg = "No plugin named '%s' loaded"
  267. msg %= plugin_name
  268. raise PluginError(msg)
  269. ##@brief Given a plugin name returns the plugin path
  270. # @param plugin_name str : The plugin name
  271. # @return the plugin directory path
  272. @classmethod
  273. def plugin_path(cls, plugin_name):
  274. cls.started()
  275. try:
  276. return cls.get(plugin_name).path
  277. except PluginError:
  278. pass
  279. path = None
  280. for cur_path in cls._plugin_directories:
  281. plugin_path = os.path.join(cur_path, plugin_name)+'/'
  282. if os.path.isdir(plugin_path):
  283. return plugin_path
  284. raise NameError("No plugin named '%s'" % plugin_name)
  285. @classmethod
  286. def plugin_module_name(cls, plugin_name):
  287. return "%s.%s" % (VIRTUAL_PACKAGE_NAME, plugin_name)
  288. ##@brief Start the Plugin class
  289. #
  290. # Called by Settings.__bootstrap()
  291. #
  292. # This method load path and preload plugins
  293. @classmethod
  294. def start(cls, plugins_directories, plugins):
  295. if cls._plugin_directories is not None:
  296. return
  297. import inspect
  298. self_path = inspect.getsourcefile(Plugin)
  299. default_plugin_path = os.path.abspath(self_path + '../../../../plugins')
  300. if plugins_directories is None:
  301. plugins_directories = list()
  302. plugins_directories += [ default_plugin_path ]
  303. cls._plugin_directories = list(set(plugins_directories))
  304. for plugin_name in plugins:
  305. cls.register(plugin_name)
  306. @classmethod
  307. def started(cls, raise_if_not = True):
  308. res = cls._plugin_directories is not None
  309. if raise_if_not and not res:
  310. raise RuntimeError("Class Plugins is not initialized")
  311. @classmethod
  312. def clear(cls):
  313. if cls._plugin_directories is not None:
  314. cls._plugin_directories = None
  315. if cls._plugin_instances != dict():
  316. cls._plugin_instances = dict()
  317. if cls._load_called != []:
  318. cls._load_called = []
  319. ##@brief Decorator class designed to allow plugins to add custom methods
  320. #to LeObject childs (dyncode objects)
  321. #
  322. class CustomMethod(object):
  323. ##@brief Stores registered custom methods
  324. #
  325. #Key = LeObject child class name
  326. #Value = CustomMethod instance
  327. _custom_methods = dict()
  328. INSTANCE_METHOD = 0
  329. CLASS_METHOD = 1
  330. STATIC_METHOD = 2
  331. ##@brief Decorator constructor
  332. #@param component_name str : the name of the component to enhance
  333. #@param method_name str : the name of the method to inject (if None given
  334. #@param method_type int : take value in one of
  335. #CustomMethod::INSTANCE_METHOD CustomMethod::CLASS_METHOD or
  336. #CustomMethod::STATIC_METHOD
  337. #use the function name
  338. def __init__(self, component_name, method_name = None, method_type=0):
  339. ##@brief The targeted LeObject child class
  340. self._comp_name = component_name
  341. ##@brief The method name
  342. self._method_name = method_name
  343. ##@brief The function (that will be the injected method)
  344. self._fun = None
  345. ##@brief Stores the type of method (instance, class or static)
  346. self._type = int(method_type)
  347. if self._type not in (self.INSTANCE_METHOD, self.CLASS_METHOD,\
  348. self.STATIC_METHOD):
  349. raise ValueError("Excepted value for method_type was one of \
  350. CustomMethod::INSTANCE_METHOD CustomMethod::CLASS_METHOD or \
  351. CustomMethod::STATIC_METHOD, but got %s" % self._type)
  352. ##@brief called just after __init__
  353. #@param fun function : the decorated function
  354. #@param return the function
  355. def __call__(self, fun):
  356. if self._method_name is None:
  357. self._method_name = fun.__name__
  358. if self._comp_name not in self._custom_methods:
  359. self._custom_methods[self._comp_name] = list()
  360. if self._method_name in [ scm._method_name for scm in self._custom_methods[self._comp_name]]:
  361. raise RuntimeError("A method named %s allready registered by \
  362. another plugin : %s" % (
  363. self._method_name,
  364. self._custom_methods[self._comp_name].__module__))
  365. self._fun = fun
  366. self._custom_methods[self._comp_name].append(self)
  367. ##@brief Textual representation
  368. #@return textual representation of the CustomMethod instance
  369. def __repr__(self):
  370. res = "<CustomMethod name={method_name} target={classname} \
  371. source={module_name}.{fun_name}>"
  372. return res.format(
  373. method_name = self._method_name,
  374. classname = self._comp_name,
  375. module_name = self._fun.__module__,
  376. fun_name = self._fun.__name__)
  377. ##@brief Return a well formed method
  378. #
  379. #@note the type of method depends on the _type attribute
  380. #@return a method directly injectable in the target class
  381. def __get_method(self):
  382. if self._type == self.INSTANCE_METHOD:
  383. def custom__get__(self, obj, objtype = None):
  384. return types.MethodType(self, obj, objtype)
  385. setattr(self._fun, '__get__', custom__get__)
  386. return self._fun
  387. elif self._type == self.CLASS_METHOD:
  388. return classmethod(self._fun)
  389. elif self._type == self.STATIC_METHOD:
  390. return staticmethod(self._fun)
  391. else:
  392. raise RuntimeError("Attribute _type is not one of \
  393. CustomMethod::INSTANCE_METHOD CustomMethod::CLASS_METHOD \
  394. CustomMethod::STATIC_METHOD")
  395. ##@brief Handle custom method dynamic injection in LeAPI dyncode
  396. #
  397. #Called by lodel2_dyncode_loaded hook defined at
  398. #lodel.plugin.core_hooks.lodel2_plugin_custom_methods()
  399. #
  400. #@param cls
  401. #@param dynclasses LeObject child classes : List of dynamically generated
  402. #LeObject child classes
  403. @classmethod
  404. def set_registered(cls, dynclasses):
  405. from lodel import logger
  406. dyn_cls_dict = { dc.__name__:dc for dc in dynclasses}
  407. for cls_name, custom_methods in cls._custom_methods.items():
  408. for custom_method in custom_methods:
  409. if cls_name not in dyn_cls_dict:
  410. logger.error("Custom method %s adding fails : No dynamic \
  411. LeAPI objects named %s." % (custom_method, cls_name))
  412. elif custom_method._method_name in dir(dyn_cls_dict[cls_name]):
  413. logger.warning("Overriding existing method '%s' on target \
  414. with %s" % (custom_method._method_name, custom_method))
  415. else:
  416. setattr(
  417. dyn_cls_dict[cls_name],
  418. custom_method._method_name,
  419. custom_method.__get_method())
  420. logger.debug(
  421. "Custom method %s added to target" % custom_method)
  422. ##@page lodel2_plugins Lodel2 plugins system
  423. #
  424. # @par Plugin structure
  425. #A plugin is a package (a folder containing, at least, an __init__.py file.
  426. #This file should expose multiple things :
  427. # - a CONFSPEC variable containing configuration specifications
  428. # - an _activate() method that returns True if the plugin can be activated (
  429. # optionnal)
  430. #