diff --git a/dr_botzo/ircbot/bot.py b/dr_botzo/ircbot/bot.py index de7ab71..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), @@ -576,7 +581,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,16 +604,33 @@ 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 %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,35 +662,163 @@ 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_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.""" + """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)) 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): - """Attempt to unload and del a module if it's loaded.""" + def _unload_plugin(self, event, plugin_path, feedback=True): + """Attempt to unload and del a module if it's loaded. - log.debug("trying to unload %s", plugin_path) + This stops the plugin, which should (if it's coded properly) disconnect its + event handlers and kill threads and whatnot. + + :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 + :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) 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: + 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 + if feedback: + self.privmsg(dest, "Plugin '{0:s}' unloaded.".format(plugin_path)) - # guess it was never loaded - self.privmsg(dest, "Plugin '{0:s}' is not loaded.".format(plugin_path)) + 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 + 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.