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 35KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953
  1. #-*- coding: utf-8 -*-
  2. import sys
  3. import os.path
  4. import importlib
  5. import copy
  6. import json
  7. from importlib.machinery import SourceFileLoader
  8. from lodel.context import LodelContext
  9. LodelContext.expose_modules(globals(), {
  10. 'lodel.logger': 'logger',
  11. 'lodel.settings.utils': ['SettingsError'],
  12. 'lodel.plugin.hooks': ['LodelHook'],
  13. 'lodel.plugin.exceptions': ['PluginError', 'PluginVersionError',
  14. 'PluginTypeError', 'LodelScriptError', 'DatasourcePluginError'],
  15. 'lodel.exceptions': ['LodelException', 'LodelExceptions',
  16. 'LodelFatalError', 'DataNoneValid', 'FieldValidationError']})
  17. #  @package lodel.plugins Lodel2 plugins management
  18. #@ingroup lodel2_plugins
  19. #
  20. # Lodel2 plugins are stored in directories
  21. # A typical lodel2 plugin directory structure looks like :
  22. # - {{__init__.py}}} containing informations like full_name, authors, licence etc.
  23. # - main.py containing hooks registration etc
  24. # - confspec.py containing a configuration specification dictionary named CONFSPEC
  25. #
  26. # All plugins are expected to be found in the lodel package (lodel.plugins)
  27. #
  28. # @defgroup plugin_init_specs Plugins __init__.py specifications
  29. #@ingroup lodel2_plugins
  30. #@{
  31. # @brief The package in which we will load plugins modules
  32. VIRTUAL_PACKAGE_NAME = 'lodel.plugins'
  33. # @brief The temporary package to import python sources
  34. VIRTUAL_TEMP_PACKAGE_NAME = 'lodel.plugin_tmp'
  35. # @brief Plugin init filename
  36. INIT_FILENAME = '__init__.py' # Loaded with settings
  37. # @brief Name of the variable containing the plugin name
  38. PLUGIN_NAME_VARNAME = '__plugin_name__'
  39. # @brief Name of the variable containing the plugin type
  40. PLUGIN_TYPE_VARNAME = '__plugin_type__'
  41. # @brief Name of the variable containing the plugin version
  42. PLUGIN_VERSION_VARNAME = '__version__'
  43. # @brief Name of the variable containing the confpsec filename
  44. CONFSPEC_FILENAME_VARNAME = '__confspec__'
  45. # @brief Name of the variable containing the confspecs
  46. CONFSPEC_VARNAME = 'CONFSPEC'
  47. # @brief Name of the variable containing the loader filename
  48. LOADER_FILENAME_VARNAME = '__loader__'
  49. # @brief Name of the variable containing the plugin dependencies
  50. PLUGIN_DEPS_VARNAME = '__plugin_deps__'
  51. # @brief Name of the optionnal activate method
  52. ACTIVATE_METHOD_NAME = '_activate'
  53. # @brief Default & failover value for plugins path list
  54. PLUGINS_PATH = os.path.join(LodelContext.context_dir(), 'plugins')
  55. # @brief List storing the mandatory variables expected in a plugin __init__.py
  56. # file
  57. MANDATORY_VARNAMES = [PLUGIN_NAME_VARNAME, LOADER_FILENAME_VARNAME,
  58. PLUGIN_VERSION_VARNAME]
  59. # @brief Default plugin type
  60. DEFAULT_PLUGIN_TYPE = 'extension' # Value found in lodel/plugin/extensions.py::Extensions._type_conf_name
  61. # @}
  62. # @brief Describe and handle version numbers
  63. #@ingroup lodel2_plugins
  64. #
  65. # A version number can be represented by a string like MAJOR.MINOR.PATCH
  66. # or by a list [MAJOR, MINOR,PATCH ].
  67. #
  68. # The class implements basics comparison function and string repr
  69. class PluginVersion(object):
  70. PROPERTY_LIST = ['major', 'minor', 'revision']
  71. # @brief Version constructor
  72. #@param *args : You can either give a str that will be splitted on . or you
  73. # can give a iterable containing 3 integers or 3 arguments representing
  74. # major, minor and revision version
  75. def __init__(self, *args):
  76. self.__version = [0 for _ in range(3)]
  77. if len(args) == 1:
  78. arg = args[0]
  79. if isinstance(arg, str):
  80. # Casting from string to version numbers
  81. spl = arg.split('.')
  82. invalid = False
  83. if len(spl) > 3:
  84. raise PluginVersionError("The string '%s' is not a valid plugin \
  85. version number" % arg)
  86. if len(spl) < 3:
  87. spl += [0 for _ in range(3 - len(spl))]
  88. try:
  89. self.__version = [int(s) for s in spl]
  90. except (ValueError, TypeError):
  91. raise PluginVersionError("The string '%s' is not a valid lodel2 \
  92. plugin version number" % arg)
  93. else:
  94. try:
  95. if len(arg) >= 1:
  96. if len(arg) > 3:
  97. raise PluginVersionError("Expected maximum 3 value to \
  98. create a plugin version number but found '%s' as argument" % arg)
  99. for i, v in enumerate(arg):
  100. self.__version[i] = int(arg[i])
  101. except (TypeError, ValueError):
  102. raise PluginVersionError("Unable to convert argument into plugin \
  103. version number" % arg)
  104. elif len(args) > 3:
  105. raise PluginError("Expected between 1 and 3 positional arguments \
  106. but %d arguments found" % len(args))
  107. else:
  108. for i, v in enumerate(args):
  109. self.__version[i] = int(v)
  110. # Checks that version numbering is correct
  111. for v in self.__version:
  112. if v < 0:
  113. raise PluginVersionError("No negative version number allowed !")
  114. # @brief Property to access major version number
  115. @property
  116. def major(self):
  117. return self.__version[0]
  118. # @brief Property to access minor version number
  119. @property
  120. def minor(self):
  121. return self.__version[1]
  122. # @brief Property to access patch version number
  123. @property
  124. def revision(self):
  125. return self.__version[2]
  126. # @brief Check and prepare comparison argument
  127. #@return A PluginVersion instance
  128. #@throw PluginError if invalid argument provided
  129. def __cmp_check(self, other):
  130. if not isinstance(other, PluginVersion):
  131. try:
  132. if len(other) <= 3 and len(other) > 0:
  133. return PluginVersion(other)
  134. except TypeError:
  135. raise PluginError("Cannot compare argument '%s' with \
  136. a PluginVersion instance" % other)
  137. return other
  138. # @brief Allow accessing to version parts using integer index
  139. #@param key int : index
  140. #@return major for key == 0, minor for key == 1, revision for key == 2
  141. def __getitem__(self, key):
  142. try:
  143. key = int(key)
  144. except (ValueError, TypeError):
  145. raise ValueError("Expected an int as key")
  146. if key < 0 or key > 2:
  147. raise ValueError("Key is expected to be in [0..2]")
  148. return self.__version[key]
  149. def __lt__(self, other):
  150. for i in range(3):
  151. if self[i] < other[i]:
  152. return True
  153. elif self[i] > other[i]:
  154. return False
  155. return False
  156. def __eq__(self, other):
  157. for i in range(3):
  158. if self[i] != other[i]:
  159. return False
  160. return True
  161. def __gt__(self, other):
  162. for i in range(3):
  163. if self[i] > other[i]:
  164. return True
  165. elif self[i] < other[i]:
  166. return False
  167. return False
  168. def __le__(self, other):
  169. return self < other or self == other
  170. def __ne__(self, other):
  171. return not(self == other)
  172. def __ge__(self, other):
  173. return self > other or self == other
  174. def __str__(self):
  175. return '%d.%d.%d' % tuple(self.__version)
  176. def __repr__(self):
  177. return "{'major': %d, 'minor': %d, 'revision': %d}" % tuple(
  178. self.__version)
  179. # @brief Plugin metaclass that allows to "catch" child class declaration
  180. #@ingroup lodel2_plugins
  181. #
  182. # Automatic script registration on child class declaration
  183. class MetaPlugType(type):
  184. # @brief Dict storing all plugin types
  185. #
  186. # key is the _type_conf_name and value is the class
  187. _all_ptypes = dict()
  188. # @brief type constructor reimplementation
  189. def __init__(self, name, bases, attrs):
  190. # Here we can store all child classes of Plugin
  191. super().__init__(name, bases, attrs)
  192. if len(bases) == 1 and bases[0] == object:
  193. return
  194. # Regitering a new plugin type
  195. MetaPlugType._all_ptypes[self._type_conf_name] = self
  196. # @brief Accessor to the list of plugin types
  197. #@return A copy of _all_ptypes attribute (a dict with typename as key
  198. # and the class as value)
  199. @classmethod
  200. def all_types(cls):
  201. return copy.copy(cls._all_ptypes)
  202. # @brief Accessor to the list of plugin names
  203. #@return a list of all plugin names
  204. @classmethod
  205. def all_ptype_names(cls):
  206. return list(cls._all_ptypes.keys())
  207. # @brief Given a plugin type name returns a Plugin child class
  208. #@param ptype_name str : a plugin type name
  209. #@return A Plugin child class
  210. #@throw PluginError if ptype_name is not an exsiting plugin type name
  211. @classmethod
  212. def type_from_name(cls, ptype_name):
  213. if ptype_name not in cls._all_ptypes:
  214. raise PluginError("Unknown plugin type '%s'" % ptype_name)
  215. return cls._all_ptypes[ptype_name]
  216. # @brief Call the clear classmethod on each child classes
  217. @classmethod
  218. def clear_cls(cls):
  219. for pcls in cls._all_ptypes.values():
  220. pcls.clear_cls()
  221. # @brief Handle plugins
  222. #@ingroup lodel2_plugins
  223. #
  224. # An instance represent a loaded plugin. Class methods allow to load/preload
  225. # plugins.
  226. #
  227. # Typicall Plugins load sequence is :
  228. # 1. Settings call start method to instanciate all plugins found in confs
  229. # 2. Settings fetch all confspecs
  230. # 3. the loader call load_all to register hooks etc
  231. class Plugin(object, metaclass=MetaPlugType):
  232. # @brief Stores Plugin instances indexed by name
  233. _plugin_instances = dict()
  234. # @brief Attribute used by load_all and load methods to detect circular
  235. # dependencies
  236. _load_called = []
  237. # @brief Attribute that stores plugins list from discover cache file
  238. _plugin_list = None
  239. #@brief Designed to store, in child classes, the confspec indicating \
  240. # where plugin list is stored
  241. _plist_confspecs = None
  242. # @brief The name of the plugin type in the confguration
  243. #
  244. # None in abstract classes and implemented by child classes
  245. _type_conf_name = None
  246. # @brief Stores virtual modules uniq key
  247. #@note When testing if a dir contains a plugin, if we reimport the __init__
  248. # in a module with the same name, all non existing value (plugin_type for
  249. # example) are replaced by previous plugin values
  250. _mod_cnt = 0
  251. # @brief Plugin class constructor
  252. #
  253. # Called by setting in early stage of lodel2 boot sequence using classmethod
  254. # register
  255. #
  256. # @param plugin_name str : plugin name
  257. # @throw PluginError
  258. def __init__(self, plugin_name):
  259. # @brief The plugin name
  260. self.name = plugin_name
  261. # @brief The plugin package path
  262. self.path = self.plugin_path(plugin_name)
  263. # @brief Stores the plugin module
  264. self.module = None
  265. # @brief Stores the plugin loader module
  266. self.__loader_module = None
  267. # @brief The plugin confspecs
  268. self.__confspecs = dict()
  269. # @brief Boolean flag telling if the plugin is loaded or not
  270. self.loaded = False
  271. # Importing __init__.py infos in it
  272. plugin_module = self.module_name()
  273. self.module = LodelContext.module(plugin_module)
  274. # loading confspecs
  275. try:
  276. # Loading confspec directly from __init__.py
  277. self.__confspecs = getattr(self.module, CONFSPEC_VARNAME)
  278. except AttributeError:
  279. # Loading file in __confspec__ var in __init__.py
  280. try:
  281. module = self._import_from_init_var(CONFSPEC_FILENAME_VARNAME)
  282. except AttributeError:
  283. msg = "Malformed plugin {plugin} . No {varname} not {filevar} found in __init__.py"
  284. msg = msg.format(
  285. plugin=self.name,
  286. varname=CONFSPEC_VARNAME,
  287. filevar=CONFSPEC_FILENAME_VARNAME)
  288. raise PluginError(msg)
  289. except ImportError as e:
  290. msg = "Broken plugin {plugin} : {expt}"
  291. msg = msg.format(
  292. plugin=self.name,
  293. expt=str(e))
  294. raise PluginError(msg)
  295. except Exception as e:
  296. msg = "Plugin '%s' :" + str(e)
  297. raise e.__class__(msg)
  298. try:
  299. # loading confpsecs from file
  300. self.__confspecs = getattr(module, CONFSPEC_VARNAME)
  301. except AttributeError:
  302. msg = "Broken plugin. {varname} not found in '{filename}'"
  303. msg = msg.format(
  304. varname=CONFSPEC_VARNAME,
  305. filename=confspec_filename)
  306. raise PluginError(msg)
  307. # loading plugin version
  308. try:
  309. # this try block should be useless. The existance of
  310. # PLUGIN_VERSION_VARNAME in init file is mandatory
  311. self.__version = getattr(self.module, PLUGIN_VERSION_VARNAME)
  312. except AttributeError:
  313. msg = "Error that should not append while loading plugin '%s': no \
  314. %s found in plugin init file. Malformed plugin"
  315. msg %= (plugin_name, PLUGIN_VERSION_VARNAME)
  316. raise LodelFatalError(msg)
  317. # Load plugin type
  318. try:
  319. self.__type = getattr(self.module, PLUGIN_TYPE_VARNAME)
  320. except AttributeError:
  321. self.__type = DEFAULT_PLUGIN_TYPE
  322. self.__type = str(self.__type).lower()
  323. if self.__type not in MetaPlugType.all_ptype_names():
  324. raise PluginError("Unknown plugin type '%s'" % self.__type)
  325. # Load plugin name from init file (just for checking)
  326. try:
  327. # this try block should be useless. The existance of
  328. # PLUGIN_NAME_VARNAME in init file is mandatory
  329. pname = getattr(self.module, PLUGIN_NAME_VARNAME)
  330. except AttributeError:
  331. msg = "Error that should not append : no %s found in plugin \
  332. init file. Malformed plugin"
  333. msg %= PLUGIN_NAME_VARNAME
  334. raise LodelFatalError(msg)
  335. if pname != plugin_name:
  336. msg = "Plugin's discover cache inconsistency detected ! Cached \
  337. name differ from the one found in plugin's init file"
  338. raise PluginError(msg)
  339. # @brief Try to import a file from a variable in __init__.py
  340. #@param varname str : The variable name
  341. #@return loaded module
  342. #@throw AttributeError if varname not found
  343. #@throw ImportError if the file fails to be imported
  344. #@throw PluginError if the filename was not valid
  345. #@todo Some strange things append :
  346. # when loading modules in test self.module.__name__ does not contains
  347. # the package... but in prod cases the self.module.__name__ is
  348. # the module fullname... Just a reminder note to explain the dirty
  349. # if on self_modname
  350. def _import_from_init_var(self, varname):
  351. # Read varname
  352. try:
  353. filename = getattr(self.module, varname)
  354. except AttributeError:
  355. msg = "Malformed plugin {plugin}. No {varname} found in __init__.py"
  356. msg = msg.format(
  357. plugin=self.name,
  358. varname=LOADER_FILENAME_VARNAME)
  359. raise PluginError(msg)
  360. # Path are not allowed
  361. if filename != os.path.basename(filename):
  362. msg = "Invalid {varname} content : '{fname}' for plugin {name}"
  363. msg = msg.format(
  364. varname=varname,
  365. fname=filename,
  366. name=self.name)
  367. raise PluginError(msg)
  368. # See the todo
  369. if len(self.module.__name__.split('.')) == 1:
  370. self_modname = self.module.__package__
  371. else:
  372. self_modname = self.module.__name__
  373. # extract module name from filename
  374. base_mod = '.'.join(filename.split('.')[:-1])
  375. module_name = self_modname + "." + base_mod
  376. return importlib.import_module(module_name)
  377. # @brief Return associated module name
  378. def module_name(self):
  379. path_array = self.path.split('/')
  380. if 'plugins' not in self.path:
  381. raise PluginError("Bad path for plugin %s : %s" % (
  382. self.name, self.path))
  383. return '.'.join(['lodel'] + path_array[path_array.index('plugins'):])
  384. # @brief Check dependencies of plugin
  385. #@return A list of plugin name to be loaded before
  386. def check_deps(self):
  387. try:
  388. res = getattr(self.module, PLUGIN_DEPS_VARNAME)
  389. except AttributeError:
  390. return list()
  391. result = list()
  392. errors = list()
  393. for plugin_name in res:
  394. try:
  395. result.append(self.get(plugin_name))
  396. except PluginError:
  397. errors.append(plugin_name)
  398. if len(errors) > 0:
  399. raise PluginError("Bad dependencie for '%s' :" % self.name,
  400. ', '.join(errors))
  401. return result
  402. # @brief Check if the plugin should be activated
  403. #
  404. # Try to fetch a function called @ref ACTIVATE_METHOD_NAME in __init__.py
  405. # of a plugin. If none found assert that the plugin can be loaded, else
  406. # the method is called. If it returns anything else that True, the plugin
  407. # is noted as not activable
  408. #
  409. # @note Maybe we have to exit everything if a plugin cannot be loaded...
  410. def activable(self):
  411. try:
  412. test_fun = getattr(self.module, ACTIVATE_METHOD_NAME)
  413. except AttributeError:
  414. msg = "No %s method found for plugin %s. Assuming plugin is ready to be loaded"
  415. msg %= (ACTIVATE_METHOD_NAME, self.name)
  416. logger.debug(msg)
  417. test_fun = lambda: True
  418. return test_fun()
  419. # @brief Load a plugin
  420. #
  421. # Loading a plugin means importing a file. The filename is defined in the
  422. # plugin's __init__.py file in a LOADER_FILENAME_VARNAME variable.
  423. #
  424. # The loading process has to take care of other things :
  425. #- loading dependencies (other plugins)
  426. #- check that the plugin can be activated using Plugin.activate() method
  427. #- avoid circular dependencies infinite loop
  428. def _load(self):
  429. if self.loaded:
  430. return
  431. # Test that plugin "wants" to be activated
  432. activable = self.activable()
  433. if not(activable is True):
  434. msg = "Plugin %s is not activable : %s"
  435. msg %= (self.name, activable)
  436. raise PluginError(msg)
  437. # Circular dependencie detection
  438. if self.name in self._load_called:
  439. raise PluginError("Circular dependencie in Plugin detected. Abording")
  440. else:
  441. self._load_called.append(self.name)
  442. # Dependencie load
  443. for dependencie in self.check_deps():
  444. activable = dependencie.activable()
  445. if activable is True:
  446. dependencie._load()
  447. else:
  448. msg = "Plugin {plugin_name} not activable because it depends on plugin {dep_name} that is not activable : {reason}"
  449. msg = msg.format(
  450. plugin_name=self.name,
  451. dep_name=dependencie.name,
  452. reason=activable)
  453. # Loading the plugin
  454. try:
  455. self.__loader_module = self._import_from_init_var(LOADER_FILENAME_VARNAME)
  456. except PluginError as e:
  457. raise e
  458. except ImportError as e:
  459. msg = "Broken plugin {plugin} : {expt}"
  460. msg = msg.format(
  461. plugin=self.name,
  462. expt=str(e))
  463. raise PluginError(msg)
  464. logger.debug("Plugin '%s' loaded" % self.name)
  465. self.loaded = True
  466. # @brief Returns the loader module
  467. #
  468. # Accessor for the __loader__ python module
  469. def loader_module(self):
  470. if not self.loaded:
  471. raise RuntimeError("Plugin %s not loaded yet." % self.name)
  472. return self.__loader_module
  473. def __str__(self):
  474. return "<LodelPlugin '%s' version %s>" % (self.name, self.__version)
  475. # @brief Call load method on every pre-loaded plugins
  476. #
  477. # Called by loader to trigger hooks registration.
  478. # This method have to avoid circular dependencies infinite loops. For this
  479. # purpose a class attribute _load_called exists.
  480. # @throw PluginError
  481. @classmethod
  482. def load_all(cls):
  483. errors = dict()
  484. cls._load_called = []
  485. for name, plugin in cls._plugin_instances.items():
  486. try:
  487. plugin._load()
  488. except PluginError as e:
  489. errors[name] = e
  490. if len(errors) > 0:
  491. msg = "Errors while loading plugins :"
  492. for name, e in errors.items():
  493. msg += "\n\t%20s : %s" % (name, e)
  494. msg += "\n"
  495. raise PluginError(msg)
  496. LodelHook.call_hook(
  497. "lodel2_plugins_loaded", cls, cls._plugin_instances)
  498. # @return a copy of __confspecs attr
  499. @property
  500. def confspecs(self):
  501. return copy.copy(self.__confspecs)
  502. # @brief Accessor to confspec indicating where we can find the plugin list
  503. #@note Abtract method implemented only for Plugin child classes
  504. # This attribute indicate where we fetch the plugin list.
  505. @classmethod
  506. def plist_confspecs(cls):
  507. if cls._plist_confspecs is None:
  508. raise LodelFatalError('Unitialized _plist_confspecs attribute for \
  509. %s' % cls.__name__)
  510. return copy.copy(cls._plist_confspecs)
  511. # @brief Retrieves plugin list confspecs
  512. #
  513. # This method ask for each Plugin child class the confspecs specifying where
  514. # the wanted plugin list is stored. (For example DatasourcePlugin expect
  515. # that a list of ds plugin to load stored in lodel2 section, datasources key
  516. # etc...
  517. @classmethod
  518. def plugin_list_confspec(cls):
  519. LodelContext.expose_modules(globals(), {
  520. 'lodel.settings.validator': ['confspec_append']})
  521. res = dict()
  522. for pcls in cls.plugin_types():
  523. plcs = pcls.plist_confspec()
  524. confspec_append(res, plcs)
  525. return res
  526. # @brief Register a new plugin
  527. #
  528. #@param plugin_name str : The plugin name
  529. #@return a Plugin instance
  530. #@throw PluginError
  531. @classmethod
  532. def register(cls, plugin_name):
  533. if plugin_name in cls._plugin_instances:
  534. msg = "Plugin allready registered with same name %s"
  535. msg %= plugin_name
  536. raise PluginError(msg)
  537. # Here we check that previous discover found a plugin with that name
  538. pdcache = cls.discover()
  539. if plugin_name not in pdcache:
  540. raise PluginError("No plugin named '%s' found" % plugin_name)
  541. ptype = pdcache[plugin_name]['type']
  542. pcls = MetaPlugType.type_from_name(ptype)
  543. plugin = pcls(plugin_name)
  544. cls._plugin_instances[plugin_name] = plugin
  545. logger.debug("Plugin %s available." % plugin)
  546. return plugin
  547. # @brief Plugins instances accessor
  548. #
  549. #@param plugin_name str: The plugin name
  550. #@return a Plugin instance
  551. #@throw PluginError if plugin not found
  552. @classmethod
  553. def get(cls, plugin_name):
  554. try:
  555. return cls._plugin_instances[plugin_name]
  556. except KeyError:
  557. msg = "No plugin named '%s' loaded"
  558. msg %= plugin_name
  559. raise PluginError(msg)
  560. # @brief Given a plugin name returns the plugin path
  561. # @param plugin_name str : The plugin name
  562. # @return the plugin directory path
  563. @classmethod
  564. def plugin_path(cls, plugin_name):
  565. plist = cls.plugin_list()
  566. if plugin_name not in plist:
  567. raise PluginError("No plugin named '%s' found" % plugin_name)
  568. try:
  569. return cls.get(plugin_name).path
  570. except PluginError:
  571. pass
  572. return plist[plugin_name]['path']
  573. # @brief Return the plugin module name
  574. #
  575. # This module name is the "virtual" module where we imported the plugin.
  576. #
  577. # Typically composed like VIRTUAL_PACKAGE_NAME.PLUGIN_NAME
  578. #@warning Brokes subdire feature
  579. #@param plugin_name str : a plugin name
  580. #@return a string representing a module name
  581. #@todo fix broken subdir capabilitie ( @see module_name() )
  582. #@todo check if used, else delete it
  583. @classmethod
  584. def plugin_module_name(cls, plugin_name):
  585. return "%s.%s" % (VIRTUAL_PACKAGE_NAME, plugin_name)
  586. # @brief Start the Plugin class
  587. #
  588. # Called by Settings.__bootstrap()
  589. #
  590. # This method load path and preload plugins
  591. @classmethod
  592. def start(cls, plugins):
  593. for plugin_name in plugins:
  594. cls.register(plugin_name)
  595. # @brief Attempt to "restart" the Plugin class
  596. @classmethod
  597. def clear(cls):
  598. if cls._plugin_instances != dict():
  599. cls._plugin_instances = dict()
  600. if cls._load_called != []:
  601. cls._load_called = []
  602. MetaPlugType.clear_cls()
  603. # @brief Designed to be implemented by child classes
  604. @classmethod
  605. def clear_cls(cls):
  606. pass
  607. # @brief Reccursively walk throught paths to find plugin, then stores
  608. # found plugin in a static var
  609. #
  610. # Found plugins are stored in cls._plugin_list
  611. #@note The discover is run only if no cached datas are found
  612. #@return a list of dict with plugin infos { see @ref _discover }
  613. #@todo add max_depth and no symlink following feature
  614. @classmethod
  615. def discover(cls):
  616. if cls._plugin_list is not None:
  617. return cls._plugin_list
  618. logger.info("Running plugin discover")
  619. tmp_res = cls._discover(PLUGINS_PATH)
  620. # Formating and dedoubloning result
  621. result = dict()
  622. for pinfos in tmp_res:
  623. pname = pinfos['name']
  624. if (pname in result
  625. and pinfos['version'] > result[pname]['version'])\
  626. or pname not in result:
  627. result[pname] = pinfos
  628. else:
  629. # dropped
  630. pass
  631. cls._plugin_list = result
  632. return result
  633. # @brief Return discover result
  634. #@param refresh bool : if true invalidate all plugin list cache
  635. #@note If discover cache file not found run discover first
  636. #@note if refresh is set to True discover MUST have been run at least
  637. # one time. In fact refresh action load the list of path to explore
  638. # from the plugin's discover cache
  639. @classmethod
  640. def plugin_list(cls, refresh=False):
  641. return cls._plugin_list
  642. # @brief Return a list of child Class Plugin
  643. @classmethod
  644. def plugin_types(cls):
  645. return MetaPlugType.all_types()
  646. # @brief Check if a directory is a plugin module
  647. #@param path str : path to check
  648. #@param assert_in_package bool : if False didn't check that path is
  649. # a subdir of PLUGINS_PATH
  650. #@return a dict with name, version and path if path is a plugin module, else False
  651. @classmethod
  652. def dir_is_plugin(cls, path, assert_in_package=True):
  653. log_msg = "%s is not a plugin directory because : " % path
  654. if assert_in_package:
  655. # Check that path is a subdir of PLUGINS_PATH
  656. abspath = os.path.abspath(path)
  657. if not abspath.startswith(os.path.abspath(PLUGINS_PATH)):
  658. raise PluginError(
  659. "%s is not a subdir of %s" % log_msg, PLUGINS_PATH)
  660. # Checks that path exists
  661. if not os.path.isdir(path):
  662. raise ValueError(
  663. "Expected path to be a directory, but '%s' found" % path)
  664. # Checks that path contains plugin's init file
  665. initfile = os.path.join(path, INIT_FILENAME)
  666. if not os.path.isfile(initfile):
  667. log_msg += "'%s' not found" % (INIT_FILENAME)
  668. logger.debug(log_msg)
  669. return False
  670. # Importing plugin's init file to check contained datas
  671. try:
  672. initmod, modname = cls.import_init(path)
  673. except PluginError as e:
  674. log_msg += "unable to load '%s'. Exception raised : %s"
  675. log_msg %= (INIT_FILENAME, e)
  676. logger.debug(log_msg)
  677. return False
  678. # Checking mandatory init module variables
  679. for attr_name in MANDATORY_VARNAMES:
  680. if not hasattr(initmod, attr_name):
  681. log_msg += " mandatory variable '%s' not found in '%s'"
  682. log_msg %= (attr_name, INIT_FILENAME)
  683. logger.debug(log_msg)
  684. return False
  685. # Fetching plugin's version
  686. try:
  687. pversion = getattr(initmod, PLUGIN_VERSION_VARNAME)
  688. except (NameError, AttributeError) as e:
  689. msg = "Invalid plugin version found in %s : %s"
  690. msg %= (path, e)
  691. raise PluginError(msg)
  692. # Fetching plugin's type
  693. try:
  694. ptype = getattr(initmod, PLUGIN_TYPE_VARNAME)
  695. except (NameError, AttributeError) as e:
  696. ptype = DEFAULT_PLUGIN_TYPE
  697. pname = getattr(initmod, PLUGIN_NAME_VARNAME)
  698. return {'name': pname,
  699. 'version': PluginVersion(pversion),
  700. 'path': path,
  701. 'type': ptype}
  702. # @brief Import init file from a plugin path
  703. #@param path str : Directory path
  704. #@return a tuple (init_module, module_name)
  705. #@todo replace by LodelContext usage !!! (not mandatory, this fun
  706. # is only used in plugin discover method)
  707. @classmethod
  708. def import_init(cls, path):
  709. cls._mod_cnt += 1 # in order to ensure module name unicity
  710. init_source = os.path.join(path, INIT_FILENAME)
  711. temp_module = '%s.%s.%s%d' % (
  712. VIRTUAL_TEMP_PACKAGE_NAME, os.path.basename(os.path.dirname(path)),
  713. 'test_init', cls._mod_cnt)
  714. try:
  715. loader = SourceFileLoader(temp_module, init_source)
  716. except (ImportError, FileNotFoundError) as e:
  717. raise PluginError("Unable to import init file from '%s' : %s" % (
  718. temp_module, e))
  719. try:
  720. res_module = loader.load_module()
  721. except Exception as e:
  722. raise PluginError("Unable to import initfile : %s" % e)
  723. return (res_module, temp_module)
  724. @classmethod
  725. def debug_wrapper(cls, updglob=None):
  726. if updglob is not None:
  727. for k, v in updglob.items():
  728. globals()[k] = v
  729. print(logger)
  730. # @brief Reccursiv plugin discover given a path
  731. #@param path str : the path to walk through
  732. #@return A dict with plugin_name as key and {'path':..., 'version':...} as value
  733. @classmethod
  734. def _discover(cls, path):
  735. # Ensure plugins symlink creation
  736. LodelContext.expose_modules(globals(), {
  737. 'lodel.plugins': 'plugins'})
  738. res = []
  739. to_explore = [path]
  740. while len(to_explore) > 0:
  741. cur_path = to_explore.pop()
  742. for f in os.listdir(cur_path):
  743. f_path = os.path.join(cur_path, f)
  744. if f not in ['.', '..'] and os.path.isdir(f_path):
  745. # Check if it is a plugin directory
  746. test_result = cls.dir_is_plugin(f_path)
  747. if not (test_result is False):
  748. logger.info("Plugin '%s' found in %s" % (
  749. test_result['name'], f_path))
  750. res.append(test_result)
  751. else:
  752. to_explore.append(f_path)
  753. return res
  754. def debug_wrapper_mod():
  755. print("MOD : ", logger)
  756. # @brief Decorator class designed to allow plugins to add custom methods
  757. # to LeObject childs (dyncode objects)
  758. #@ingroup lodel2_plugins
  759. #
  760. class CustomMethod(object):
  761. # @brief Stores registered custom methods
  762. #
  763. # Key = LeObject child class name
  764. # Value = CustomMethod instance
  765. _custom_methods = dict()
  766. INSTANCE_METHOD = 0
  767. CLASS_METHOD = 1
  768. STATIC_METHOD = 2
  769. # @brief Decorator constructor
  770. #@param component_name str : the name of the component to enhance
  771. #@param method_name str : the name of the method to inject (if None given
  772. #@param method_type int : take value in one of
  773. # CustomMethod::INSTANCE_METHOD CustomMethod::CLASS_METHOD or
  774. # CustomMethod::STATIC_METHOD
  775. # use the function name
  776. def __init__(self, component_name, method_name=None, method_type=0):
  777. # @brief The targeted LeObject child class
  778. self._comp_name = component_name
  779. # @brief The method name
  780. self._method_name = method_name
  781. # @brief The function (that will be the injected method)
  782. self._fun = None
  783. # @brief Stores the type of method (instance, class or static)
  784. self._type = int(method_type)
  785. if self._type not in (self.INSTANCE_METHOD, self.CLASS_METHOD,
  786. self.STATIC_METHOD):
  787. raise ValueError("Excepted value for method_type was one of \
  788. CustomMethod::INSTANCE_METHOD CustomMethod::CLASS_METHOD or \
  789. CustomMethod::STATIC_METHOD, but got %s" % self._type)
  790. # @brief called just after __init__
  791. #@param fun function : the decorated function
  792. def __call__(self, fun):
  793. if self._method_name is None:
  794. self._method_name = fun.__name__
  795. if self._comp_name not in self._custom_methods:
  796. self._custom_methods[self._comp_name] = list()
  797. if self._method_name in [scm._method_name for scm in self._custom_methods[self._comp_name]]:
  798. raise RuntimeError("A method named %s allready registered by \
  799. another plugin : %s" % (
  800. self._method_name,
  801. self._custom_methods[self._comp_name].__module__))
  802. self._fun = fun
  803. self._custom_methods[self._comp_name].append(self)
  804. # @brief Textual representation
  805. #@return textual representation of the CustomMethod instance
  806. def __repr__(self):
  807. res = "<CustomMethod name={method_name} target={classname} \
  808. source={module_name}.{fun_name}>"
  809. return res.format(
  810. method_name=self._method_name,
  811. classname=self._comp_name,
  812. module_name=self._fun.__module__,
  813. fun_name=self._fun.__name__)
  814. # @brief Return a well formed method
  815. #
  816. #@note the type of method depends on the _type attribute
  817. #@return a method directly injectable in the target class
  818. def __get_method(self):
  819. if self._type == self.INSTANCE_METHOD:
  820. def custom__get__(self, obj, objtype=None):
  821. return types.MethodType(self, obj, objtype)
  822. setattr(self._fun, '__get__', custom__get__)
  823. return self._fun
  824. elif self._type == self.CLASS_METHOD:
  825. return classmethod(self._fun)
  826. elif self._type == self.STATIC_METHOD:
  827. return staticmethod(self._fun)
  828. else:
  829. raise RuntimeError("Attribute _type is not one of \
  830. CustomMethod::INSTANCE_METHOD CustomMethod::CLASS_METHOD \
  831. CustomMethod::STATIC_METHOD")
  832. # @brief Handle custom method dynamic injection in LeAPI dyncode
  833. #
  834. # Called by lodel2_dyncode_loaded hook defined at
  835. # lodel.plugin.core_hooks.lodel2_plugin_custom_methods()
  836. #
  837. #@param cls
  838. #@param dynclasses LeObject child classes : List of dynamically generated
  839. # LeObject child classes
  840. @classmethod
  841. def set_registered(cls, dynclasses):
  842. from lodel import logger
  843. dyn_cls_dict = {dc.__name__: dc for dc in dynclasses}
  844. for cls_name, custom_methods in cls._custom_methods.items():
  845. for custom_method in custom_methods:
  846. if cls_name not in dyn_cls_dict:
  847. logger.error("Custom method %s adding fails : No dynamic \
  848. LeAPI objects named %s." % (custom_method, cls_name))
  849. elif custom_method._method_name in dir(dyn_cls_dict[cls_name]):
  850. logger.warning("Overriding existing method '%s' on target \
  851. with %s" % (custom_method._method_name, custom_method))
  852. else:
  853. setattr(
  854. dyn_cls_dict[cls_name],
  855. custom_method._method_name,
  856. custom_method.__get_method())
  857. logger.debug(
  858. "Custom method %s added to target" % custom_method)
  859. def wrapper_debug_fun():
  860. print(logger)