diff --git a/docs/source/cli.rst b/docs/source/cli.rst index efb666beea..347d421d1c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -1,3 +1,4 @@ +======================= Command Line Interfaces ======================= @@ -15,6 +16,13 @@ to, and channels to join. By default, it creates the file Once this is done, the ``start`` subcommand runs the bot, using this configuration file unless one is provided using the ``-c``/``--config`` option. +.. contents:: + :local: + :depth: 1 + + +The ``sopel`` command +===================== .. autoprogram:: sopel.cli.run:build_parser() :prog: sopel @@ -35,3 +43,27 @@ configuration file unless one is provided using the ``-c``/``--config`` option. .. autoprogram:: sopel.cli.run:build_parser() :prog: sopel :start_command: configure + + +The ``sopel-config`` command +============================ + +.. versionadded:: 7.0 + + The command ``sopel-config`` and its subcommands have been added in + Sopel 7.0. + +.. autoprogram:: sopel.cli.config:build_parser() + :prog: sopel-config + + +The ``sopel-plugins`` command +============================= + +.. versionadded:: 7.0 + + The command ``sopel-plugins`` and its subcommands have been added in + Sopel 7.0. + +.. autoprogram:: sopel.cli.plugins:build_parser() + :prog: sopel-plugins diff --git a/setup.py b/setup.py index 97e4d82843..b95d07f0e5 100755 --- a/setup.py +++ b/setup.py @@ -63,6 +63,7 @@ def read_reqs(path): 'console_scripts': [ 'sopel = sopel.cli.run:main', 'sopel-config = sopel.cli.config:main', + 'sopel-plugins = sopel.cli.plugins:main', ], }, python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, <4', diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py new file mode 100644 index 0000000000..eb83ca745e --- /dev/null +++ b/sopel/cli/plugins.py @@ -0,0 +1,506 @@ +# coding=utf-8 +"""Sopel Plugins Command Line Interface (CLI): ``sopel-plugins``""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import argparse +import inspect +import operator + +from sopel import plugins, tools + +from . import utils + + +def build_parser(): + """Configure an argument parser for ``sopel-plugins``""" + parser = argparse.ArgumentParser( + description='Sopel plugins tool') + + # Subparser: sopel-plugins + subparsers = parser.add_subparsers( + help='Action to perform', + dest='action') + + # sopel-plugins show + show_parser = subparsers.add_parser( + 'show', + formatter_class=argparse.RawTextHelpFormatter, + help="Show plugin details", + description="Show detailed information about a plugin.") + utils.add_common_arguments(show_parser) + show_parser.add_argument('name', help='Plugin name') + + # sopel-plugins configure + config_parser = subparsers.add_parser( + 'configure', + formatter_class=argparse.RawTextHelpFormatter, + help="Configure plugin with a config wizard", + description=inspect.cleandoc(""" + Run a config wizard to configure a plugin. + + This can be used whether the plugin is enabled or not. + """)) + utils.add_common_arguments(config_parser) + config_parser.add_argument('name', help='Plugin name') + + # sopel-plugins list + list_parser = subparsers.add_parser( + 'list', + formatter_class=argparse.RawTextHelpFormatter, + help="List available Sopel plugins", + description=inspect.cleandoc(""" + List available Sopel plugins from all possible sources. + + Plugin sources are: built-in, from ``sopel_modules.*``, + from ``sopel.plugins`` entry points, or Sopel's plugin directories. + + Enabled plugins are displayed in green; disabled, in red. + """)) + utils.add_common_arguments(list_parser) + list_parser.add_argument( + '-C', '--no-color', + help='Disable colors', + dest='no_color', + action='store_true', + default=False) + list_enable = list_parser.add_mutually_exclusive_group(required=False) + list_enable.add_argument( + '-e', '--enabled-only', + help='Display only enabled plugins', + dest='enabled_only', + action='store_true', + default=False) + list_enable.add_argument( + '-d', '--disabled-only', + help='Display only disabled plugins', + dest='disabled_only', + action='store_true', + default=False) + list_parser.add_argument( + '-n', '--name-only', + help='Display only plugin names', + dest='name_only', + action='store_true', + default=False) + + # sopel-plugins disable + disable_parser = subparsers.add_parser( + 'disable', + formatter_class=argparse.RawTextHelpFormatter, + help="Disable a Sopel plugins", + description=inspect.cleandoc(""" + Disable a Sopel plugin by its name, no matter where it comes from. + + It is not possible to disable the ``coretasks`` plugin. + """)) + utils.add_common_arguments(disable_parser) + disable_parser.add_argument( + 'names', metavar='name', nargs='+', + help=inspect.cleandoc(""" + Name of the plugin to disable. + Can be used multiple times to disable multiple plugins at once. + In case of error, configuration is not modified. + """)) + disable_parser.add_argument( + '-f', '--force', action='store_true', default=False, + help=inspect.cleandoc(""" + Force exclusion of the plugin. + When ``core.enable`` is defined, a plugin may be disabled without + being excluded. In this case, use this option to force + its exclusion. + """)) + disable_parser.add_argument( + '-r', '--remove', action='store_true', default=False, + help="Remove from ``core.enable`` list if applicable.") + + # sopel-plugins enable + enable_parser = subparsers.add_parser( + 'enable', + formatter_class=argparse.RawTextHelpFormatter, + help="Enable a Sopel plugin", + description=inspect.cleandoc(""" + Enable a Sopel plugin by its name, no matter where it comes from. + + The ``coretasks`` plugin is always enabled. + + By default, a plugin that is not excluded is enabled, unless at + least one plugin is defined in the ``core.enable`` list. + In that case, Sopel uses an "allow-only" policy for plugins, and + all desired plugins must be added to this list. + """)) + utils.add_common_arguments(enable_parser) + enable_parser.add_argument( + 'names', metavar='name', nargs='+', + help=inspect.cleandoc(""" + Name of the plugin to enable. + Can be used multiple times to enable multiple plugins at once. + In case of error, configuration is not modified. + """)) + enable_parser.add_argument( + '-a', '--allow-only', + dest='allow_only', + action='store_true', + default=False, + help=inspect.cleandoc(""" + Enforce allow-only policy. + It makes sure the plugin is added to the ``core.enable`` list. + """)) + + return parser + + +def handle_list(options): + """List Sopel plugins""" + settings = utils.load_settings(options) + no_color = options.no_color + name_only = options.name_only + enabled_only = options.enabled_only + disabled_only = options.disabled_only + + # get usable plugins + items = ( + (name, info[0], info[1]) + for name, info in plugins.get_usable_plugins(settings).items() + ) + items = ( + (name, plugin, is_enabled) + for name, plugin, is_enabled in items + ) + # filter on enabled/disabled if required + if enabled_only: + items = ( + (name, plugin, is_enabled) + for name, plugin, is_enabled in items + if is_enabled + ) + elif disabled_only: + items = ( + (name, plugin, is_enabled) + for name, plugin, is_enabled in items + if not is_enabled + ) + # sort plugins + items = sorted(items, key=operator.itemgetter(0)) + + for name, plugin, is_enabled in items: + description = { + 'name': name, + 'status': 'enabled' if is_enabled else 'disabled', + } + + # optional meta description from the plugin itself + try: + plugin.load() + description.update(plugin.get_meta_description()) + + # colorize name for display purpose + if not no_color: + if is_enabled: + description['name'] = utils.green(name) + else: + description['name'] = utils.red(name) + except Exception as error: + label = ('%s' % error) or 'unknown loading exception' + error_status = 'error' + description.update({ + 'label': 'Error: %s' % label, + 'type': 'unknown', + 'source': 'unknown', + 'status': error_status, + }) + if not no_color: + if is_enabled: + # yellow instead of green + description['name'] = utils.yellow(name) + else: + # keep it red for disabled plugins + description['name'] = utils.red(name) + description['status'] = utils.red(error_status) + + template = '{name}/{type} {label} ({source}) [{status}]' + if name_only: + template = '{name}' + + print(template.format(**description)) + + +def handle_show(options): + """Show plugin details""" + plugin_name = options.name + settings = utils.load_settings(options) + usable_plugins = plugins.get_usable_plugins(settings) + + # plugin does not exist + if plugin_name not in usable_plugins: + tools.stderr('No plugin named %s' % plugin_name) + return 1 + + plugin, is_enabled = usable_plugins[plugin_name] + description = { + 'name': plugin_name, + 'status': 'enabled' if is_enabled else 'disabled', + } + + # optional meta description from the plugin itself + loaded = False + try: + plugin.load() + description.update(plugin.get_meta_description()) + loaded = True + except Exception as error: + label = ('%s' % error) or 'unknown loading exception' + error_status = 'error' + description.update({ + 'label': 'Error: %s' % label, + 'type': 'unknown', + 'source': 'unknown', + 'status': error_status, + }) + + print('Plugin:', description['name']) + print('Status:', description['status']) + print('Type:', description['type']) + print('Source:', description['source']) + print('Label:', description['label']) + + if not loaded: + print('Loading failed') + return 1 + + print('Loaded successfully') + print('Setup:', 'yes' if plugin.has_setup() else 'no') + print('Shutdown:', 'yes' if plugin.has_shutdown() else 'no') + print('Configure:', 'yes' if plugin.has_configure() else 'no') + + +def handle_configure(options): + """Configure a Sopel plugin with a config wizard""" + plugin_name = options.name + settings = utils.load_settings(options) + usable_plugins = plugins.get_usable_plugins(settings) + + # plugin does not exist + if plugin_name not in usable_plugins: + tools.stderr('No plugin named %s' % plugin_name) + return 1 + + plugin, is_enabled = usable_plugins[plugin_name] + try: + plugin.load() + except Exception as error: + tools.stderr('Cannot load plugin %s: %s' % (plugin_name, error)) + return 1 + + if not plugin.has_configure(): + tools.stderr('Nothing to configure for plugin %s' % plugin_name) + return 0 # nothing to configure is not exactly an error case + + print('Configure %s' % plugin.get_label()) + plugin.configure(settings) + settings.save() + + if not is_enabled: + tools.stderr( + "Plugin {0} has been configured but is not enabled. " + "Use 'sopel-plugins enable {0}' to enable it".format(plugin_name) + ) + + +def _handle_disable_plugin(settings, plugin_name, force): + excluded = settings.core.exclude + # nothing left to do if already excluded + if plugin_name in excluded: + tools.stderr('Plugin %s already disabled.' % plugin_name) + return False + + # recalculate state: at the moment, the plugin is not in the excluded list + # however, with ensure_remove, the enable list may be empty, so we have + # to compute the plugin's state here, and not use what comes from + # plugins.get_usable_plugins + is_enabled = ( + not settings.core.enable or + plugin_name in settings.core.enable + ) + + # if not enabled at this point, exclude if options.force is used + if not is_enabled and not force: + tools.stderr( + 'Plugin %s is disabled but not excluded; ' + 'use -f/--force to force its exclusion.' + % plugin_name) + return False + + settings.core.exclude = excluded + [plugin_name] + return True + + +def display_unknown_plugins(unknown_plugins): + # at least one of the plugins does not exist + tools.stderr(utils.get_many_text( + unknown_plugins, + one='No plugin named {item}.', + two='No plugin named {first} or {second}.', + many='No plugin named {left}, or {last}.' + )) + + +def handle_disable(options): + """Disable Sopel plugins""" + plugin_names = options.names + force = options.force + ensure_remove = options.remove + settings = utils.load_settings(options) + usable_plugins = plugins.get_usable_plugins(settings) + actually_disabled = [] + + # coretasks is sacred + if 'coretasks' in plugin_names: + tools.stderr('Plugin coretasks cannot be disabled.') + return 1 # do nothing and return an error code + + unknown_plugins = [ + name + for name in plugin_names + if name not in usable_plugins + ] + if unknown_plugins: + display_unknown_plugins(unknown_plugins) + return 1 # do nothing and return an error code + + # remove from enabled if asked + if ensure_remove: + settings.core.enable = [ + name + for name in settings.core.enable + if name not in plugin_names + ] + settings.save() + + # disable plugin (when needed) + actually_disabled = tuple( + name + for name in plugin_names + if _handle_disable_plugin(settings, name, force) + ) + + # save if required + if actually_disabled: + settings.save() + else: + return 0 # nothing to disable or save, but not an error case + + # display plugins actually disabled by the command + print(utils.get_many_text( + actually_disabled, + one='Plugin {item} disabled.', + two='Plugins {first} and {second} disabled.', + many='Plugins {left}, and {last} disabled.' + )) + + return 0 + + +def _handle_enable_plugin(settings, usable_plugins, plugin_name, allow_only): + enabled = settings.core.enable + excluded = settings.core.exclude + + # coretasks is sacred + if plugin_name == 'coretasks': + tools.stderr('Plugin coretasks is always enabled.') + return False + + # is it already enabled, but should we enforce anything? + is_enabled = usable_plugins[plugin_name][1] + if is_enabled and not allow_only: + # already enabled, and no allow-only option: all good + if plugin_name in enabled: + tools.stderr('Plugin %s is already enabled.' % plugin_name) + else: + # suggest to use --allow-only option + tools.stderr( + 'Plugin %s is enabled; ' + 'use option -a/--allow-only to enforce allow only policy.' + % plugin_name) + + return False + + # not enabled, or option allow_only to enforce + if plugin_name in excluded: + # remove from excluded + settings.core.exclude = [ + name + for name in settings.core.exclude + if plugin_name != name + ] + elif plugin_name in enabled: + # not excluded, and already in enabled list: all good + tools.stderr('Plugin %s is already enabled' % plugin_name) + return False + + if plugin_name not in enabled and (enabled or allow_only): + # not excluded, but not enabled either: allow-only mode required + # either because of the current configuration, or by request + settings.core.enable = enabled + [plugin_name] + + return True + + +def handle_enable(options): + """Enable a Sopel plugin""" + plugin_names = options.names + allow_only = options.allow_only + settings = utils.load_settings(options) + usable_plugins = plugins.get_usable_plugins(settings) + + # plugin does not exist + unknown_plugins = [ + name + for name in plugin_names + if name not in usable_plugins + ] + if unknown_plugins: + display_unknown_plugins(unknown_plugins) + return 1 # do nothing and return an error code + + actually_enabled = tuple( + name + for name in plugin_names + if _handle_enable_plugin(settings, usable_plugins, name, allow_only) + ) + + # save if required + if actually_enabled: + settings.save() + else: + return 0 # nothing to disable or save, but not an error case + + # display plugins actually disabled by the command + print(utils.get_many_text( + actually_enabled, + one='Plugin {item} enabled.', + two='Plugins {first} and {second} enabled.', + many='Plugins {left}, and {last} enabled.' + )) + return 0 + + +def main(): + """Console entry point for ``sopel-plugins``""" + parser = build_parser() + options = parser.parse_args() + action = options.action + + if not action: + parser.print_help() + return + + if action == 'list': + return handle_list(options) + elif action == 'show': + return handle_show(options) + elif action == 'configure': + return handle_configure(options) + elif action == 'disable': + return handle_disable(options) + elif action == 'enable': + return handle_enable(options) diff --git a/sopel/cli/utils.py b/sopel/cli/utils.py index 0ce1d041a6..afe9f461dc 100644 --- a/sopel/cli/utils.py +++ b/sopel/cli/utils.py @@ -1,6 +1,7 @@ # coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division +import inspect import os import sys @@ -15,9 +16,59 @@ 'redirect_outputs', 'wizard', 'plugins_wizard', + # colors + 'green', + 'yellow', + 'red', ] +RESET = '\033[0m' +RED = '\033[31m' +GREEN = '\033[32m' +YELLOW = '\033[33m' + + +def _colored(text, color, reset=True): + text = color + text + if reset: + return text + RESET + return text + + +def green(text, reset=True): + """Add ANSI escape sequences to make the text green in term + + :param str text: text to colorized in green + :param bool reset: if the text color must be reset after (default ``True``) + :return: text with ANSI escape sequences for green color + :rtype: str + """ + return _colored(text, GREEN, reset) + + +def yellow(text, reset=True): + """Add ANSI escape sequences to make the text yellow in term + + :param str text: text to colorized in yellow + :param bool reset: if the text color must be reset after (default ``True``) + :return: text with ANSI escape sequences for yellow color + :rtype: str + """ + return _colored(text, YELLOW, reset) + + +def red(text, reset=True): + """Add ANSI escape sequences to make the text red in term + + :param str text: text to colorized in red + :param bool reset: if the text color must be reset after (default ``True``) + :return: text with ANSI escape sequences for red color + :rtype: str + """ + return _colored(text, RED, reset) + + def wizard(filename): """Global Configuration Wizard @@ -222,11 +273,13 @@ def add_common_arguments(parser): default=None, metavar='filename', dest='config', - help='Use a specific configuration file. ' - 'A config name can be given and the configuration file will be ' - 'found in Sopel\'s homedir (defaults to ``~/.sopel/default.cfg``). ' - 'An absolute pathname can be provided instead to use an ' - 'arbitrary location.') + help=inspect.cleandoc(""" + Use a specific configuration file. + A config name can be given and the configuration file will be + found in Sopel\'s homedir (defaults to ``~/.sopel/default.cfg``). + An absolute pathname can be provided instead to use an + arbitrary location. + """)) def load_settings(options): @@ -286,3 +339,23 @@ def redirect_outputs(settings, is_quiet=False): logfile = os.path.os.path.join(settings.core.logdir, settings.basename + '.stdio.log') sys.stderr = tools.OutputRedirect(logfile, True, is_quiet) sys.stdout = tools.OutputRedirect(logfile, False, is_quiet) + + +def get_many_text(items, one, two, many): + """Get the right text based on the number of ``items``.""" + message = '' + if not items: + return message + + items_count = len(items) + + if items_count == 1: + message = one.format(item=items[0], items=items) + elif items_count == 2: + message = two.format(first=items[0], second=items[1], items=items) + else: + left = ', '.join(items[:-1]) + last = items[-1] + message = many.format(left=left, last=last, items=items) + + return message diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py index f0c1f58dc6..963473be70 100644 --- a/sopel/plugins/handlers.py +++ b/sopel/plugins/handlers.py @@ -87,6 +87,22 @@ def get_label(self): """ raise NotImplementedError + def get_meta_description(self): + """Retrieve a meta description for the plugin + + :return: meta description information + :rtype: :class:`dict` + + The expected keys are: + + * name: a short name for the plugin + * label: a descriptive label for the plugin + * type: the plugin's type + * source: the plugin's source + (filesystem path, python import path, etc.) + """ + raise NotImplementedError + def is_loaded(self): """Tell if the plugin is loaded or not @@ -192,6 +208,8 @@ class PyModulePlugin(AbstractPluginHandler): True """ + PLUGIN_TYPE = 'python-module' + def __init__(self, name, package=None): self.name = name self.package = package @@ -212,6 +230,14 @@ def get_label(self): lines = inspect.cleandoc(module_doc).splitlines() return default_label if not lines else lines[0] + def get_meta_description(self): + return { + 'label': self.get_label(), + 'type': self.PLUGIN_TYPE, + 'name': self.name, + 'source': self.module_name, + } + def load(self): self._module = importlib.import_module(self.module_name) @@ -267,6 +293,8 @@ class PyFilePlugin(PyModulePlugin): In this example, the plugin ``custom`` is loaded from its filename despite not being in the Python path. """ + PLUGIN_TYPE = 'python-file' + def __init__(self, filename): good_file = ( os.path.isfile(filename) and @@ -319,6 +347,13 @@ def _load(self): return mod + def get_meta_description(self): + data = super(PyFilePlugin, self).get_meta_description() + data.update({ + 'source': self.path, + }) + return data + def load(self): self._module = self._load() @@ -383,9 +418,18 @@ class EntryPointPlugin(PyModulePlugin): .. __: https://setuptools.readthedocs.io/en/stable/setuptools.html#dynamic-discovery-of-services-and-plugins """ + PLUGIN_TYPE = 'setup-entrypoint' + def __init__(self, entry_point): self.entry_point = entry_point super(EntryPointPlugin, self).__init__(entry_point.name) def load(self): self._module = self.entry_point.load() + + def get_meta_description(self): + data = super(EntryPointPlugin, self).get_meta_description() + data.update({ + 'source': str(self.entry_point), + }) + return data diff --git a/test/cli/test_cli_utils.py b/test/cli/test_cli_utils.py index 648bcae62f..7653147a5e 100644 --- a/test/cli/test_cli_utils.py +++ b/test/cli/test_cli_utils.py @@ -8,7 +8,12 @@ import pytest -from sopel.cli.utils import enumerate_configs, find_config, add_common_arguments +from sopel.cli.utils import ( + add_common_arguments, + enumerate_configs, + find_config, + get_many_text, +) @contextmanager @@ -124,3 +129,22 @@ def test_add_common_arguments_subparser(): options = parser.parse_args(['sub', '--config', 'test-long']) assert options.config == 'test-long' + + +MANY_TEXTS = ( + ([], ''), + (['a'], 'the a element'), + (['a', 'b'], 'elements a and b'), + (['a', 'b', 'c'], 'elements a, b, and c'), + (['a', 'b', 'c', 'd'], 'elements a, b, c, and d'), +) + + +@pytest.mark.parametrize('items, expected', MANY_TEXTS) +def test_get_many_text(items, expected): + result = get_many_text( + items, + 'the {item} element', + 'elements {first} and {second}', + 'elements {left}, and {last}') + assert result == expected diff --git a/test/plugins/test_plugins_handlers.py b/test/plugins/test_plugins_handlers.py new file mode 100644 index 0000000000..b5b0e84e1f --- /dev/null +++ b/test/plugins/test_plugins_handlers.py @@ -0,0 +1,84 @@ +# coding=utf-8 +"""Tests for the ``sopel.plugins.handlers`` module.""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import os +import sys + +import pkg_resources +import pytest + +from sopel.plugins import handlers + + +MOCK_MODULE_CONTENT = """# coding=utf-8 +\"\"\"module label +\"\"\" +""" + + +@pytest.fixture +def plugin_tmpfile(tmpdir): + root = tmpdir.mkdir('loader_mods') + mod_file = root.join('file_mod.py') + mod_file.write(MOCK_MODULE_CONTENT) + + return mod_file + + +def test_get_label_pymodule(): + plugin = handlers.PyModulePlugin('coretasks', 'sopel') + meta = plugin.get_meta_description() + + assert 'name' in meta + assert 'label' in meta + assert 'type' in meta + assert 'source' in meta + + assert meta['name'] == 'coretasks' + assert meta['label'] == 'coretasks module', 'Expecting default label' + assert meta['type'] == handlers.PyModulePlugin.PLUGIN_TYPE + assert meta['source'] == 'sopel.coretasks' + + +def test_get_label_pyfile(plugin_tmpfile): + plugin = handlers.PyFilePlugin(plugin_tmpfile.strpath) + meta = plugin.get_meta_description() + + assert meta['name'] == 'file_mod' + assert meta['label'] == 'file_mod module', 'Expecting default label' + assert meta['type'] == handlers.PyFilePlugin.PLUGIN_TYPE + assert meta['source'] == plugin_tmpfile.strpath + + +def test_get_label_pyfile_loaded(plugin_tmpfile): + plugin = handlers.PyFilePlugin(plugin_tmpfile.strpath) + plugin.load() + meta = plugin.get_meta_description() + + assert meta['name'] == 'file_mod' + assert meta['label'] == 'module label' + assert meta['type'] == handlers.PyFilePlugin.PLUGIN_TYPE + assert meta['source'] == plugin_tmpfile.strpath + + +def test_get_label_entrypoint(plugin_tmpfile): + # generate setuptools Distribution object + distrib_dir = os.path.dirname(plugin_tmpfile.strpath) + distrib = pkg_resources.Distribution(distrib_dir) + sys.path.append(distrib_dir) + + # load the entry point + try: + entry_point = pkg_resources.EntryPoint( + 'test_plugin', 'file_mod', dist=distrib) + plugin = handlers.EntryPointPlugin(entry_point) + plugin.load() + finally: + sys.path.remove(distrib_dir) + + meta = plugin.get_meta_description() + assert meta['name'] == 'test_plugin' + assert meta['label'] == 'module label' + assert meta['type'] == handlers.EntryPointPlugin.PLUGIN_TYPE + assert meta['source'] == 'test_plugin = file_mod'