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

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