From 4c949ee6f383c4af3c4c53e056e720086e16f543 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 17 Jan 2016 09:20:06 -0600 Subject: [PATCH 1/6] ircbot: don't unload just one plugin of path if we somehow got multiple plugins of the same path loaded, unload them all when unloading, not just the first one we find --- dr_botzo/ircbot/bot.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index de7ab71..607f70f 100644 --- a/dr_botzo/ircbot/bot.py +++ b/dr_botzo/ircbot/bot.py @@ -646,18 +646,19 @@ class IRCBot(irc.client.SimpleIRCClient): dest = ircbotlib.reply_destination_for_event(event) + unloaded = False for path, plugin in self.plugins: if plugin_path == path: + unloaded = True self.plugins.remove((path, plugin)) plugin.stop() del plugin del sys.modules[plugin_path] self.privmsg(dest, "Plugin '{0:s}' unloaded.".format(plugin_path)) - return - # guess it was never loaded - self.privmsg(dest, "Plugin '{0:s}' is not loaded.".format(plugin_path)) + if not unloaded: + self.privmsg(dest, "Plugin '{0:s}' is not loaded.".format(plugin_path)) def privmsg(self, target, text): """Send a PRIVMSG command. From 676f479d526d2639580f358d8b10d212eadce627 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 17 Jan 2016 09:21:29 -0600 Subject: [PATCH 2/6] ircbot: add some debug logging in plugin (un)load --- dr_botzo/ircbot/bot.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index 607f70f..42bc64d 100644 --- a/dr_botzo/ircbot/bot.py +++ b/dr_botzo/ircbot/bot.py @@ -591,13 +591,19 @@ class IRCBot(irc.client.SimpleIRCClient): The general assumption here is that a plugin's init loads its hooks and handlers. """ - log.debug("trying to load %s", plugin_path) + log.debug("trying to load plugin %s", plugin_path) dest = None if feedback: dest = ircbotlib.reply_destination_for_event(event) + # loop over the sys.modules entries, for debugging + log.debug("loaded modules:") + for module_name, module in sys.modules.items(): + log.debug(" %s", module_name) + for path, plugin in self.plugins: + log.debug("looping already loaded plugin: %s %s", plugin, path) if plugin_path == path: if feedback: self.privmsg(dest, "Plugin '{0:s}' is already loaded.".format(plugin_path)) @@ -629,6 +635,11 @@ class IRCBot(irc.client.SimpleIRCClient): if feedback: self.privmsg(dest, "Plugin '{0:s}' could not be loaded: {1:s}".format(plugin_path, str(e))) + # loop over the sys.modules entries, for debugging + log.debug("new list of loaded modules:") + for module_name, module in sys.modules.items(): + log.debug(" %s", module_name) + def handle_unload(self, connection, event, match): """Handle IRC requests to unload a plugin.""" @@ -642,10 +653,15 @@ class IRCBot(irc.client.SimpleIRCClient): def _unload_plugin(self, connection, event, plugin_path): """Attempt to unload and del a module if it's loaded.""" - log.debug("trying to unload %s", plugin_path) + log.debug("trying to unload plugin %s", plugin_path) dest = ircbotlib.reply_destination_for_event(event) + # loop over the sys.modules entries, for debugging + log.debug("loaded modules:") + for module_name, module in sys.modules.items(): + log.debug(" %s", module_name) + unloaded = False for path, plugin in self.plugins: if plugin_path == path: @@ -660,6 +676,11 @@ class IRCBot(irc.client.SimpleIRCClient): if not unloaded: self.privmsg(dest, "Plugin '{0:s}' is not loaded.".format(plugin_path)) + # loop over the sys.modules entries, for debugging + log.debug("new list of loaded modules:") + for module_name, module in sys.modules.items(): + log.debug(" %s", module_name) + def privmsg(self, target, text): """Send a PRIVMSG command. From 10071f90940f904735ed8f5f9da3bba569a7709c Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 17 Jan 2016 09:43:48 -0600 Subject: [PATCH 3/6] ircbot: add load/unload plugin documentation --- dr_botzo/ircbot/bot.py | 55 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index 42bc64d..d5f0e26 100644 --- a/dr_botzo/ircbot/bot.py +++ b/dr_botzo/ircbot/bot.py @@ -576,7 +576,18 @@ class IRCBot(irc.client.SimpleIRCClient): pass def handle_load(self, connection, event, match): - """Handle IRC requests to load a plugin.""" + """Handle IRC requests to load a plugin. + + This loads a plugin, as requested, and the interpreter takes care of + loading dependencies. + + :param connection: connection for this load request + :type connection: LenientServerConnection + :param event: associated irc event object + :type event: Event + :param match: the matched regex (via add_global_regex_handler) + :type match: Match + """ has_perm = ircbotlib.has_permission(event.source, 'ircbot.manage_loaded_plugins') log.debug("has permission to load?: %s", str(has_perm)) @@ -588,7 +599,18 @@ class IRCBot(irc.client.SimpleIRCClient): def _load_plugin(self, connection, event, plugin_path, feedback=True): """Load an IRC plugin. - The general assumption here is that a plugin's init loads its hooks and handlers. + A plugin's init must initialize its hooks and handlers as references to this bot, this + is handed off but otherwise nothing is done with that here, this just loads it from a + Python sense. + + :param connection: connection for this load request + :type connection: LenientServerConnection + :param event: associated irc event object + :type event: Event + :param plugin_path: path (likely a relative one) of the plugin to attempt to load + :type plugin_path: str + :param feedback: whether or not to send messages to IRC regarding the load attempt + :type feedback: bool """ log.debug("trying to load plugin %s", plugin_path) @@ -641,7 +663,21 @@ class IRCBot(irc.client.SimpleIRCClient): log.debug(" %s", module_name) def handle_unload(self, connection, event, match): - """Handle IRC requests to unload a plugin.""" + """Handle IRC requests to unload a plugin. + + Unloading includes disconnecting the IRC plugin hooks, so this is the + way to "remove" a plugin from the IRC bot. Note that this only + removes the module for the specified plugin, not its dependencies, so + if you're trying to unload in order to refresh modules, this won't + do what you want. + + :param connection: connection for this unload request + :type connection: LenientServerConnection + :param event: associated irc event object + :type event: Event + :param match: the matched regex (via add_global_regex_handler) + :type match: Match + """ has_perm = ircbotlib.has_permission(event.source, 'ircbot.manage_loaded_plugins') log.debug("has permission to unload?: %s", str(has_perm)) @@ -651,7 +687,18 @@ class IRCBot(irc.client.SimpleIRCClient): self._unload_plugin(connection, event, plugin_path) def _unload_plugin(self, connection, event, plugin_path): - """Attempt to unload and del a module if it's loaded.""" + """Attempt to unload and del a module if it's loaded. + + This stops the plugin, which should (if it's coded properly) disconnect its + event handlers and kill threads and whatnot. + + :param connection: connection for this unload request + :type connection: LenientServerConnection + :param event: associated irc event object + :type event: Event + :param plugin_path: path (likely a relative one) of the plugin to attempt to unload + :type plugin_path: str + """ log.debug("trying to unload plugin %s", plugin_path) From 97c18a24598407287aba364170383870a02380f0 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 17 Jan 2016 09:49:55 -0600 Subject: [PATCH 4/6] ircbot: provide feedback option to _plugin_unload --- dr_botzo/ircbot/bot.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index d5f0e26..9cec53a 100644 --- a/dr_botzo/ircbot/bot.py +++ b/dr_botzo/ircbot/bot.py @@ -686,7 +686,7 @@ class IRCBot(irc.client.SimpleIRCClient): log.debug("calling _unload_plugin on %s", plugin_path) self._unload_plugin(connection, event, plugin_path) - def _unload_plugin(self, connection, event, plugin_path): + def _unload_plugin(self, connection, event, plugin_path, feedback=True): """Attempt to unload and del a module if it's loaded. This stops the plugin, which should (if it's coded properly) disconnect its @@ -698,6 +698,8 @@ class IRCBot(irc.client.SimpleIRCClient): :type event: Event :param plugin_path: path (likely a relative one) of the plugin to attempt to unload :type plugin_path: str + :param feedback: whether or not to send messages to IRC regarding the unload attempt + :type feedback: bool """ log.debug("trying to unload plugin %s", plugin_path) @@ -718,9 +720,10 @@ class IRCBot(irc.client.SimpleIRCClient): del plugin del sys.modules[plugin_path] - self.privmsg(dest, "Plugin '{0:s}' unloaded.".format(plugin_path)) + if feedback: + self.privmsg(dest, "Plugin '{0:s}' unloaded.".format(plugin_path)) - if not unloaded: + if not unloaded and feedback: self.privmsg(dest, "Plugin '{0:s}' is not loaded.".format(plugin_path)) # loop over the sys.modules entries, for debugging From 0ba889bf75dec3d8df4927aff1cd13cf9e89b2d8 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 17 Jan 2016 10:52:25 -0600 Subject: [PATCH 5/6] ircbot: _unload_plugin doesn't need connection --- dr_botzo/ircbot/bot.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index 9cec53a..35225c8 100644 --- a/dr_botzo/ircbot/bot.py +++ b/dr_botzo/ircbot/bot.py @@ -684,16 +684,14 @@ class IRCBot(irc.client.SimpleIRCClient): if has_perm: plugin_path = match.group(1) log.debug("calling _unload_plugin on %s", plugin_path) - self._unload_plugin(connection, event, plugin_path) + self._unload_plugin(event, plugin_path) - def _unload_plugin(self, connection, event, plugin_path, feedback=True): + def _unload_plugin(self, event, plugin_path, feedback=True): """Attempt to unload and del a module if it's loaded. This stops the plugin, which should (if it's coded properly) disconnect its event handlers and kill threads and whatnot. - :param connection: connection for this unload request - :type connection: LenientServerConnection :param event: associated irc event object :type event: Event :param plugin_path: path (likely a relative one) of the plugin to attempt to unload From 6fe67972816cd19078c691b243c10f0f8743c1e5 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 17 Jan 2016 10:56:39 -0600 Subject: [PATCH 6/6] ircbot: implement !reload for plugins in addition to being a convenient unload plugin, load plugin, this also uses importlib.reload() to kick the interpreter to reload changed code inside the plugin's package, if the user requests to reload the entire package. this seems safe so far (famous last words) --- dr_botzo/ircbot/bot.py | 91 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index 35225c8..45b7473 100644 --- a/dr_botzo/ircbot/bot.py +++ b/dr_botzo/ircbot/bot.py @@ -3,6 +3,7 @@ import bisect import collections import copy +import importlib import logging import re from xmlrpc.server import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler @@ -356,6 +357,10 @@ class IRCBot(irc.client.SimpleIRCClient): getattr(self, 'handle_unload'), -20) self.connection.reactor.add_global_regex_handler('privmsg', r'^!unload\s+([\S]+)$', getattr(self, 'handle_unload'), -20) + self.connection.reactor.add_global_regex_handler('pubmsg', r'^!reload\s+([\S]+)$', + getattr(self, 'handle_reload'), -20) + self.connection.reactor.add_global_regex_handler('privmsg', r'^!reload\s+([\S]+)$', + getattr(self, 'handle_reload'), -20) # load XML-RPC server self.xmlrpc = SimpleXMLRPCServer((settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT), @@ -662,6 +667,92 @@ class IRCBot(irc.client.SimpleIRCClient): for module_name, module in sys.modules.items(): log.debug(" %s", module_name) + def handle_reload(self, connection, event, match): + """Handle IRC requests to reload a plugin (module or package). + + This allows code for a plugin (and potentially also its sibling modules in the package) + to be reloaded on demand, in the event that changes were made. + + :param connection: connection for this reload request + :type connection: LenientServerConnection + :param event: associated irc event object + :type event: Event + :param match: the matched regex (via add_global_regex_handler) + :type match: Match + """ + + has_perm = ircbotlib.has_permission(event.source, 'ircbot.manage_loaded_plugins') + log.debug("has permission to reload?: %s", str(has_perm)) + if has_perm: + plugin_path = match.group(1) + log.debug("calling _reload_plugin on %s", plugin_path) + self._reload_plugin(connection, event, plugin_path) + + def _reload_plugin(self, connection, event, plugin_path, feedback=True): + """Execute the built-in reload() on a plugin (module) or it and its siblings (package). + + :param connection: connection for this load request + :type connection: LenientServerConnection + :param event: associated irc event object + :type event: Event + :param plugin_path: path (likely a relative one) of the plugin to attempt to reload + :type plugin_path: str + :param feedback: whether or not to send messages to IRC regarding the reload attempt + :type feedback: bool + """ + + log.debug("trying to unload plugin %s", plugin_path) + + dest = ircbotlib.reply_destination_for_event(event) + + # loop over the sys.modules entries, for debugging + log.debug("loaded modules:") + for module_name, module in sys.modules.items(): + log.debug(" %s", module_name) + + # make sure what was provided was a legitimate plugin + # (so that we don't go reloading totally arbitrary code) + search_path = None + for path, plugin in self.plugins: + # first check exact path + if path == plugin_path: + log.debug("requested %s is %s, so we will try to reload %s", + plugin_path, path, plugin_path) + search_path = plugin_path + if plugin_path[-1] == '.' and path.startswith(plugin_path): + log.debug("requested %s is substring of %s, so we will try to reload all %s items", + plugin_path, path, plugin_path) + search_path = plugin_path[:-1] + + # we need to know the plugin names to do some extra logic with reload + plugin_names = [x[0] for x in self.plugins] + + # do reload(s) + if search_path: + plugins_to_reload = [] + for module_name, module in sys.modules.items(): + if module_name.startswith(search_path): + if module_name in plugin_names: + # we need to take the plugin reload path for this, but not + # before all other code is reloaded, so we save this for the end + # if we did this here, the ircplugin might still reference the old + # version of its dependencies + plugins_to_reload.append(module_name) + else: + log.debug("reloading %s", module_name) + importlib.reload(module) + + if feedback: + self.privmsg(dest, "'{0:s}' reloaded.".format(module_name)) + + # the references to event handlers will be stale (reload() doesn't go that deep) + # so we need to tear down and reinitialize plugins + for plugin_to_reload in plugins_to_reload: + log.debug("unloading plugin %s", plugin_to_reload) + self._unload_plugin(event, plugin_to_reload, False) + log.debug("loading plugin %s", plugin_to_reload) + self._load_plugin(connection, event, plugin_to_reload, False) + def handle_unload(self, connection, event, match): """Handle IRC requests to unload a plugin.