1import logging
2import os
3import sys
4import shutil
5import time
6from collections import defaultdict
7
8import importlib
9import json
10
11from Debug import Debug
12from Config import config
13import plugins
14
15
16class PluginManager:
17    def __init__(self):
18        self.log = logging.getLogger("PluginManager")
19        self.path_plugins = os.path.abspath(os.path.dirname(plugins.__file__))
20        self.path_installed_plugins = config.data_dir + "/__plugins__"
21        self.plugins = defaultdict(list)  # Registered plugins (key: class name, value: list of plugins for class)
22        self.subclass_order = {}  # Record the load order of the plugins, to keep it after reload
23        self.pluggable = {}
24        self.plugin_names = []  # Loaded plugin names
25        self.plugins_updated = {}  # List of updated plugins since restart
26        self.plugins_rev = {}  # Installed plugins revision numbers
27        self.after_load = []   # Execute functions after loaded plugins
28        self.function_flags = {}  # Flag function for permissions
29        self.reloading = False
30        self.config_path = config.data_dir + "/plugins.json"
31        self.loadConfig()
32
33        self.config.setdefault("builtin", {})
34
35        sys.path.append(os.path.join(os.getcwd(), self.path_plugins))
36        self.migratePlugins()
37
38        if config.debug:  # Auto reload Plugins on file change
39            from Debug import DebugReloader
40            DebugReloader.watcher.addCallback(self.reloadPlugins)
41
42    def loadConfig(self):
43        if os.path.isfile(self.config_path):
44            try:
45                self.config = json.load(open(self.config_path, encoding="utf8"))
46            except Exception as err:
47                self.log.error("Error loading %s: %s" % (self.config_path, err))
48                self.config = {}
49        else:
50            self.config = {}
51
52    def saveConfig(self):
53        f = open(self.config_path, "w", encoding="utf8")
54        json.dump(self.config, f, ensure_ascii=False, sort_keys=True, indent=2)
55
56    def migratePlugins(self):
57        for dir_name in os.listdir(self.path_plugins):
58            if dir_name == "Mute":
59                self.log.info("Deleting deprecated/renamed plugin: %s" % dir_name)
60                shutil.rmtree("%s/%s" % (self.path_plugins, dir_name))
61
62    # -- Load / Unload --
63
64    def listPlugins(self, list_disabled=False):
65        plugins = []
66        for dir_name in sorted(os.listdir(self.path_plugins)):
67            dir_path = os.path.join(self.path_plugins, dir_name)
68            plugin_name = dir_name.replace("disabled-", "")
69            if dir_name.startswith("disabled"):
70                is_enabled = False
71            else:
72                is_enabled = True
73
74            plugin_config = self.config["builtin"].get(plugin_name, {})
75            if "enabled" in plugin_config:
76                is_enabled = plugin_config["enabled"]
77
78            if dir_name == "__pycache__" or not os.path.isdir(dir_path):
79                continue  # skip
80            if dir_name.startswith("Debug") and not config.debug:
81                continue  # Only load in debug mode if module name starts with Debug
82            if not is_enabled and not list_disabled:
83                continue  # Dont load if disabled
84
85            plugin = {}
86            plugin["source"] = "builtin"
87            plugin["name"] = plugin_name
88            plugin["dir_name"] = dir_name
89            plugin["dir_path"] = dir_path
90            plugin["inner_path"] = plugin_name
91            plugin["enabled"] = is_enabled
92            plugin["rev"] = config.rev
93            plugin["loaded"] = plugin_name in self.plugin_names
94            plugins.append(plugin)
95
96        plugins += self.listInstalledPlugins(list_disabled)
97        return plugins
98
99    def listInstalledPlugins(self, list_disabled=False):
100        plugins = []
101
102        for address, site_plugins in sorted(self.config.items()):
103            if address == "builtin":
104                continue
105            for plugin_inner_path, plugin_config in sorted(site_plugins.items()):
106                is_enabled = plugin_config.get("enabled", False)
107                if not is_enabled and not list_disabled:
108                    continue
109                plugin_name = os.path.basename(plugin_inner_path)
110
111                dir_path = "%s/%s/%s" % (self.path_installed_plugins, address, plugin_inner_path)
112
113                plugin = {}
114                plugin["source"] = address
115                plugin["name"] = plugin_name
116                plugin["dir_name"] = plugin_name
117                plugin["dir_path"] = dir_path
118                plugin["inner_path"] = plugin_inner_path
119                plugin["enabled"] = is_enabled
120                plugin["rev"] = plugin_config.get("rev", 0)
121                plugin["loaded"] = plugin_name in self.plugin_names
122                plugins.append(plugin)
123
124        return plugins
125
126    # Load all plugin
127    def loadPlugins(self):
128        all_loaded = True
129        s = time.time()
130        for plugin in self.listPlugins():
131            self.log.debug("Loading plugin: %s (%s)" % (plugin["name"], plugin["source"]))
132            if plugin["source"] != "builtin":
133                self.plugins_rev[plugin["name"]] = plugin["rev"]
134                site_plugin_dir = os.path.dirname(plugin["dir_path"])
135                if site_plugin_dir not in sys.path:
136                    sys.path.append(site_plugin_dir)
137            try:
138                sys.modules[plugin["name"]] = __import__(plugin["dir_name"])
139            except Exception as err:
140                self.log.error("Plugin %s load error: %s" % (plugin["name"], Debug.formatException(err)))
141                all_loaded = False
142            if plugin["name"] not in self.plugin_names:
143                self.plugin_names.append(plugin["name"])
144
145        self.log.debug("Plugins loaded in %.3fs" % (time.time() - s))
146        for func in self.after_load:
147            func()
148        return all_loaded
149
150    # Reload all plugins
151    def reloadPlugins(self):
152        self.reloading = True
153        self.after_load = []
154        self.plugins_before = self.plugins
155        self.plugins = defaultdict(list)  # Reset registered plugins
156        for module_name, module in list(sys.modules.items()):
157            if not module or not getattr(module, "__file__", None):
158                continue
159            if self.path_plugins not in module.__file__ and self.path_installed_plugins not in module.__file__:
160                continue
161
162            if "allow_reload" in dir(module) and not module.allow_reload:  # Reload disabled
163                # Re-add non-reloadable plugins
164                for class_name, classes in self.plugins_before.items():
165                    for c in classes:
166                        if c.__module__ != module.__name__:
167                            continue
168                        self.plugins[class_name].append(c)
169            else:
170                try:
171                    importlib.reload(module)
172                except Exception as err:
173                    self.log.error("Plugin %s reload error: %s" % (module_name, Debug.formatException(err)))
174
175        self.loadPlugins()  # Load new plugins
176
177        # Change current classes in memory
178        import gc
179        patched = {}
180        for class_name, classes in self.plugins.items():
181            classes = classes[:]  # Copy the current plugins
182            classes.reverse()
183            base_class = self.pluggable[class_name]  # Original class
184            classes.append(base_class)  # Add the class itself to end of inherience line
185            plugined_class = type(class_name, tuple(classes), dict())  # Create the plugined class
186            for obj in gc.get_objects():
187                if type(obj).__name__ == class_name:
188                    obj.__class__ = plugined_class
189                    patched[class_name] = patched.get(class_name, 0) + 1
190        self.log.debug("Patched objects: %s" % patched)
191
192        # Change classes in modules
193        patched = {}
194        for class_name, classes in self.plugins.items():
195            for module_name, module in list(sys.modules.items()):
196                if class_name in dir(module):
197                    if "__class__" not in dir(getattr(module, class_name)):  # Not a class
198                        continue
199                    base_class = self.pluggable[class_name]
200                    classes = self.plugins[class_name][:]
201                    classes.reverse()
202                    classes.append(base_class)
203                    plugined_class = type(class_name, tuple(classes), dict())
204                    setattr(module, class_name, plugined_class)
205                    patched[class_name] = patched.get(class_name, 0) + 1
206
207        self.log.debug("Patched modules: %s" % patched)
208        self.reloading = False
209
210
211plugin_manager = PluginManager()  # Singletone
212
213# -- Decorators --
214
215# Accept plugin to class decorator
216
217
218def acceptPlugins(base_class):
219    class_name = base_class.__name__
220    plugin_manager.pluggable[class_name] = base_class
221    if class_name in plugin_manager.plugins:  # Has plugins
222        classes = plugin_manager.plugins[class_name][:]  # Copy the current plugins
223
224        # Restore the subclass order after reload
225        if class_name in plugin_manager.subclass_order:
226            classes = sorted(
227                classes,
228                key=lambda key:
229                    plugin_manager.subclass_order[class_name].index(str(key))
230                    if str(key) in plugin_manager.subclass_order[class_name]
231                    else 9999
232            )
233        plugin_manager.subclass_order[class_name] = list(map(str, classes))
234
235        classes.reverse()
236        classes.append(base_class)  # Add the class itself to end of inherience line
237        plugined_class = type(class_name, tuple(classes), dict())  # Create the plugined class
238        plugin_manager.log.debug("New class accepts plugins: %s (Loaded plugins: %s)" % (class_name, classes))
239    else:  # No plugins just use the original
240        plugined_class = base_class
241    return plugined_class
242
243
244# Register plugin to class name decorator
245def registerTo(class_name):
246    if config.debug and not plugin_manager.reloading:
247        import gc
248        for obj in gc.get_objects():
249            if type(obj).__name__ == class_name:
250                raise Exception("Class %s instances already present in memory" % class_name)
251                break
252
253    plugin_manager.log.debug("New plugin registered to: %s" % class_name)
254    if class_name not in plugin_manager.plugins:
255        plugin_manager.plugins[class_name] = []
256
257    def classDecorator(self):
258        plugin_manager.plugins[class_name].append(self)
259        return self
260    return classDecorator
261
262
263def afterLoad(func):
264    plugin_manager.after_load.append(func)
265    return func
266
267
268# - Example usage -
269
270if __name__ == "__main__":
271    @registerTo("Request")
272    class RequestPlugin(object):
273
274        def actionMainPage(self, path):
275            return "Hello MainPage!"
276
277    @acceptPlugins
278    class Request(object):
279
280        def route(self, path):
281            func = getattr(self, "action" + path, None)
282            if func:
283                return func(path)
284            else:
285                return "Can't route to", path
286
287    print(Request().route("MainPage"))
288