Browse Source

New plugin information & load procedure implementation ( see #13 )

Yann Weber 8 years ago
parent
commit
29e825e6ce

+ 325
- 26
lodel/plugin/plugins.py View File

@@ -4,6 +4,7 @@ import sys
4 4
 import os.path
5 5
 import importlib
6 6
 import copy
7
+import json
7 8
 from importlib.machinery import SourceFileLoader, SourcelessFileLoader
8 9
 
9 10
 import plugins
@@ -19,12 +20,131 @@ from .exceptions import *
19 20
 
20 21
 ##@brief The package in which we will load plugins modules
21 22
 VIRTUAL_PACKAGE_NAME = 'lodel.plugins'
23
+##@brief The temporary package to import python sources
24
+VIRTUAL_TEMP_PACKAGE_NAME = 'lodel.plugin_tmp'
25
+##@brief Plugin init filename
22 26
 INIT_FILENAME = '__init__.py' # Loaded with settings
27
+PLUGIN_NAME_VARNAME = '__plugin_name__'
28
+PLUGIN_TYPE_VARNAME = '__type__'
29
+PLUGIN_VERSION_VARNAME = '__version__'
23 30
 CONFSPEC_FILENAME_VARNAME = '__confspec__'
24 31
 CONFSPEC_VARNAME = 'CONFSPEC'
25 32
 LOADER_FILENAME_VARNAME = '__loader__'
26 33
 PLUGIN_DEPS_VARNAME = '__plugin_deps__'
27 34
 ACTIVATE_METHOD_NAME = '_activate'
35
+##@brief Discover stage cache filename
36
+DISCOVER_CACHE_FILENAME = '.plugin_discover_cache.json'
37
+##@brief Default & failover value for plugins path list
38
+DEFAULT_PLUGINS_PATH_LIST = ['./plugins']
39
+
40
+MANDATORY_VARNAMES = [PLUGIN_NAME_VARNAME, LOADER_FILENAME_VARNAME, 
41
+    PLUGIN_VERSION_VARNAME]
42
+
43
+PLUGIN_DEFAULT_TYPE = 'default'
44
+PLUGINS_TYPES = [PLUGIN_DEFAULT_TYPE, 'datasource', 'session_handler']
45
+
46
+
47
+##@brief Describe and handle version numbers
48
+class PluginVersion(object):
49
+
50
+    PROPERTY_LIST = ['major', 'minor', 'revision' ]
51
+
52
+    ##@brief Version constructor
53
+    #@param *args : You can either give a str that will be splitted on . or you
54
+    #can give a iterable containing 3 integer or 3 arguments representing
55
+    #major, minor and revision version
56
+    def __init__(self, *args):
57
+        self.__version = [0 for _ in range(3) ]
58
+        if len(args) == 1:
59
+            arg = args[0]
60
+            if isinstance(arg, str):
61
+                spl = arg.split('.')
62
+                invalid = False
63
+                if len(spl) > 3:
64
+                    raise PluginError("The string '%s' is not a valid plugin \
65
+version number" % arg)
66
+            else:
67
+                try:
68
+                    if len(arg) >= 1:
69
+                        if len(arg) > 3:
70
+                            raise PluginError("Expected maximum 3 value to \
71
+create a plugin version number but found '%s' as argument" % arg)
72
+                        for i, v in enumerate(arg):
73
+                            self.__version[i] = arg[i]
74
+                except TypeError:
75
+                    raise PluginError("Unable to convert argument into plugin \
76
+version number" % arg)
77
+        elif len(args) > 3:
78
+            raise PluginError("Expected between 1 and 3 positional arguments \
79
+but %d arguments found" % len(args))
80
+        else: 
81
+            for i,v in enumerate(args):
82
+                self.__version[i] = v
83
+    
84
+    @property
85
+    def major(self):
86
+        return self.__version[0]
87
+
88
+    @property
89
+    def minor(self):
90
+        return self.__version[1]
91
+
92
+    @property
93
+    def revision(self):
94
+        return self.__version[2]
95
+    
96
+    ##@brief Check and prepare comparisoon argument
97
+    #@return A PluginVersion instance
98
+    #@throw PluginError if invalid argument provided
99
+    def __cmp_check(self, other):
100
+        if not isinstance(other, PluginVersion):
101
+            try:
102
+                if len(other) <= 3 and len(other) > 0:
103
+                    return PluginVersion(other)
104
+            except TypeError:
105
+                raise PluginError("Cannot compare argument '%s' with \
106
+a PluginVerison instance" % other)
107
+        return other
108
+    
109
+    ##@brief Generic comparison function
110
+    #@param other PluginVersion or iterable
111
+    #@param cmp_fun_name function : interger comparison function
112
+    def __generic_cmp(self, other, cmp_fun_name):
113
+        other = self.__cmp_check(other)
114
+        try:
115
+            cmpfun = getattr(int, cmp_fun_name)
116
+        except AttributeError:
117
+            raise LodelFatalError("Invalid comparison callback given \
118
+to generic PluginVersion comparison function : '%s'" % cmp_fun_name)
119
+        for property_name in self.PROPERTY_LIST:
120
+            if not cmpfun(getattr(self, pname), getattr(other, pname)):
121
+                return False
122
+        return True
123
+
124
+    def __lt__(self, other):
125
+        return self.__generic_cmp(other, '__lt__')
126
+
127
+    def __le__(self, other):
128
+        return self.__generic_cmp(other, '__le__')
129
+
130
+    def __eq__(self, other):
131
+        return self.__generic_cmp(other, '__eq__')
132
+
133
+    def __ne__(self, other):
134
+        return self.__generic_cmp(other, '__ne__')
135
+
136
+    def __gt__(self, other):
137
+        return self.__generic_cmp(other, '__gt__')
138
+
139
+    def __ge__(self, other):
140
+        return self.__generic_cmp(other, '__ge__')
141
+
142
+    def __str__(self):
143
+        return '%d.%d.%d' % tuple(self.__version)
144
+
145
+    def __repr__(self):
146
+        return {'major': self.major, 'minor': self.minor,
147
+            'revision': self.revision}
28 148
 
29 149
 
30 150
 ##@brief Handle plugins
@@ -48,6 +168,9 @@ class Plugin(object):
48 168
     #dependencies
49 169
     _load_called = []
50 170
 
171
+    ##@brief Attribute that stores plugins list from discover cache file
172
+    _plugin_list = None
173
+
51 174
     ##@brief Plugin class constructor
52 175
     #
53 176
     # Called by setting in early stage of lodel2 boot sequence using classmethod
@@ -67,11 +190,11 @@ class Plugin(object):
67 190
         self.__confspecs = dict()
68 191
         self.loaded = False
69 192
         
70
-        # Importing __init__.py
193
+        # Importing __init__.py infos in it
71 194
         plugin_module = '%s.%s' % (VIRTUAL_PACKAGE_NAME,
72 195
                                     plugin_name)
73 196
 
74
-        init_source = self.path + INIT_FILENAME
197
+        init_source = os.path.join(self.path, INIT_FILENAME)
75 198
         try:
76 199
             loader = SourceFileLoader(plugin_module, init_source)
77 200
             self.module = loader.load_module()
@@ -109,23 +232,39 @@ class Plugin(object):
109 232
                     varname = CONFSPEC_VARNAME,
110 233
                     filename = confspec_filename)
111 234
                 raise PluginError(msg)
235
+        # loading plugin version
236
+        try:
237
+            #this try block should be useless. The existance of
238
+            #PLUGIN_VERSION_VARNAME in init file is mandatory
239
+            self.__version = getattr(self.module, PLUGIN_VERSION_VARNAME)
240
+        except AttributeError:
241
+            msg = "Error that should not append : no %s found in plugin \
242
+init file. Malformed plugin"
243
+            msg %= PLUGIN_VERSION_VARNAME
244
+            raise LodelFatalError(msg)
112 245
 
113
-    ##@brief Browse directory to get plugin
114
-    #@param plugin_path 
115
-    #@return module existing
116
-    def _discover_plugin(self, plugin_path):
117
-        res = os.listdir(plugin_path) is not None
118
-        if res:
119
-            dirname = os.path.dirname(plugin_path)
120
-            for f in os.listdir(plugin_path):
121
-                file_name = ''.join(dirname, f)
122
-                if self.is_plugin_dir(file_name):
123
-                    return self.is_plugin_dir(file_name)
124
-                else:
125
-                    self._discover_plugin(file_name)
126
-        else:
127
-            pass
128
-
246
+        # Load plugin type
247
+        try:
248
+            self.__type = getattr(self.module, PLUGIN_TYPE_VARNAME)
249
+        except AttributeError:
250
+            self.__type = PLUGIN_DEFAULT_TYPE
251
+        self.__type = str(self.__type).lower()
252
+        if self.__type not in PLUGINS_TYPES:
253
+            raise PluginError("Unknown plugin type '%s'" % self.__type)
254
+        # Load plugin name from init file (just for checking)
255
+        try:
256
+            #this try block should be useless. The existance of
257
+            #PLUGIN_NAME_VARNAME in init file is mandatory
258
+            pname = getattr(self.module, PLUGIN_NAME_VARNAME)
259
+        except AttributeError:
260
+            msg = "Error that should not append : no %s found in plugin \
261
+init file. Malformed plugin"
262
+            msg %= PLUGIN_NAME_VARNAME
263
+            raise LodelFatalError(msg)
264
+        if pname != plugin_name:
265
+            msg = "Plugin's discover cache inconsistency detected ! Cached \
266
+name differ from the one found in plugin's init file"
267
+            raise PluginError(msg)
129 268
 
130 269
     ##@brief Try to import a file from a variable in __init__.py
131 270
     #@param varname str : The variable name
@@ -153,7 +292,7 @@ class Plugin(object):
153 292
             raise PluginError(msg)
154 293
         # importing the file in varname
155 294
         module_name = self.module.__name__+"."+varname
156
-        filename = self.path + filename
295
+        filename = os.path.join(self.path, filename)
157 296
         loader = SourceFileLoader(module_name, filename)
158 297
         return loader.load_module()
159 298
     
@@ -252,6 +391,9 @@ class Plugin(object):
252 391
             raise RuntimeError("Plugin %s not loaded yet."%self.name)
253 392
         return self.__loader_module
254 393
 
394
+    def __str__(self):
395
+        return "<LodelPlugin '%s' version %s>" % (self.name, self.__version)
396
+
255 397
     ##@brief Call load method on every pre-loaded plugins
256 398
     #
257 399
     # Called by loader to trigger hooks registration.
@@ -317,18 +459,17 @@ class Plugin(object):
317 459
     @classmethod
318 460
     def plugin_path(cls, plugin_name):
319 461
         cls.started()
462
+        plist = cls.plugin_list()
463
+        if plugin_name not in plist:
464
+            raise PluginError("No plugin named '%s' found" % plugin_name)
465
+
320 466
         try:
321 467
             return cls.get(plugin_name).path
322 468
         except PluginError:
323 469
             pass
324
-        
325
-        path = None
326
-        for cur_path in cls._plugin_directories:
327
-            plugin_path = os.path.join(cur_path, plugin_name)+'/'
328
-            if os.path.isdir(plugin_path):
329
-                return plugin_path
330
-        raise NameError("No plugin named '%s'" % plugin_name)
331 470
 
471
+        return plist[plugin_name]['path']
472
+        
332 473
     @classmethod
333 474
     def plugin_module_name(cls, plugin_name):
334 475
         return "%s.%s" % (VIRTUAL_PACKAGE_NAME, plugin_name)
@@ -366,6 +507,164 @@ class Plugin(object):
366 507
             cls._plugin_instances = dict()
367 508
         if cls._load_called != []:
368 509
             cls._load_called = []
510
+    
511
+    @classmethod
512
+    ##@brief Browse directory to get plugin
513
+    #@param plugin_path 
514
+    #@return module existing
515
+    def plugin_discover(self, plugin_path):
516
+        res = os.listdir(plugin_path) is not None
517
+        if res:
518
+            dirname = os.path.dirname(plugin_path)
519
+            for f in os.listdir(plugin_path):
520
+                file_name = ''.join(dirname, f)
521
+                if self.is_plugin_dir(file_name):
522
+                    return self.is_plugin_dir(file_name)
523
+                else:
524
+                    self._discover_plugin(file_name)
525
+        else:
526
+            pass
527
+    
528
+    ##@brief Reccursively walk throught paths to find plugin, then stores
529
+    #found plugin in a file...
530
+    #@return a dict {'path_list': [...], 'plugins': { see @ref _discover }}
531
+    @classmethod
532
+    def discover(cls, paths):
533
+        tmp_res = []
534
+        for path in paths:
535
+            tmp_res += cls._discover(path)
536
+        #Formating and dedoubloning result
537
+        result = dict()
538
+        for pinfos in tmp_res:
539
+            pname = pinfos['name']
540
+            if (
541
+                    pname in result 
542
+                    and pinfos['version'] > result[pname]['version'])\
543
+                or pname not in result:
544
+                result[pname] = pinfos
545
+            else:
546
+                #dropped
547
+                pass
548
+        result = {'path_list': paths, 'plugins': result}
549
+        #Writing to cache
550
+        with open(DISCOVER_CACHE_FILENAME, 'w+') as pdcache:
551
+            pdcache.write(json.dumps(result))
552
+        return result
553
+    
554
+    ##@brief Return discover result
555
+    #@param refresh bool : if true invalidate all plugin list cache
556
+    #@note If discover cache file not found run discover first
557
+    #@note if refresh is set to True discover MUST have been run at least
558
+    #one time. In fact refresh action load the list of path to explore
559
+    #from the plugin's discover cache
560
+    @classmethod
561
+    def plugin_list(cls, refresh = False):
562
+        try:
563
+            infos = cls._load_discover_cache()
564
+            path_list = infos['path_list']
565
+        except PluginError:
566
+            refresh = True
567
+            path_list = DEFAULT_PLUGINS_PATH_LIST
568
+
569
+        if cls._plugin_list is None or refresh:
570
+            if not os.path.isfile(DISCOVER_CACHE_FILENAME) or refresh:
571
+                infos = cls.discover(path_list)
572
+        cls._plugin_list = infos['plugins']
573
+        return cls._plugin_list
574
+
575
+    ##@brief Attempt to open and load plugin discover cache
576
+    #@return discover cache
577
+    #@throw PluginError when open or load fails
578
+    @classmethod
579
+    def _load_discover_cache(cls):
580
+        try:
581
+            pdcache = open(DISCOVER_CACHE_FILENAME, 'r')
582
+        except Exception as e:
583
+            msg = "Unable to open discover cache : %s"
584
+            msg %= e
585
+            raise PluginError(msg)
586
+        try:
587
+            res = json.load(pdcache)
588
+        except Exception as e:
589
+            msg = "Unable to load discover cache : %s"
590
+            msg %= e
591
+            raise PluginError(msg)
592
+        pdcache.close()
593
+        return res
594
+
595
+    ##@brief Check if a directory is a plugin module
596
+    #@param path str : path to check
597
+    #@return a dict with name, version and path if path is a plugin module, else False
598
+    @classmethod
599
+    def dir_is_plugin(cls, path):
600
+        #Checks that path exists
601
+        if not os.path.isdir(path):
602
+            raise ValueError(
603
+                "Expected path to be a directory, but '%s' found" % path)
604
+        #Checks that path contains plugin's init file
605
+        initfile = os.path.join(path, INIT_FILENAME)
606
+        if not os.path.isfile(initfile):
607
+            return False
608
+        #Importing plugin's init file to check contained datas
609
+        try:
610
+            initmod, modname = cls.import_init(path)
611
+        except PluginError:
612
+            return False
613
+        #Checking mandatory init module variables
614
+        for attr_name in MANDATORY_VARNAMES:
615
+            if not hasattr(initmod,attr_name):
616
+                return False
617
+        try:
618
+            pversion = getattr(initmod, PLUGIN_VERSION_VARNAME)
619
+        except PluginError as e:
620
+            msg = "Invalid plugin version found in %s : %s"
621
+            msg %= (path, e)
622
+            raise PluginError(msg)
623
+        pname = getattr(initmod, PLUGIN_NAME_VARNAME)
624
+        return {'name': pname,
625
+            'version': pversion,
626
+            'path': path}
627
+    
628
+    ##@brief Import init file from a plugin path
629
+    #@param path str : Directory path
630
+    #@return a tuple (init_module, module_name)
631
+    @classmethod
632
+    def import_init(self, path):
633
+        init_source = os.path.join(path, INIT_FILENAME)
634
+        temp_module = '%s.%s.%s' % (
635
+            VIRTUAL_TEMP_PACKAGE_NAME, os.path.basename(os.path.dirname(path)),
636
+            'test_init')
637
+        try:
638
+            loader = SourceFileLoader(temp_module, init_source)
639
+        except (ImportError, FileNotFoundError) as e:
640
+            raise PluginError("Unable to import init file from '%s' : %s" % (
641
+                temp_module, e))
642
+        try:
643
+            res_module = loader.load_module()
644
+        except Exception as e:
645
+            raise PluginError("Unable to import initfile")
646
+        return (res_module, temp_module)
647
+
648
+    ##@brief Reccursiv plugin discover given a path
649
+    #@param path str : the path to walk through
650
+    #@return A dict with plugin_name as key and {'path':..., 'version':...} as value
651
+    @classmethod
652
+    def _discover(cls, path):
653
+        res = []
654
+        to_explore = [path]
655
+        while len(to_explore) > 0:
656
+            cur_path = to_explore.pop()
657
+            for f in os.listdir(cur_path):
658
+                f_path = os.path.join(cur_path, f)
659
+                if f not in ['.', '..'] and os.path.isdir(f_path):
660
+                    #Check if it is a plugin directory
661
+                    test_result = cls.dir_is_plugin(f_path)
662
+                    if not (test_result is False):
663
+                        res.append(test_result)
664
+                    else:
665
+                        to_explore.append(f_path)
666
+        return res
667
+
369 668
 
370 669
 ##@brief Decorator class designed to allow plugins to add custom methods
371 670
 #to LeObject childs (dyncode objects)

+ 3
- 0
plugins/dummy/__init__.py View File

@@ -1,9 +1,12 @@
1 1
 from lodel.settings.validator import SettingValidator
2 2
 
3
+__plugin_name__ = "dummy"
4
+__version__ = '0.0.1' #or __version__ = [0,0,1]
3 5
 __loader__ = "main.py"
4 6
 __confspec__ = "confspec.py"
5 7
 __author__ = "Lodel2 dev team"
6 8
 __fullname__ = "Dummy plugin"
9
+__name__ = 'yweber.dummy'
7 10
 
8 11
 
9 12
 ##@brief This methods allow plugin writter to write some checks

+ 2
- 0
plugins/dummy_datasource/__init__.py View File

@@ -1,6 +1,8 @@
1 1
 from lodel.settings.validator import SettingValidator
2 2
 from .datasource import DummyDatasource as Datasource
3 3
 
4
+__plugin_name__ = "dummy_datasource"
5
+__version__ = '0.0.1'
4 6
 __loader__ = 'main.py'
5 7
 __plugin_deps__ = []
6 8
 

+ 4
- 0
plugins/mongodb_datasource/__init__.py View File

@@ -1,4 +1,8 @@
1 1
 #-*- coding: utf-8 -*-
2
+__plugin_name__ = 'mongodb_datasource'
3
+__version__ = '0.0.1'
4
+__plugin_type__ = 'datasource'
5
+
2 6
 __loader__ = "main.py"
3 7
 __confspec__ = "confspec.py"
4 8
 

Loading…
Cancel
Save