diff --git a/docs/source/plugin.rst b/docs/source/plugin.rst index 9f68c61cc8..6c7a2a3038 100644 --- a/docs/source/plugin.rst +++ b/docs/source/plugin.rst @@ -5,10 +5,15 @@ A Sopel plugin consists of a Python module containing one or more ``callable``\s. It may optionally also contain ``configure``, ``setup``, and ``shutdown`` hooks. -.. py:method:: callable(bot, trigger) +.. py:function:: callable(bot, trigger) + + :param bot: the bot's instance + :type bot: :class:`sopel.bot.SopelWrapper` + :param trigger: the object that triggered the call + :type trigger: :class:`sopel.trigger.Trigger` A callable is any function which takes as its arguments a - :class:`sopel.bot.Sopel` object and a :class:`sopel.trigger.Trigger` + :class:`sopel.bot.SopelWrapper` object and a :class:`sopel.trigger.Trigger` object, and is wrapped with appropriate decorators from :mod:`sopel.module`. The ``bot`` provides the ability to send messages to the network and check the state of the bot. The ``trigger`` provides @@ -21,7 +26,10 @@ A Sopel plugin consists of a Python module containing one or more Note that the name can, and should, be anything - it doesn't need to be called "callable". -.. py:method:: setup(bot) +.. py:function:: setup(bot) + + :param bot: the bot's instance + :type bot: :class:`sopel.bot.Sopel` This is an optional function of a plugin, which will be called while the module is being loaded. The purpose of this function is to perform whatever @@ -41,7 +49,10 @@ A Sopel plugin consists of a Python module containing one or more execution of this function. As such, an infinite loop (such as an unthreaded polling loop) will cause the bot to hang. -.. py:method:: shutdown(bot) +.. py:function:: shutdown(bot) + + :param bot: the bot's instance + :type bot: :class:`sopel.bot.Sopel` This is an optional function of a module, which will be called while the bot is quitting. Note that this normally occurs after closing connection @@ -57,7 +68,10 @@ A Sopel plugin consists of a Python module containing one or more .. versionadded:: 4.1 -.. py:method:: configure(config) +.. py:function:: configure(config) + + :param bot: the bot's configuration object + :type bot: :class:`sopel.config.Config` This is an optional function of a module, which will be called during the user's setup of the bot. It's intended purpose is to use the methods of the @@ -71,3 +85,12 @@ sopel.module .. automodule:: sopel.module :members: +sopel.plugins +------------- +.. automodule:: sopel.plugins + :members: + +sopel.plugins.handlers +---------------------- +.. automodule:: sopel.plugins.handlers + :members: diff --git a/setup.py b/setup.py index 69fd312886..c7c13e504a 100755 --- a/setup.py +++ b/setup.py @@ -67,7 +67,7 @@ def read_reqs(path): ), # Distutils is shit, and doesn't check if it's a list of basestring # but instead requires str. - packages=[str('sopel'), str('sopel.modules'), + packages=[str('sopel'), str('sopel.modules'), str('sopel.plugins'), str('sopel.cli'), str('sopel.config'), str('sopel.tools')], classifiers=classifiers, license='Eiffel Forum License, version 2', diff --git a/sopel/bot.py b/sopel/bot.py index 583f962184..9dc1d78d58 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -9,14 +9,14 @@ from ast import literal_eval import collections +import itertools import os import re import sys import threading import time -from sopel import tools -from sopel import irc +from sopel import irc, plugins, tools from sopel.db import SopelDB from sopel.tools import stderr, Identifier import sopel.tools.jobs @@ -60,6 +60,7 @@ def __init__(self, config, daemon=False): 'medium': collections.defaultdict(list), 'low': collections.defaultdict(list) } + self._plugins = {} self.config = config """The :class:`sopel.config.Config` for the current Sopel instance.""" self.doc = {} @@ -178,31 +179,33 @@ def write(self, args, text=None): # Shim this in here for autodocs irc.Bot.write(self, args, text=text) def setup(self): - stderr("\nWelcome to Sopel. Loading modules...\n\n") - - modules = sopel.loader.enumerate_modules(self.config) - - error_count = 0 - success_count = 0 - for name in modules: - path, type_ = modules[name] + load_success = 0 + load_error = 0 + load_disabled = 0 + + stderr("Welcome to Sopel. Loading modules...") + usable_plugins = plugins.get_usable_plugins(self.config) + for name, info in usable_plugins.items(): + plugin, is_enabled = info + if not is_enabled: + load_disabled = load_disabled + 1 + continue try: - module, _ = sopel.loader.load_module(name, path, type_) + plugin.load() except Exception as e: - error_count = error_count + 1 + load_error = load_error + 1 filename, lineno = tools.get_raising_file_and_line() rel_path = os.path.relpath(filename, os.path.dirname(__file__)) raising_stmt = "%s:%d" % (rel_path, lineno) stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) else: try: - if hasattr(module, 'setup'): - module.setup(self) - relevant_parts = sopel.loader.clean_module( - module, self.config) + if plugin.has_setup(): + plugin.setup(self) + plugin.register(self) except Exception as e: - error_count = error_count + 1 + load_error = load_error + 1 filename, lineno = tools.get_raising_file_and_line() rel_path = os.path.relpath( filename, os.path.dirname(__file__) @@ -211,15 +214,94 @@ def setup(self): stderr("Error in %s setup procedure: %s (%s)" % (name, e, raising_stmt)) else: - self.register(*relevant_parts) - success_count += 1 - - if len(modules) > 1: # coretasks is counted - stderr('\n\nRegistered %d modules,' % (success_count - 1)) - stderr('%d modules failed to load\n\n' % error_count) + load_success = load_success + 1 + print('Loaded: %s' % name) + + total = sum([load_success, load_error, load_disabled]) + if total and load_success: + stderr('Registered %d modules' % (load_success - 1)) + stderr('%d modules failed to load' % load_error) + stderr('%d modules disabled' % load_disabled) else: stderr("Warning: Couldn't load any modules") + def reload_plugin(self, name): + """Reload a plugin + + :param str name: name of the plugin to reload + :raise PluginNotRegistered: when there is no ``name`` plugin registered + + It runs the plugin's shutdown routine and unregisters it. Then it + reloads it, runs its setup routines, and registers it again. + """ + if not self.has_plugin(name): + raise plugins.exceptions.PluginNotRegistered(name) + + plugin = self._plugins[name] + # tear down + plugin.shutdown(self) + plugin.unregister(self) + print('Unloaded: %s' % name) + # reload & setup + plugin.reload() + plugin.setup(self) + plugin.register(self) + print('Reloaded: %s' % name) + + def reload_plugins(self): + """Reload all plugins + + First, run all plugin shutdown routines and unregister all plugins. + Then reload all plugins, run their setup routines, and register them + again. + """ + registered = list(self._plugins.items()) + # tear down all plugins + for name, plugin in registered: + plugin.shutdown(self) + plugin.unregister(self) + print('Unloaded: %s' % name) + + # reload & setup all plugins + for name, plugin in registered: + plugin.reload() + plugin.setup(self) + plugin.register(self) + print('Reloaded: %s' % name) + + def add_plugin(self, plugin, callables, jobs, shutdowns, urls): + """Add a loaded plugin to the bot's registry""" + self._plugins[plugin.name] = plugin + self.register(callables, jobs, shutdowns, urls) + + def remove_plugin(self, plugin, callables, jobs, shutdowns, urls): + """Remove a loaded plugin from the bot's registry""" + name = plugin.name + if not self.has_plugin(name): + raise plugins.exceptions.PluginNotRegistered(name) + + try: + # remove commands, jobs, and shutdown functions + for func in itertools.chain(callables, jobs, shutdowns): + self.unregister(func) + + # remove URL callback handlers + if self.memory.contains('url_callbacks'): + for func in urls: + regex = func.url_regex + if func == self.memory['url_callbacks'].get(regex): + self.unregister_url_callback(regex) + except: # noqa + # TODO: consider logging? + raise # re-raised + else: + # remove plugin from registry + del self._plugins[name] + + def has_plugin(self, name): + """Tell if the bot has registered this plugin by its name""" + return name in self._plugins + def unregister(self, obj): if not callable(obj): return @@ -229,9 +311,7 @@ def unregister(self, obj): if obj in callb_list: callb_list.remove(obj) if hasattr(obj, 'interval'): - # TODO this should somehow find the right job to remove, rather than - # clearing the entire queue. Issue #831 - self.scheduler.clear_jobs() + self.scheduler.remove_callable_job(obj) if (getattr(obj, '__name__', None) == 'shutdown' and obj in self.shutdown_methods): self.shutdown_methods.remove(obj) diff --git a/sopel/cli/config.py b/sopel/cli/config.py index c8a2d29ed3..692124b7f7 100644 --- a/sopel/cli/config.py +++ b/sopel/cli/config.py @@ -117,7 +117,7 @@ def handle_init(options): return 1 print('Starting Sopel config wizard for: %s' % config_filename) - config._wizard('all', config_name) + utils.wizard(config_name) def handle_get(options): diff --git a/sopel/cli/run.py b/sopel/cli/run.py index fd25383cd0..c21947b6b7 100755 --- a/sopel/cli/run.py +++ b/sopel/cli/run.py @@ -18,15 +18,7 @@ import time import traceback -from sopel import bot, logger, tools, __version__ -from sopel.config import ( - Config, - _create_config, - ConfigurationError, - ConfigurationNotFound, - DEFAULT_HOMEDIR, - _wizard -) +from sopel import bot, config, logger, tools, __version__ from . import utils if sys.version_info < (2, 7): @@ -48,10 +40,10 @@ """ -def run(config, pid_file, daemon=False): +def run(settings, pid_file, daemon=False): delay = 20 # Inject ca_certs from config to web for SSL validation of web requests - if not config.core.ca_certs: + if not settings.core.ca_certs: tools.stderr( 'Could not open CA certificates file. SSL will not work properly!') @@ -69,7 +61,7 @@ def signal_handler(sig, frame): if p and p.hasquit: # Check if `hasquit` was set for bot during disconnected phase break try: - p = bot.Sopel(config, daemon=daemon) + p = bot.Sopel(settings, daemon=daemon) if hasattr(signal, 'SIGUSR1'): signal.signal(signal.SIGUSR1, signal_handler) if hasattr(signal, 'SIGTERM'): @@ -81,7 +73,7 @@ def signal_handler(sig, frame): if hasattr(signal, 'SIGILL'): signal.signal(signal.SIGILL, signal_handler) logger.setup_logging(p) - p.run(config.core.host, int(config.core.port)) + p.run(settings.core.host, int(settings.core.port)) except KeyboardInterrupt: break except Exception: # TODO: Be specific @@ -90,7 +82,7 @@ def signal_handler(sig, frame): tools.stderr(trace) except Exception: # TODO: Be specific pass - logfile = open(os.path.join(config.core.logdir, 'exceptions.log'), 'a') + logfile = open(os.path.join(settings.core.logdir, 'exceptions.log'), 'a') logfile.write('Critical exception in core') logfile.write(trace) logfile.write('----------------------------------------\n\n') @@ -278,12 +270,12 @@ def print_version(): def print_config(): """Print list of available configurations from default homedir.""" - configs = utils.enumerate_configs(DEFAULT_HOMEDIR) - print('Config files in %s:' % DEFAULT_HOMEDIR) - config = None - for config in configs: - print('\t%s' % config) - if not config: + configs = utils.enumerate_configs(config.DEFAULT_HOMEDIR) + print('Config files in %s:' % config.DEFAULT_HOMEDIR) + configfile = None + for configfile in configs: + print('\t%s' % configfile) + if not configfile: print('\tNone found') print('-------------------------') @@ -308,23 +300,16 @@ def get_configuration(options): """ try: - bot_config = utils.load_settings(options) - except ConfigurationNotFound as error: + settings = utils.load_settings(options) + except config.ConfigurationNotFound as error: print( "Welcome to Sopel!\n" "I can't seem to find the configuration file, " "so let's generate it!\n") + settings = utils.wizard(error.filename) - config_path = error.filename - if not config_path.endswith('.cfg'): - config_path = config_path + '.cfg' - - config_path = _create_config(config_path) - # try to reload it now that it's created - bot_config = Config(config_path) - - bot_config._is_daemonized = options.daemonize - return bot_config + settings._is_daemonized = options.daemonize + return settings def get_pid_filename(options, pid_dir): @@ -374,7 +359,7 @@ def command_start(opts): # Step One: Get the configuration file and prepare to run try: config_module = get_configuration(opts) - except ConfigurationError as e: + except config.ConfigurationError as e: tools.stderr(e) return ERR_CODE_NO_RESTART @@ -421,10 +406,12 @@ def command_start(opts): def command_configure(opts): """Sopel Configuration Wizard""" + configpath = utils.find_config( + config.DEFAULT_HOMEDIR, opts.config or 'default') if getattr(opts, 'modules', False): - _wizard('mod', opts.config) + utils.plugins_wizard(configpath) else: - _wizard('all', opts.config) + utils.wizard(configpath) def command_stop(opts): @@ -432,7 +419,7 @@ def command_stop(opts): # Get Configuration try: settings = utils.load_settings(opts) - except ConfigurationNotFound as error: + except config.ConfigurationNotFound as error: tools.stderr('Configuration "%s" not found' % error.filename) return ERR_CODE @@ -471,7 +458,7 @@ def command_restart(opts): # Get Configuration try: settings = utils.load_settings(opts) - except ConfigurationNotFound as error: + except config.ConfigurationNotFound as error: tools.stderr('Configuration "%s" not found' % error.filename) return ERR_CODE @@ -533,18 +520,22 @@ def command_legacy(opts): print_version() return + # TODO: allow to use a different homedir + configpath = utils.find_config( + config.DEFAULT_HOMEDIR, opts.config or 'default') + if opts.wizard: tools.stderr( 'WARNING: option -w/--configure-all is deprecated; ' 'use `sopel configure` instead') - _wizard('all', opts.config) + utils.wizard(configpath) return if opts.mod_wizard: tools.stderr( 'WARNING: option --configure-modules is deprecated; ' 'use `sopel configure --modules` instead') - _wizard('mod', opts.config) + utils.plugins_wizard(configpath) return if opts.list_configs: @@ -554,7 +545,7 @@ def command_legacy(opts): # Step Two: Get the configuration file and prepare to run try: config_module = get_configuration(opts) - except ConfigurationError as e: + except config.ConfigurationError as e: tools.stderr(e) return ERR_CODE_NO_RESTART diff --git a/sopel/cli/utils.py b/sopel/cli/utils.py index 233366dfc7..1b84a1970a 100644 --- a/sopel/cli/utils.py +++ b/sopel/cli/utils.py @@ -4,7 +4,7 @@ import os import sys -from sopel import config, tools +from sopel import config, plugins, tools # Allow clean import * __all__ = [ @@ -13,9 +13,115 @@ 'add_common_arguments', 'load_settings', 'redirect_outputs', + 'wizard', + 'plugins_wizard', ] +def wizard(filename): + """Global Configuration Wizard + + :param str filename: name of the new file to be created + :return: the created configuration object + + This wizard function helps the creation of a Sopel configuration file, + with its core section and its plugins' sections. + """ + homedir, basename = os.path.split(filename) + if not basename: + raise config.ConfigurationError( + 'Sopel requires a filename for its configuration, not a directory') + + try: + if not os.path.isdir(homedir): + print('Creating config directory at {}'.format(homedir)) + os.makedirs(homedir) + print('Config directory created') + except Exception: + tools.stderr('There was a problem creating {}'.format(homedir)) + raise + + name, ext = os.path.splitext(basename) + if not ext: + # Always add .cfg if filename does not have an extension + filename = os.path.join(homedir, name + '.cfg') + elif ext != '.cfg': + # It is possible to use a non-cfg file for Sopel + # but the wizard does not allow it at the moment + raise config.ConfigurationError( + 'Sopel uses ".cfg" as configuration file extension, not "%s".' % ext) + + settings = config.Config(filename, validate=False) + + print("Please answer the following questions " + "to create your configuration file (%s):\n" % filename) + config.core_section.configure(settings) + if settings.option( + 'Would you like to see if there are any modules ' + 'that need configuring' + ): + _plugins_wizard(settings) + + try: + settings.save() + except Exception: # TODO: Be specific + tools.stderr("Encountered an error while writing the config file. " + "This shouldn't happen. Check permissions.") + raise + + print("Config file written successfully!") + return settings + + +def plugins_wizard(filename): + """Plugins Configuration Wizard + + :param str filename: path to an existing Sopel configuration + :return: the configuration object + + This wizard function helps to configure plugins for an existing Sopel + config file. + """ + if not os.path.isfile(filename): + raise config.ConfigurationNotFound(filename) + + settings = config.Config(filename, validate=False) + _plugins_wizard(settings) + + try: + settings.save() + except Exception: # TODO: Be specific + tools.stderr("Encountered an error while writing the config file. " + "This shouldn't happen. Check permissions.") + raise + + return settings + + +def _plugins_wizard(settings): + usable_plugins = plugins.get_usable_plugins(settings) + for plugin, is_enabled in usable_plugins.values(): + if not is_enabled: + # Do not configure non-enabled modules + continue + + name = plugin.name + try: + _plugin_wizard(settings, plugin) + except Exception as e: + filename, lineno = tools.get_raising_file_and_line() + rel_path = os.path.relpath(filename, os.path.dirname(__file__)) + raising_stmt = "%s:%d" % (rel_path, lineno) + tools.stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) + + +def _plugin_wizard(settings, plugin): + plugin.load() + prompt = 'Configure {} (y/n)? [n]'.format(plugin.get_label()) + if plugin.has_configure() and settings.option(prompt): + plugin.configure(settings) + + def enumerate_configs(config_dir, extension='.cfg'): """List configuration files from ``config_dir`` with ``extension`` diff --git a/sopel/config/__init__.py b/sopel/config/__init__.py index cf81b54ad4..19357a3398 100644 --- a/sopel/config/__init__.py +++ b/sopel/config/__init__.py @@ -22,11 +22,8 @@ import os import sys -import sopel.config.core_section -from sopel.config.types import StaticSection -import sopel.loader -import sopel.tools -from sopel.tools import get_input, iteritems, stderr +from sopel import tools +from . import core_section, types if sys.version_info.major < 3: import ConfigParser @@ -73,7 +70,7 @@ def __init__(self, filename, validate=True): """The config object's associated file, as noted above.""" self.parser = ConfigParser.RawConfigParser(allow_no_value=True) self.parser.read(self.filename) - self.define_section('core', sopel.config.core_section.CoreSection, + self.define_section('core', core_section.CoreSection, validate=validate) self.get = self.parser.get @@ -117,7 +114,7 @@ def define_section(self, name, cls_, validate=True): exception raised if they are invalid. This is desirable in a module's setup function, for example, but might not be in the configure function. """ - if not issubclass(cls_, StaticSection): + if not issubclass(cls_, types.StaticSection): raise ValueError("Class must be a subclass of StaticSection.") current = getattr(self, name, None) current_name = str(current.__class__) @@ -198,88 +195,7 @@ def option(self, question, default=False): d = 'n' if default: d = 'y' - ans = get_input(question + ' (y/n)? [' + d + '] ') + ans = tools.get_input(question + ' (y/n)? [' + d + '] ') if not ans: ans = d return ans.lower() == 'y' - - def _modules(self): - home = os.getcwd() - modules_dir = os.path.join(home, 'modules') - filenames = sopel.loader.enumerate_modules(self) - os.sys.path.insert(0, modules_dir) - for name, mod_spec in iteritems(filenames): - path, type_ = mod_spec - try: - module, _ = sopel.loader.load_module(name, path, type_) - except Exception as e: - filename, lineno = sopel.tools.get_raising_file_and_line() - rel_path = os.path.relpath(filename, os.path.dirname(__file__)) - raising_stmt = "%s:%d" % (rel_path, lineno) - stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) - else: - if hasattr(module, 'configure'): - prompt = name + ' module' - if module.__doc__: - doc = module.__doc__.split('\n', 1)[0] - if doc: - prompt = doc - prompt = 'Configure {} (y/n)? [n]'.format(prompt) - do_configure = get_input(prompt) - do_configure = do_configure and do_configure.lower() == 'y' - if do_configure: - module.configure(self) - self.save() - - -def _wizard(section, config=None): - dotdir = os.path.dirname(config) if config is not None else DEFAULT_HOMEDIR - configpath = os.path.join(dotdir, ((config or 'default.cfg') + ('.cfg' if config and not config.endswith('.cfg') else ''))) - if section == 'all': - _create_config(configpath) - elif section == 'mod': - _check_dir(dotdir, False) - if not os.path.isfile(configpath): - print("No config file found." + - " Please make one before configuring these options.") - sys.exit(1) - config = Config(configpath, validate=False) - config._modules() - - -def _check_dir(path=DEFAULT_HOMEDIR, create=True): - if not os.path.isdir(path): - if create: - print('Creating a config directory at {}...'.format(path)) - try: - os.makedirs(path) - except Exception as e: - print('There was a problem creating %s:' % path, file=sys.stderr) - print('%s, %s' % (e.__class__, str(e)), file=sys.stderr) - print('Please fix this and then run Sopel again.', file=sys.stderr) - sys.exit(1) - else: - print("No config file found. Please make one before configuring these options.") - sys.exit(1) - - -def _create_config(configpath): - _check_dir(os.path.dirname(configpath)) - print("Please answer the following questions" + - " to create your configuration file:\n") - try: - config = Config(configpath, validate=False) - sopel.config.core_section.configure(config) - if config.option( - 'Would you like to see if there are any modules' - ' that need configuring' - ): - config._modules() - config.save() - except Exception: # TODO: Be specific - print("Encountered an error while writing the config file." + - " This shouldn't happen. Check permissions.") - raise - - print("Config file written successfully!") - return config.filename diff --git a/sopel/loader.py b/sopel/loader.py index 763c96c4ca..83a1db8c8a 100644 --- a/sopel/loader.py +++ b/sopel/loader.py @@ -1,8 +1,6 @@ # coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division -import imp -import os.path import re import sys @@ -16,101 +14,6 @@ basestring = (str, bytes) -def get_module_description(path): - good_file = (os.path.isfile(path) and - path.endswith('.py') and not path.startswith('_')) - good_dir = (os.path.isdir(path) and - os.path.isfile(os.path.join(path, '__init__.py'))) - if good_file: - name = os.path.basename(path)[:-3] - return (name, path, imp.PY_SOURCE) - elif good_dir: - name = os.path.basename(path) - return (name, path, imp.PKG_DIRECTORY) - else: - return None - - -def _update_modules_from_dir(modules, directory): - # Note that this modifies modules in place - for path in os.listdir(directory): - path = os.path.join(directory, path) - result = get_module_description(path) - if result: - modules[result[0]] = result[1:] - - -def enumerate_modules(config, show_all=False): - """Map the names of modules to the location of their file. - - Return a dict mapping the names of modules to a tuple of the module name, - the pathname and either `imp.PY_SOURCE` or `imp.PKG_DIRECTORY`. This - searches the regular modules directory and all directories specified in the - `core.extra` attribute of the `config` object. If two modules have the same - name, the last one to be found will be returned and the rest will be - ignored. Modules are found starting in the regular directory, followed by - `~/.sopel/modules`, and then through the extra directories in the order - that the are specified. - - If `show_all` is given as `True`, the `enable` and `exclude` - configuration options will be ignored, and all modules will be shown - (though duplicates will still be ignored as above). - """ - modules = {} - - # First, add modules from the regular modules directory - main_dir = os.path.dirname(os.path.abspath(__file__)) - modules_dir = os.path.join(main_dir, 'modules') - _update_modules_from_dir(modules, modules_dir) - for path in os.listdir(modules_dir): - break - - # Then, find PyPI installed modules - # TODO does this work with all possible install mechanisms? - try: - import sopel_modules - except Exception: # TODO: Be specific - pass - else: - for directory in sopel_modules.__path__: - _update_modules_from_dir(modules, directory) - - # Next, look in ~/.sopel/modules - home_modules_dir = os.path.join(config.homedir, 'modules') - if not os.path.isdir(home_modules_dir): - os.makedirs(home_modules_dir) - _update_modules_from_dir(modules, home_modules_dir) - - # Last, look at all the extra directories. - for directory in config.core.extra: - _update_modules_from_dir(modules, directory) - - # Coretasks is special. No custom user coretasks. - ct_path = os.path.join(main_dir, 'coretasks.py') - modules['coretasks'] = (ct_path, imp.PY_SOURCE) - - # If caller wants all of them, don't apply white and blacklists - if show_all: - return modules - - # Apply whitelist, if present - enable = config.core.enable - if enable: - enabled_modules = {'coretasks': modules['coretasks']} - for module in enable: - if module in modules: - enabled_modules[module] = modules[module] - modules = enabled_modules - - # Apply blacklist, if present - exclude = config.core.exclude - for module in exclude: - if module in modules: - del modules[module] - - return modules - - def trim_docstring(doc): """Get the docstring as a series of lines that can be sent""" if not doc: @@ -168,10 +71,12 @@ def clean_callable(func, config): func.rule = getattr(func, 'rule', []) for command in getattr(func, 'commands', []): regexp = get_command_regexp(prefix, command) - func.rule.append(regexp) + if regexp not in func.rule: + func.rule.append(regexp) for command in getattr(func, 'nickname_commands', []): regexp = get_nickname_command_regexp(nick, command, alias_nicks) - func.rule.append(regexp) + if regexp not in func.rule: + func.rule.append(regexp) if hasattr(func, 'example'): # If no examples are flagged as user-facing, just show the first one like Sopel<7 did examples = [rec["example"] for rec in func.example if rec["help"]] or [func.example[0]["example"]] @@ -188,19 +93,14 @@ def clean_callable(func, config): func._docs[command] = (doc, examples) if hasattr(func, 'intents'): - func.intents = [re.compile(intent, re.IGNORECASE) for intent in func.intents] - - -def load_module(name, path, type_): - """Load a module, and sort out the callables and shutdowns""" - if type_ == imp.PY_SOURCE: - with open(path) as mod: - module = imp.load_module(name, mod, path, ('.py', 'U', type_)) - elif type_ == imp.PKG_DIRECTORY: - module = imp.load_module(name, None, path, ('', '', type_)) - else: - raise TypeError('Unsupported module type') - return module, os.path.getmtime(path) + # Can be implementation-dependent + _regex_type = type(re.compile('')) + func.intents = [ + (intent + if isinstance(intent, _regex_type) + else re.compile(intent, re.IGNORECASE)) + for intent in func.intents + ] def is_triggerable(obj): diff --git a/sopel/modules/reload.py b/sopel/modules/reload.py index 3780b39e72..5264328831 100644 --- a/sopel/modules/reload.py +++ b/sopel/modules/reload.py @@ -8,22 +8,27 @@ """ from __future__ import unicode_literals, absolute_import, print_function, division -import collections +import os import subprocess -import sys -import time -from sopel.tools import stderr, itervalues -import sopel.loader import sopel.module +from sopel import plugins, tools -try: - from importlib import reload -except ImportError: + +def _load(bot, plugin): + # handle errors while loading (if any) try: - from imp import reload - except ImportError: - pass # fallback to builtin if neither module is available + plugin.load() + if plugin.has_setup(): + plugin.setup(bot) + plugin.register(bot) + except Exception as e: + filename, lineno = tools.get_raising_file_and_line() + rel_path = os.path.relpath(filename, os.path.dirname(__file__)) + raising_stmt = "%s:%d" % (rel_path, lineno) + tools.stderr( + "Error loading %s: %s (%s)" % (plugin.name, e, raising_stmt)) + raise @sopel.module.nickname_commands("reload") @@ -35,89 +40,14 @@ def f_reload(bot, trigger): name = trigger.group(2) if not name or name == '*' or name.upper() == 'ALL THE THINGS': - bot._callables = { - 'high': collections.defaultdict(list), - 'medium': collections.defaultdict(list), - 'low': collections.defaultdict(list) - } - bot._command_groups = collections.defaultdict(list) - - for m in sopel.loader.enumerate_modules(bot.config): - reload_module_tree(bot, m, silent=True) - + bot.reload_plugins() return bot.reply('done') - if (name not in sys.modules and name not in sopel.loader.enumerate_modules(bot.config)): + if not bot.has_plugin(name): return bot.reply('"%s" not loaded, try the `load` command' % name) - reload_module_tree(bot, name) - - -def reload_module_tree(bot, name, seen=None, silent=False): - from types import ModuleType - - old_module = sys.modules[name] - - if seen is None: - seen = {} - if name not in seen: - seen[name] = [] - - for obj in itervalues(vars(old_module)): - if callable(obj): - if (getattr(obj, '__name__', None) == 'shutdown' and - obj in bot.shutdown_methods): - # If this is a shutdown method, call it first. - try: - stderr( - "calling %s.%s" % ( - obj.__module__, obj.__name__, - ) - ) - obj(bot) - except Exception as e: - stderr( - "Error calling shutdown method for module %s:%s" % ( - obj.__module__, e - ) - ) - bot.unregister(obj) - elif (type(obj) is ModuleType and - obj.__name__.startswith(name + '.') and - obj.__name__ not in sys.builtin_module_names): - # recurse into submodules, see issue 1056 - if obj not in seen[name]: - seen[name].append(obj) - reload(obj) - reload_module_tree(bot, obj.__name__, seen, silent) - - modules = sopel.loader.enumerate_modules(bot.config) - if name not in modules: - return # Only reload the top-level module, once recursion is finished - - # Also delete the setup function - # Sub-modules shouldn't have setup functions, so do after the recursion check - if hasattr(old_module, "setup"): - delattr(old_module, "setup") - - path, type_ = modules[name] - load_module(bot, name, path, type_, silent) - - -def load_module(bot, name, path, type_, silent=False): - module, mtime = sopel.loader.load_module(name, path, type_) - relevant_parts = sopel.loader.clean_module(module, bot.config) - - bot.register(*relevant_parts) - - # TODO sys.modules[name] = module - if hasattr(module, 'setup'): - module.setup(bot) - - modified = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(mtime)) - - if not silent: - bot.reply('%r (version: %s)' % (module, modified)) + bot.reload_plugin(name) + return bot.reply('done: %s reloaded' % name) @sopel.module.nickname_commands('update') @@ -139,18 +69,28 @@ def f_update(bot, trigger): def f_load(bot, trigger): """Loads a module (for use by admins only).""" name = trigger.group(2) - path = '' if not name: return bot.reply('Load what?') - if name in sys.modules: + if bot.has_plugin(name): return bot.reply('Module already loaded, use reload') - mods = sopel.loader.enumerate_modules(bot.config) - if name not in mods: - return bot.reply('Module %s not found' % name) - path, type_ = mods[name] - load_module(bot, name, path, type_) + usable_plugins = plugins.get_usable_plugins(bot.config) + if name not in usable_plugins: + bot.reply('Module %s not found' % name) + return + + plugin, is_enabled = usable_plugins[name] + if not is_enabled: + bot.reply('Module %s is disabled' % name) + return + + try: + _load(bot, plugin) + except Exception as error: + bot.reply('Could not load module %s: %s' % (name, error)) + else: + bot.reply('Module %s loaded' % name) # Catch private messages diff --git a/sopel/plugins/__init__.py b/sopel/plugins/__init__.py new file mode 100644 index 0000000000..518914aa30 --- /dev/null +++ b/sopel/plugins/__init__.py @@ -0,0 +1,189 @@ +# coding=utf-8 +"""Sopel's plugins interface + +.. versionadded:: 7.0 + +Sopel uses plugins (also called "modules") and uses what are called +Plugin Handlers as an interface between the bot and its plugins. This interface +is defined by the :class:`~.handlers.AbstractPluginHandler` abstract class. + +Plugins that can be used by Sopel are provided by :func:`~.get_usable_plugins` +in an :class:`ordered dict`. This dict contains one +and only one plugin per unique name, using a specific order: + +* extra directories defined in the settings +* homedir's ``modules`` directory +* ``sopel_modules``'s subpackages +* ``sopel.modules``'s core plugins + +(The ``coretasks`` plugin is *always* the one from ``sopel.coretasks`` and +cannot be overridden.) + +To find all plugins (no matter their sources), the :func:`~.enumerate_plugins` +function can be used. For a more fine-grained search, ``find_*`` functions +exist for each type of plugin. +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import collections +import imp +import itertools +import os + +from . import exceptions, handlers # noqa + + +def _list_plugin_filenames(directory): + # list plugin filenames from a directory + # yield 2-value tuples: (name, absolute path) + base = os.path.abspath(directory) + for filename in os.listdir(base): + abspath = os.path.join(base, filename) + + if os.path.isdir(abspath): + if os.path.isfile(os.path.join(abspath, '__init__.py')): + yield os.path.basename(filename), abspath + else: + name, ext = os.path.splitext(filename) + if ext == '.py' and name != '__init__': + yield name, abspath + + +def find_internal_plugins(): + """List internal plugins + + :return: Yield instance of :class:`~.handlers.PyModulePlugin` + configured for ``sopel.modules.*`` + """ + plugin_dir = imp.find_module( + 'modules', + [imp.find_module('sopel')[1]] + )[1] + + for name, _ in _list_plugin_filenames(plugin_dir): + yield handlers.PyModulePlugin(name, 'sopel.modules') + + +def find_sopel_modules_plugins(): + """List plugins from ``sopel_modules.*`` + + :return: Yield instance of :class:`~.handlers.PyModulePlugin` + configured for ``sopel_modules.*`` + """ + try: + import sopel_modules + except ImportError: + return + + for plugin_dir in set(sopel_modules.__path__): + for name, _ in _list_plugin_filenames(plugin_dir): + yield handlers.PyModulePlugin(name, 'sopel_modules') + + +def find_directory_plugins(directory): + """List plugins from a ``directory`` + + :param str directory: Directory path to search + :return: Yield instance of :class:`~.handlers.PyFilePlugin` + found in ``directory`` + """ + for _, abspath in _list_plugin_filenames(directory): + yield handlers.PyFilePlugin(abspath) + + +def enumerate_plugins(settings): + """Yield Sopel's plugins + + :param settings: Sopel's configuration + :type settings: :class:`sopel.config.Config` + :return: yield 2-value tuple: an instance of + :class:`~.handlers.AbstractPluginHandler`, and if the plugin is + active or not + + This function uses the find functions to find all of Sopel's available + plugins. It uses the bot's ``settings`` to determine if the plugin is + enabled or disabled. + + .. seealso:: + + The find functions used are: + + * :func:`find_internal_plugins` for internal plugins + * :func:`find_sopel_modules_plugins` for ``sopel_modules.*`` plugins + * :func:`find_directory_plugins` for modules in ``$homedir/modules`` + and in extra directories, as defined by ``settings.core.extra`` + + """ + from_internals = find_internal_plugins() + from_sopel_modules = find_sopel_modules_plugins() + # load from directories + source_dirs = [os.path.join(settings.homedir, 'modules')] + if settings.core.extra: + source_dirs = source_dirs + list(settings.core.extra) + + from_directories = [ + find_directory_plugins(source_dir) + for source_dir in source_dirs + if os.path.isdir(source_dir) + ] + + # Retrieve all plugins + all_plugins = itertools.chain( + from_internals, + from_sopel_modules, + *from_directories) + + # Get module settings + enabled = settings.core.enable + disabled = settings.core.exclude + + # Yield all found plugins with their enabled status (True/False) + for plugin in all_plugins: + name = plugin.name + is_enabled = name not in disabled and (not enabled or name in enabled) + yield plugin, is_enabled + + # And always yield coretasks + yield handlers.PyModulePlugin('coretasks', 'sopel'), True + + +def get_usable_plugins(settings): + """Get usable plugins, unique per name + + :param settings: Sopel's configuration + :type settings: :class:`sopel.config.Config` + :return: an ordered dict of usable plugins + :rtype: collections.OrderedDict + + This function provides the plugins Sopel can use to load, enable, + or disable, as an :class:`ordered dict`. This dict + contains one and only one plugin per unique name, using a specific order: + + * extra directories defined in the settings + * homedir's ``modules`` directory + * ``sopel_modules``'s subpackages + * ``sopel.modules``'s core plugins + + (The ``coretasks`` plugin is *always* the one from ``sopel.coretasks`` and + cannot be overridden.) + + .. seealso:: + + The :func:`~.enumerate_plugins` function is used to generate a list + of all possible plugins, and its return value is used to populate + the :class:`ordered dict`. + + """ + # Use an OrderedDict to get one and only one plugin per name + # based on what plugins.enumerate_plugins does, external plugins are + # allowed to override internal plugins + plugins_info = collections.OrderedDict( + (plugin.name, (plugin, is_enabled)) + for plugin, is_enabled in enumerate_plugins(settings)) + # reset coretasks's position at the end of the loading queue + # Python 2's OrderedDict does not have a `move_to_end` method + # TODO: replace by plugins_info.move_to_end('coretasks') for Python 3 + core_info = plugins_info.pop('coretasks') + plugins_info['coretasks'] = core_info + + return plugins_info diff --git a/sopel/plugins/exceptions.py b/sopel/plugins/exceptions.py new file mode 100644 index 0000000000..8c49d5bda0 --- /dev/null +++ b/sopel/plugins/exceptions.py @@ -0,0 +1,15 @@ +# coding=utf-8 +"""Sopel's plugins exceptions.""" +from __future__ import unicode_literals, absolute_import, print_function, division + + +class PluginError(Exception): + """Base class for plugin related exceptions.""" + + +class PluginNotRegistered(PluginError): + """Exception raised when a plugin is not registered.""" + def __init__(self, name): + message = 'Plugin "%s" not registered' % name + self.plugin_name = name + super(PluginNotRegistered, self).__init__(message) diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py new file mode 100644 index 0000000000..7dbc3bc881 --- /dev/null +++ b/sopel/plugins/handlers.py @@ -0,0 +1,332 @@ +# coding=utf-8 +"""Sopel's plugin handlers + +.. versionadded:: 7.0 + +Between a plugin (or "module") and Sopel's core, Plugin Handlers are used. It +is an interface (defined by the :class:`AbstractPluginHandler` abstract class), +that acts as a proxy between Sopel and the plugin, making a clear separation +between how the bot behaves and how the plugins work. + +From the :class:`~sopel.bot.Sopel` class, a plugin must be: + +* loaded, using :meth:`~AbstractPluginHandler.load` +* setup (if required), using :meth:`~AbstractPluginHandler.setup` +* and eventually registered using :meth:`~AbstractPluginHandler.register` + +Each subclass of :class:`AbstractPluginHandler` must implement its methods in +order to be used in the application. + +At the moment, only two types of plugin are handled: + +* :class:`PyModulePlugin`: manage plugins that can be imported as Python + module from a Python package, i.e. where ``from package import name`` works +* :class:`PyFilePlugin`: manage plugins that are Python files on the filesystem + or Python directory (with an ``__init__.py`` file inside), that cannot be + directly imported and extra steps are necessary + +Both expose the same interface and thereby abstract the internal implementation +away from the rest of the application. +""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import inspect +import imp +import importlib +import os + +from sopel import loader +from . import exceptions + +try: + from importlib import reload +except ImportError: + # py2: no reload function + # TODO: imp is deprecated, to be removed when py2 support is dropped + from imp import reload + + +class AbstractPluginHandler(object): + """Base class for plugin handlers. + + This abstract class defines the interface Sopel uses to + configure, load, shutdown, etc. a Sopel plugin (or "module"). + + It is through this interface that Sopel will interact with its plugins, + whether internal (from ``sopel.modules``) or external (from the Python + files in a directory, to ``sopel_modules.*`` subpackages). + + Sopel's loader will create a "Plugin Handler" for each plugin it finds, to + which it then delegates loading the plugin, listing its functions + (commands, jobs, etc.), configuring it, and running any required actions + on shutdown (either upon exiting Sopel or unloading that plugin). + """ + def load(self): + """Load the plugin + + This method must be called first, in order to setup, register, shutdown, + or configure the plugin later. + """ + raise NotImplementedError + + def reload(self): + """Reload the plugin + + This method can be called once the plugin is already loaded. It will + take care of reloading the plugin from its source. + """ + raise NotImplementedError + + def get_label(self): + """Retrieve a display label for the plugin + + :return: A human readable label for display purpose + :rtype: str + + This method should, at least, return ``module_name + S + "module"``. + """ + raise NotImplementedError + + def is_loaded(self): + """Tell if the plugin is loaded or not + + :return: ``True`` if the plugin is loaded, ``False`` otherwise + :rtype: boolean + + This must return ``True`` if the :meth:`load` method has been called + with success. + """ + raise NotImplementedError + + def setup(self, bot): + """Setup the plugin with the ``bot`` + + :param bot: instance of Sopel + :type bot: :class:`sopel.bot.Sopel` + """ + raise NotImplementedError + + def has_setup(self): + """Tell if the plugin has a setup action + + :return: ``True`` if the plugin has a setup, ``False`` otherwise + :rtype: boolean + """ + raise NotImplementedError + + def register(self, bot): + """Register the plugin with the ``bot`` + + :param bot: instance of Sopel + :type bot: :class:`sopel.bot.Sopel` + """ + raise NotImplementedError + + def unregister(self, bot): + """Unregister the plugin from the ``bot`` + + :param bot: instance of Sopel + :type bot: :class:`sopel.bot.Sopel` + """ + raise NotImplementedError + + def shutdown(self, bot): + """Take action on bot's shutdown + + :param bot: instance of Sopel + :type bot: :class:`sopel.bot.Sopel` + """ + raise NotImplementedError + + def has_shutdown(self): + """Tell if the plugin has a shutdown action + + :return: ``True`` if the plugin has a ``shutdown`` action, ``False`` + otherwise + :rtype: boolean + """ + raise NotImplementedError + + def configure(self, settings): + """Configure Sopel's ``settings`` for this plugin + + :param settings: Sopel's configuration + :type settings: :class:`sopel.config.Config` + + This method will be called by Sopel's configuration wizard. + """ + raise NotImplementedError + + def has_configure(self): + """Tell if the plugin has a configure action + + :return: ``True`` if the plugin has a ``configure`` action, ``False`` + otherwise + :rtype: boolean + """ + raise NotImplementedError + + +class PyModulePlugin(AbstractPluginHandler): + """Sopel plugin loaded from a Python module or package + + A :class:`PyModulePlugin` represents a Sopel plugin that is a Python + module (or package) that can be imported directly. + + This:: + + >>> import sys + >>> from sopel.plugins.handlers import PyModulePlugin + >>> plugin = PyModulePlugin('xkcd', 'sopel.modules') + >>> plugin.module_name + 'sopel.modules.xkcd' + >>> plugin.load() + >>> plugin.module_name in sys.modules + True + + Is the same as this:: + + >>> import sys + >>> from sopel.modules import xkcd + >>> 'sopel.modules.xkcd' in sys.modules + True + + """ + def __init__(self, name, package=None): + self.name = name + self.package = package + if package: + self.module_name = self.package + '.' + self.name + else: + self.module_name = name + + self._module = None + + def get_label(self): + default_label = '%s module' % self.name + module_doc = getattr(self._module, '__doc__', None) + + if not self.is_loaded() or not module_doc: + return default_label + + lines = inspect.cleandoc(module_doc).splitlines() + return default_label if not lines else lines[0] + + def load(self): + self._module = importlib.import_module(self.module_name) + + def reload(self): + self._module = reload(self._module) + + def is_loaded(self): + return self._module is not None + + def setup(self, bot): + if self.has_setup(): + self._module.setup(bot) + + def has_setup(self): + return hasattr(self._module, 'setup') + + def register(self, bot): + relevant_parts = loader.clean_module(self._module, bot.config) + bot.add_plugin(self, *relevant_parts) + + def unregister(self, bot): + relevant_parts = loader.clean_module(self._module, bot.config) + bot.remove_plugin(self, *relevant_parts) + + def shutdown(self, bot): + if self.has_shutdown(): + self._module.shutdown(bot) + + def has_shutdown(self): + return hasattr(self._module, 'shutdown') + + def configure(self, settings): + if self.has_configure(): + self._module.configure(settings) + + def has_configure(self): + return hasattr(self._module, 'configure') + + +class PyFilePlugin(PyModulePlugin): + """Sopel plugin loaded from the filesystem outside of the Python path + + This plugin handler can be used to load a Sopel plugin from the + filesystem, either a Python ``.py`` file or a directory containing an + ``__init__.py`` file, and behaves like a :class:`PyModulePlugin`:: + + >>> from sopel.plugins.handlers import PyFilePlugin + >>> plugin = PyFilePlugin('/home/sopel/.sopel/modules/custom.py') + >>> plugin.load() + >>> plugin.name + 'custom' + + In this example, the plugin ``custom`` is loaded from its filename despite + not being in the Python path. + """ + def __init__(self, filename): + good_file = ( + os.path.isfile(filename) and + filename.endswith('.py') and not filename.startswith('_') + ) + good_dir = ( + os.path.isdir(filename) and + os.path.isfile(os.path.join(filename, '__init__.py')) + ) + + if good_file: + name = os.path.basename(filename)[:-3] + module_type = imp.PY_SOURCE + elif good_dir: + name = os.path.basename(filename) + module_type = imp.PKG_DIRECTORY + else: + raise exceptions.PluginError('Invalid Sopel plugin: %s' % filename) + + self.filename = filename + self.path = filename + self.module_type = module_type + + super(PyFilePlugin, self).__init__(name) + + def _load(self): + # The current implementation uses `imp.load_module` to perform the + # load action, which also reload the module. However, `imp` is + # deprecated in Python 3, so that might need to be changed when the + # support for Python 2 is dropped. + # + # However, the solution for Python 3 is non-trivial, since the + # `importlib` built-in module does not have a similar function, + # therefore requires to dive into its public internals + # (``importlib.machinery`` and ``importlib.util``). + # + # All of that is doable, but represents a lot of work. As long as + # Python 2 is supported, we can keep it for now. + # + # TODO: switch to ``importlib`` when Python2 support is dropped. + if self.module_type == imp.PY_SOURCE: + with open(self.path) as mod: + description = ('.py', 'U', self.module_type) + mod = imp.load_module(self.name, mod, self.path, description) + elif self.module_type == imp.PKG_DIRECTORY: + description = ('', '', self.module_type) + mod = imp.load_module(self.name, None, self.path, description) + else: + raise TypeError('Unsupported module type') + + return mod + + def load(self): + self._module = self._load() + + def reload(self): + """Reload the plugin + + Unlike :class:`PyModulePlugin`, it is not possible to use the + ``reload`` function (either from `imp` or `importlib`), because the + module might not be available through ``sys.path``. + """ + self._module = self._load() diff --git a/sopel/tools/jobs.py b/sopel/tools/jobs.py index 266c8bc9c3..690ee5fce8 100644 --- a/sopel/tools/jobs.py +++ b/sopel/tools/jobs.py @@ -56,6 +56,14 @@ def stop(self): """ self.stopping.set() + def remove_callable_job(self, callable): + """Removes specific callable from job queue""" + with self._mutex: + self._jobs = [ + job for job in self._jobs + if job.func != callable + ] + def run(self): """Run forever until :attr:`stopping` event is set.""" while not self.stopping.is_set(): diff --git a/test/test_bot.py b/test/test_bot.py new file mode 100644 index 0000000000..34543b794e --- /dev/null +++ b/test/test_bot.py @@ -0,0 +1,60 @@ +# coding=utf-8 +"""Tests for core ``sopel.bot`` module""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import pytest + +from sopel import bot, config, plugins + + +@pytest.fixture +def tmpconfig(tmpdir): + conf_file = tmpdir.join('conf.ini') + conf_file.write("\n".join([ + "[core]", + "owner=testnick", + "nick = TestBot", + "enable = coretasks" + "" + ])) + return config.Config(conf_file.strpath) + + +def test_remove_plugin_unknown_plugin(tmpconfig): + sopel = bot.Sopel(tmpconfig, daemon=False) + sopel.scheduler.stop() + sopel.scheduler.join(timeout=10) + + plugin = plugins.handlers.PyModulePlugin('admin', 'sopel.modules') + with pytest.raises(plugins.exceptions.PluginNotRegistered): + sopel.remove_plugin(plugin, [], [], [], []) + + +def test_remove_plugin_unregistered_plugin(tmpconfig): + sopel = bot.Sopel(tmpconfig, daemon=False) + sopel.scheduler.stop() + sopel.scheduler.join(timeout=10) + plugin = sopel._plugins.get('coretasks') + + assert plugin is not None, 'coretasks should always be loaded' + + # Unregister the plugin + plugin.unregister(sopel) + # And now it must raise an exception + with pytest.raises(plugins.exceptions.PluginNotRegistered): + sopel.remove_plugin(plugin, [], [], [], []) + + +def test_reload_plugin_unregistered_plugin(tmpconfig): + sopel = bot.Sopel(tmpconfig, daemon=False) + sopel.scheduler.stop() + sopel.scheduler.join(timeout=10) + plugin = sopel._plugins.get('coretasks') + + assert plugin is not None, 'coretasks should always be loaded' + + # Unregister the plugin + plugin.unregister(sopel) + # And now it must raise an exception + with pytest.raises(plugins.exceptions.PluginNotRegistered): + sopel.reload_plugin(plugin.name) diff --git a/test/test_loader.py b/test/test_loader.py index 534168709b..6766c31e89 100644 --- a/test/test_loader.py +++ b/test/test_loader.py @@ -2,13 +2,11 @@ """Tests for the ``sopel.loader`` module.""" from __future__ import unicode_literals, absolute_import, print_function, division -import imp import inspect -import os import pytest -from sopel import loader, config, module +from sopel import loader, config, module, plugins MOCK_MODULE_CONTENT = """# coding=utf-8 @@ -76,70 +74,15 @@ def tmpconfig(tmpdir): return config.Config(conf_file.strpath) -def test_get_module_description_good_file(tmpdir): - root = tmpdir.mkdir('loader_mods') - test_file = root.join('file_module.py') - test_file.write('') - - filename = test_file.strpath - assert loader.get_module_description(filename) == ( - 'file_module', filename, imp.PY_SOURCE - ) - - -def test_get_module_description_bad_file_pyc(tmpdir): - root = tmpdir.mkdir('loader_mods') - test_file = root.join('file_module.pyc') - test_file.write('') - - filename = test_file.strpath - assert loader.get_module_description(filename) is None - - -def test_get_module_description_bad_file_no_ext(tmpdir): - root = tmpdir.mkdir('loader_mods') - test_file = root.join('file_module') - test_file.write('') - - filename = test_file.strpath - assert loader.get_module_description(filename) is None - - -def test_get_module_description_good_dir(tmpdir): - root = tmpdir.mkdir('loader_mods') - test_dir = root.mkdir('dir_package') - test_dir.join('__init__.py').write('') - - filename = test_dir.strpath - assert loader.get_module_description(filename) == ( - 'dir_package', filename, imp.PKG_DIRECTORY - ) - - -def test_get_module_description_bad_dir_empty(tmpdir): - root = tmpdir.mkdir('loader_mods') - test_dir = root.mkdir('dir_package') - - filename = test_dir.strpath - assert loader.get_module_description(filename) is None - - -def test_get_module_description_bad_dir_no_init(tmpdir): - root = tmpdir.mkdir('loader_mods') - test_dir = root.mkdir('dir_package') - test_dir.join('no_init.py').write('') - - filename = test_dir.strpath - assert loader.get_module_description(filename) is None - - def test_clean_module_commands(tmpdir, tmpconfig): root = tmpdir.mkdir('loader_mods') mod_file = root.join('file_mod.py') mod_file.write(MOCK_MODULE_CONTENT) - test_mod, _ = loader.load_module( - 'file_mod', mod_file.strpath, imp.PY_SOURCE) + plugin = plugins.handlers.PyFilePlugin(mod_file.strpath) + plugin.load() + test_mod = plugin._module + callables, jobs, shutdowns, urls = loader.clean_module( test_mod, tmpconfig) @@ -193,6 +136,10 @@ def test_clean_callable_event(tmpconfig, func): assert hasattr(func, 'event') assert func.event == ['LOW', 'UP', 'MIXED'] + # idempotency + loader.clean_callable(func, tmpconfig) + assert func.event == ['LOW', 'UP', 'MIXED'] + def test_clean_callable_event_string(tmpconfig, func): setattr(func, 'event', 'some') @@ -201,6 +148,10 @@ def test_clean_callable_event_string(tmpconfig, func): assert hasattr(func, 'event') assert func.event == ['SOME'] + # idempotency + loader.clean_callable(func, tmpconfig) + assert func.event == ['SOME'] + def test_clean_callable_rule(tmpconfig, func): setattr(func, 'rule', [r'abc']) @@ -215,6 +166,11 @@ def test_clean_callable_rule(tmpconfig, func): assert regex.match('abcd') assert not regex.match('efg') + # idempotency + loader.clean_callable(func, tmpconfig) + assert len(func.rule) == 1 + assert regex in func.rule + def test_clean_callable_rule_string(tmpconfig, func): setattr(func, 'rule', r'abc') @@ -229,6 +185,11 @@ def test_clean_callable_rule_string(tmpconfig, func): assert regex.match('abcd') assert not regex.match('efg') + # idempotency + loader.clean_callable(func, tmpconfig) + assert len(func.rule) == 1 + assert regex in func.rule + def test_clean_callable_rule_nick(tmpconfig, func): """Assert ``$nick`` in a rule will match ``TestBot: `` or ``TestBot, ``.""" @@ -244,6 +205,11 @@ def test_clean_callable_rule_nick(tmpconfig, func): assert regex.match('TestBot, hello') assert not regex.match('TestBot not hello') + # idempotency + loader.clean_callable(func, tmpconfig) + assert len(func.rule) == 1 + assert regex in func.rule + def test_clean_callable_rule_nickname(tmpconfig, func): """Assert ``$nick`` in a rule will match ``TestBot``.""" @@ -258,6 +224,11 @@ def test_clean_callable_rule_nickname(tmpconfig, func): assert regex.match('TestBot hello') assert not regex.match('TestBot not hello') + # idempotency + loader.clean_callable(func, tmpconfig) + assert len(func.rule) == 1 + assert regex in func.rule + def test_clean_callable_nickname_command(tmpconfig, func): setattr(func, 'nickname_commands', ['hello!']) @@ -275,6 +246,11 @@ def test_clean_callable_nickname_command(tmpconfig, func): assert regex.match('TestBot: hello!') assert not regex.match('TestBot not hello') + # idempotency + loader.clean_callable(func, tmpconfig) + assert len(func.rule) == 1 + assert regex in func.rule + def test_clean_callable_events(tmpconfig, func): setattr(func, 'event', ['TOPIC']) @@ -509,50 +485,7 @@ def test_clean_callable_intents(tmpconfig, func): assert regex.match('AbCdE') assert not regex.match('efg') - -def test_load_module_pymod(tmpdir): - root = tmpdir.mkdir('loader_mods') - mod_file = root.join('file_mod.py') - mod_file.write(MOCK_MODULE_CONTENT) - - test_mod, timeinfo = loader.load_module( - 'file_mod', mod_file.strpath, imp.PY_SOURCE) - - assert hasattr(test_mod, 'first_command') - assert hasattr(test_mod, 'second_command') - assert hasattr(test_mod, 'interval5s') - assert hasattr(test_mod, 'interval10s') - assert hasattr(test_mod, 'example_url') - assert hasattr(test_mod, 'shutdown') - assert hasattr(test_mod, 'ignored') - - assert timeinfo == os.path.getmtime(mod_file.strpath) - - -def test_load_module_pypackage(tmpdir): - root = tmpdir.mkdir('loader_mods') - package_dir = root.mkdir('dir_mod') - mod_file = package_dir.join('__init__.py') - mod_file.write(MOCK_MODULE_CONTENT) - - test_mod, timeinfo = loader.load_module( - 'dir_mod', package_dir.strpath, imp.PKG_DIRECTORY) - - assert hasattr(test_mod, 'first_command') - assert hasattr(test_mod, 'second_command') - assert hasattr(test_mod, 'interval5s') - assert hasattr(test_mod, 'interval10s') - assert hasattr(test_mod, 'example_url') - assert hasattr(test_mod, 'shutdown') - assert hasattr(test_mod, 'ignored') - - assert timeinfo == os.path.getmtime(package_dir.strpath) - - -def test_load_module_error(tmpdir): - root = tmpdir.mkdir('loader_mods') - mod_file = root.join('file_mod.py') - mod_file.write(MOCK_MODULE_CONTENT) - - with pytest.raises(TypeError): - loader.load_module('file_mod', mod_file.strpath, None) + # idempotency + loader.clean_callable(func, tmpconfig) + assert len(func.intents) == 1 + assert regex in func.intents diff --git a/test/test_plugins.py b/test/test_plugins.py new file mode 100644 index 0000000000..10e2b1df6c --- /dev/null +++ b/test/test_plugins.py @@ -0,0 +1,125 @@ +# coding=utf-8 +"""Test for the ``sopel.plugins`` module.""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import pytest + +from sopel import plugins + + +MOCK_MODULE_CONTENT = """# coding=utf-8 +import sopel.module + + +@sopel.module.commands("first") +def first_command(bot, trigger): + pass + + +@sopel.module.commands("second") +def second_command(bot, trigger): + pass + + +@sopel.module.interval(5) +def interval5s(bot): + pass + + +@sopel.module.interval(10) +def interval10s(bot): + pass + + +@sopel.module.url(r'.\\.example\\.com') +def example_url(bot): + pass + + +@sopel.module.event('TOPIC') +def on_topic_command(bot): + pass + + +def shutdown(): + pass + + +def ignored(): + pass + +""" + + +def test_plugin_load_pymod(tmpdir): + root = tmpdir.mkdir('loader_mods') + mod_file = root.join('file_mod.py') + mod_file.write(MOCK_MODULE_CONTENT) + + plugin = plugins.handlers.PyFilePlugin(mod_file.strpath) + plugin.load() + + test_mod = plugin._module + + assert hasattr(test_mod, 'first_command') + assert hasattr(test_mod, 'second_command') + assert hasattr(test_mod, 'interval5s') + assert hasattr(test_mod, 'interval10s') + assert hasattr(test_mod, 'example_url') + assert hasattr(test_mod, 'shutdown') + assert hasattr(test_mod, 'ignored') + + +def test_plugin_load_pymod_bad_file_pyc(tmpdir): + root = tmpdir.mkdir('loader_mods') + test_file = root.join('file_module.pyc') + test_file.write('') + + with pytest.raises(Exception): + plugins.handlers.PyFilePlugin(test_file.strpath) + + +def test_plugin_load_pymod_bad_file_no_ext(tmpdir): + root = tmpdir.mkdir('loader_mods') + test_file = root.join('file_module') + test_file.write('') + + with pytest.raises(Exception): + plugins.handlers.PyFilePlugin(test_file.strpath) + + +def test_plugin_load_pypackage(tmpdir): + root = tmpdir.mkdir('loader_mods') + package_dir = root.mkdir('dir_mod') + mod_file = package_dir.join('__init__.py') + mod_file.write(MOCK_MODULE_CONTENT) + + plugin = plugins.handlers.PyFilePlugin(package_dir.strpath) + plugin.load() + + test_mod = plugin._module + + assert hasattr(test_mod, 'first_command') + assert hasattr(test_mod, 'second_command') + assert hasattr(test_mod, 'interval5s') + assert hasattr(test_mod, 'interval10s') + assert hasattr(test_mod, 'example_url') + assert hasattr(test_mod, 'shutdown') + assert hasattr(test_mod, 'ignored') + + +def test_plugin_load_pypackage_bad_dir_empty(tmpdir): + root = tmpdir.mkdir('loader_mods') + package_dir = root.mkdir('dir_package') + + with pytest.raises(Exception): + plugins.handlers.PyFilePlugin(package_dir.strpath) + + +def test_plugin_load_pypackage_bad_dir_no_init(tmpdir): + root = tmpdir.mkdir('loader_mods') + package_dir = root.mkdir('dir_package') + package_dir.join('no_init.py').write('') + + with pytest.raises(Exception): + plugins.handlers.PyFilePlugin(package_dir.strpath)