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

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