From 5da002c47308820ff67c4e468c7bea99ad7ea90e Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sat, 4 May 2019 19:49:40 +0200 Subject: [PATCH 01/17] cli: sopel-plugins list (with basic options) --- setup.py | 1 + sopel/cli/plugins.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ sopel/cli/utils.py | 37 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 sopel/cli/plugins.py 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..26b31484ea --- /dev/null +++ b/sopel/cli/plugins.py @@ -0,0 +1,88 @@ +# coding=utf-8 +"""Sopel Plugins Command Line Interface (CLI): ``sopel-plugins``""" +from __future__ import unicode_literals, absolute_import, print_function, division + +import argparse + +from sopel import plugins + +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 list + list_parser = subparsers.add_parser( + 'list', + help="List available Sopel plugins", + description=""" + List available Sopel plugins from all possible sources: 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) + + return parser + + +def handle_list(options): + """List Sopel plugins""" + settings = utils.load_settings(options) + for name, info in plugins.get_usable_plugins(settings).items(): + _, is_enabled = info + + if options.enabled_only and not is_enabled: + # hide disabled plugins when displaying enabled only + continue + elif options.disabled_only and is_enabled: + # hide enabled plugins when displaying disabled only + continue + + if options.no_color: + print(name) + elif is_enabled: + print(utils.green(name)) + else: + print(utils.red(name)) + + +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) diff --git a/sopel/cli/utils.py b/sopel/cli/utils.py index 0ce1d041a6..3a822f0e29 100644 --- a/sopel/cli/utils.py +++ b/sopel/cli/utils.py @@ -15,9 +15,46 @@ 'redirect_outputs', 'wizard', 'plugins_wizard', + # colors + 'green', + 'red', ] +RESET = '\033[0m' +RED = '\033[31m' +GREEN = '\033[32m' + + +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 red(text, reset=True): + """Add ANSI escape sequences to make the text red 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, RED, reset) + + def wizard(filename): """Global Configuration Wizard From 511b3aca229adc90b6488d7a46ee142e8c68513e Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 6 May 2019 22:10:55 +0200 Subject: [PATCH 02/17] cli: sopel-plugins disable --- sopel/cli/plugins.py | 81 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 26b31484ea..76c90f2598 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -4,7 +4,7 @@ import argparse -from sopel import plugins +from sopel import plugins, tools from . import utils @@ -50,6 +50,29 @@ def build_parser(): action='store_true', default=False) + # sopel-plugin disable + disable_parser = subparsers.add_parser( + 'disable', + help="Disable a Sopel plugins", + description=""" + 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('name', help='Name of the plugin to disable') + disable_parser.add_argument( + '-f', '--force', action='store_true', default=False, + help=""" + 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. + """) + return parser @@ -74,6 +97,60 @@ def handle_list(options): print(utils.red(name)) +def handle_disable(options): + """Disable a Sopel plugin""" + plugin_name = options.name + settings = utils.load_settings(options) + usable_plugins = plugins.get_usable_plugins(settings) + excluded = settings.core.exclude + + # coretasks is sacred + if plugin_name == 'coretasks': + tools.stderr('Plugin coretasks cannot be disabled') + return 1 + + # plugin does not exist + if plugin_name not in usable_plugins: + tools.stderr('No plugin named %s' % plugin_name) + return 1 + + # remove from enabled if asked + if options.remove and plugin_name in settings.core.enable: + settings.core.enable = [ + name + for name in settings.core.enable + if name != plugin_name + ] + settings.save() + + # nothing left to do if already excluded + if plugin_name in excluded: + tools.stderr('Plugin %s already disabled' % plugin_name) + return 0 + + # recalculate state: at the moment, the plugin is not in the excluded list + # however, with options.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 options.force: + tools.stderr( + 'Plugin %s is disabled but not excluded; ' + 'use -f/--force to force its exclusion' + % plugin_name) + return 0 + + settings.core.exclude = excluded + [plugin_name] + settings.save() + + print('Plugin %s disabled' % plugin_name) + + def main(): """Console entry point for ``sopel-plugins``""" parser = build_parser() @@ -86,3 +163,5 @@ def main(): if action == 'list': return handle_list(options) + elif action == 'disable': + return handle_disable(options) From 443ecff467f15ff44af5ea5174c417a8685a1647 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 6 May 2019 22:51:58 +0200 Subject: [PATCH 03/17] cli: sopel-plugins enable --- sopel/cli/plugins.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 76c90f2598..8d00c2276b 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -73,6 +73,30 @@ def build_parser(): Remove from ``core.enable`` list if applicable. """) + # sopel-plugin enable + enable_parser = subparsers.add_parser( + 'enable', + help="Enable a Sopel plugins", + description=""" + 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 + a "allow-only" policy for plugins, and enabled plugins must be + added to this list. + """) + utils.add_common_arguments(enable_parser) + enable_parser.add_argument('name', help='Name of the plugin to enable') + enable_parser.add_argument( + '-a', '--allow-only', + dest='allow_only', + action='store_true', + default=False, + help=""" + Enforce allow-only policy, adding the plugin to the ``core.enable`` + list. + """) + return parser @@ -151,6 +175,59 @@ def handle_disable(options): print('Plugin %s disabled' % plugin_name) +def handle_enable(options): + """Enable a Sopel plugin""" + plugin_name = options.name + settings = utils.load_settings(options) + usable_plugins = plugins.get_usable_plugins(settings) + enabled = settings.core.enable + excluded = settings.core.exclude + + # coretasks is sacred + if plugin_name == 'coretasks': + tools.stderr('Plugin coretasks is always enabled') + return 0 + + # plugin does not exist + if plugin_name not in usable_plugins: + tools.stderr('No plugin named %s' % plugin_name) + return 1 + + # is it already enabled, but should we enforce anything? + is_enabled = usable_plugins[plugin_name][1] + if is_enabled and not options.allow_only: + # already enabled, and no allow-only option: all good + if plugin_name not in enabled: + tools.stderr( + 'Plugin %s is enabled; ' + 'use option -a/--allow-only to enforce allow only policy' + % plugin_name) + return 0 + + # not enabled, or options.allow_only to enforce + if plugin_name in excluded: + # remove from excluded + settings.core.exclude = [ + name + for name in settings.core.exclude + if name != plugin_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 0 + + if plugin_name not in enabled: + if enabled or options.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] + + settings.save() + tools.stderr('Plugin %s enabled' % plugin_name) + return 0 + + def main(): """Console entry point for ``sopel-plugins``""" parser = build_parser() @@ -165,3 +242,5 @@ def main(): return handle_list(options) elif action == 'disable': return handle_disable(options) + elif action == 'enable': + return handle_enable(options) From 4ccbee633a398ac5ecd131b6b20c6c0a5f33680c Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sat, 18 May 2019 23:31:28 +0200 Subject: [PATCH 04/17] plugins: add get_meta_description method to plugin handlers --- sopel/plugins/handlers.py | 35 ++++++++++++++++ test/plugins/test_plugins_handlers.py | 58 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 test/plugins/test_plugins_handlers.py diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py index f0c1f58dc6..4b4dde15a5 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() diff --git a/test/plugins/test_plugins_handlers.py b/test/plugins/test_plugins_handlers.py new file mode 100644 index 0000000000..cd817dd0d0 --- /dev/null +++ b/test/plugins/test_plugins_handlers.py @@ -0,0 +1,58 @@ +# coding=utf-8 +"""Test for the ``sopel.plugins.handlers`` module.""" +from __future__ import unicode_literals, absolute_import, print_function, division + +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 From 0c0ad20a9065869108174b7f2be4fbcefa4a3127 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sat, 18 May 2019 23:31:44 +0200 Subject: [PATCH 05/17] cli: sopel-plugins list with long description Inspired by `apt list`, the `list` action now display the plugin list like this: plugin-name/plugin-type label (source) [status] where: * `plugin-name` is the plugin's name, as used to enable/disable it * `plugin-type` is the type of plugin handler used * `label` is the plugin's label (once loaded) * `source` can be either the python path to the module, or its filename * `status` can be "enabled", "disabled", or "error" (in case of error on loading the plugin) Colors are used on the plugin's name: * green if the plugin is loaded properly and is enabled * red if the plugin is disabled * yellow if the plugin cannot be loaded properly but should be enabled The error status is displayed in red if colors are activated. --- sopel/cli/plugins.py | 44 +++++++++++++++++++++++++++++++++++++------- sopel/cli/utils.py | 17 +++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 8d00c2276b..985ef0a6b0 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -104,7 +104,7 @@ def handle_list(options): """List Sopel plugins""" settings = utils.load_settings(options) for name, info in plugins.get_usable_plugins(settings).items(): - _, is_enabled = info + plugin, is_enabled = info if options.enabled_only and not is_enabled: # hide disabled plugins when displaying enabled only @@ -113,12 +113,42 @@ def handle_list(options): # hide enabled plugins when displaying disabled only continue - if options.no_color: - print(name) - elif is_enabled: - print(utils.green(name)) - else: - print(utils.red(name)) + description = { + 'name': name, + 'status': 'enabled' if is_enabled else 'disabled', + } + + # option meta description from the plugin itself + try: + plugin.load() + description.update(plugin.get_meta_description()) + + # colorize name for display purpose + if not options.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 options.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}]' + print(template.format(**description)) def handle_disable(options): diff --git a/sopel/cli/utils.py b/sopel/cli/utils.py index 3a822f0e29..4639378ee2 100644 --- a/sopel/cli/utils.py +++ b/sopel/cli/utils.py @@ -17,6 +17,7 @@ 'plugins_wizard', # colors 'green', + 'yellow', 'red', ] @@ -24,6 +25,7 @@ RESET = '\033[0m' RED = '\033[31m' GREEN = '\033[32m' +YELLOW = '\033[33m' def _colored(text, color, reset=True): @@ -44,12 +46,23 @@ def green(text, reset=True): 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 green + :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 green color + :return: text with ANSI escape sequences for red color :rtype: str """ return _colored(text, RED, reset) From d34bd70e0836f73172da1662ef081fcd5631fc0a Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 8 Jul 2019 23:58:35 +0200 Subject: [PATCH 06/17] cli: sopel-plugins list -n/--name-only --- sopel/cli/plugins.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 985ef0a6b0..33f821948c 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -49,6 +49,12 @@ def build_parser(): 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-plugin disable disable_parser = subparsers.add_parser( @@ -103,13 +109,18 @@ def build_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 + for name, info in plugins.get_usable_plugins(settings).items(): plugin, is_enabled = info - if options.enabled_only and not is_enabled: + if enabled_only and not is_enabled: # hide disabled plugins when displaying enabled only continue - elif options.disabled_only and is_enabled: + elif disabled_only and is_enabled: # hide enabled plugins when displaying disabled only continue @@ -124,7 +135,7 @@ def handle_list(options): description.update(plugin.get_meta_description()) # colorize name for display purpose - if not options.no_color: + if not no_color: if is_enabled: description['name'] = utils.green(name) else: @@ -138,7 +149,7 @@ def handle_list(options): 'source': 'unknown', 'status': error_status, }) - if not options.no_color: + if not no_color: if is_enabled: # yellow instead of green description['name'] = utils.yellow(name) @@ -148,6 +159,9 @@ def handle_list(options): description['status'] = utils.red(error_status) template = '{name}/{type} {label} ({source}) [{status}]' + if name_only: + template = '{name}' + print(template.format(**description)) From 873103720e8db76a2160c67af39287075a8daa7b Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Tue, 9 Jul 2019 22:23:42 +0200 Subject: [PATCH 07/17] cli: sopel-plugins show (with basic descriptions) --- sopel/cli/plugins.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 33f821948c..22a83888e4 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -19,6 +19,13 @@ def build_parser(): help='Action to perform', dest='action') + # sopel-plugins show + show_parser = subparsers.add_parser( + 'show', + help="Show plugin details") + utils.add_common_arguments(show_parser) + show_parser.add_argument('name', help='Plugin name') + # sopel-plugins list list_parser = subparsers.add_parser( 'list', @@ -165,6 +172,44 @@ def handle_list(options): 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', + } + + # option meta description from the plugin itself + try: + plugin.load() + description.update(plugin.get_meta_description()) + 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']) + + def handle_disable(options): """Disable a Sopel plugin""" plugin_name = options.name @@ -284,6 +329,8 @@ def main(): if action == 'list': return handle_list(options) + elif action == 'show': + return handle_show(options) elif action == 'disable': return handle_disable(options) elif action == 'enable': From 45eb1ee5b6c271a5e90af1a3107866a34fd40927 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Tue, 9 Jul 2019 23:11:39 +0200 Subject: [PATCH 08/17] cli: sopel-plugins show with loaded info --- sopel/cli/plugins.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 22a83888e4..1a4339fc11 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -190,9 +190,11 @@ def handle_show(options): } # option 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' @@ -209,6 +211,15 @@ def handle_show(options): 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_disable(options): """Disable a Sopel plugin""" From b08dbd77aa5cb8d4777af4f3dd230aeaeea4b1cc Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Tue, 9 Jul 2019 23:12:15 +0200 Subject: [PATCH 09/17] plugins: proper meta desc for entrypoint plugins --- sopel/plugins/handlers.py | 9 +++++++++ test/plugins/test_plugins_handlers.py | 28 ++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py index 4b4dde15a5..963473be70 100644 --- a/sopel/plugins/handlers.py +++ b/sopel/plugins/handlers.py @@ -418,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/plugins/test_plugins_handlers.py b/test/plugins/test_plugins_handlers.py index cd817dd0d0..b5b0e84e1f 100644 --- a/test/plugins/test_plugins_handlers.py +++ b/test/plugins/test_plugins_handlers.py @@ -1,7 +1,11 @@ # coding=utf-8 -"""Test for the ``sopel.plugins.handlers`` module.""" +"""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 @@ -56,3 +60,25 @@ def test_get_label_pyfile_loaded(plugin_tmpfile): 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' From 3837c5048bf506bbf40996baf3fe4c161f8fd775 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Wed, 10 Jul 2019 22:56:54 +0200 Subject: [PATCH 10/17] cli: sopel-plugins configure --- sopel/cli/plugins.py | 51 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 1a4339fc11..b091411915 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -22,10 +22,24 @@ def build_parser(): # sopel-plugins show show_parser = subparsers.add_parser( 'show', - help="Show plugin details") + 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', + help="Configure plugin with a config wizard", + description=""" + 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', @@ -221,6 +235,39 @@ def handle_show(options): 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(options): """Disable a Sopel plugin""" plugin_name = options.name @@ -342,6 +389,8 @@ def main(): 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': From a002c961fd4a698de600f7e27c54b5f5e4264e30 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Wed, 17 Jul 2019 20:54:21 +0200 Subject: [PATCH 11/17] doc: add sopel-plugins documentation --- docs/source/cli.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index efb666beea..419b19c323 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -1,3 +1,4 @@ +======================= Command Line Interfaces ======================= @@ -15,6 +16,12 @@ 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 +42,15 @@ configuration file unless one is provided using the ``-c``/``--config`` option. .. autoprogram:: sopel.cli.run:build_parser() :prog: sopel :start_command: configure + + +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 From 2acd5304f0c4490a31c4d6a93d1ff6abd9a41c02 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Thu, 18 Jul 2019 22:35:46 +0200 Subject: [PATCH 12/17] cli: use argparse formatter_class Co-Authored-By: dgw --- sopel/cli/plugins.py | 83 ++++++++++++++++++++++++-------------------- sopel/cli/utils.py | 13 ++++--- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index b091411915..8061090ae9 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import, print_function, division import argparse +import inspect from sopel import plugins, tools @@ -22,34 +23,38 @@ def build_parser(): # sopel-plugins show show_parser = subparsers.add_parser( 'show', + formatter_class=argparse.RawTextHelpFormatter, help="Show plugin details", - description=""" - Show detailed information about a plugin. - """) + 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=""" - Run a config wizard to configure a plugin. This can be used whether - the plugin is enabled or not. - """) + 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=""" - List available Sopel plugins from all possible sources: built-in, - from ``sopel_modules.*``, from ``sopel.plugins`` entry points, - or Sopel's plugin directories. Enabled plugins are displayed in - green; disabled, in red. - """) + 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', @@ -80,38 +85,42 @@ def build_parser(): # sopel-plugin disable disable_parser = subparsers.add_parser( 'disable', + formatter_class=argparse.RawTextHelpFormatter, help="Disable a Sopel plugins", - description=""" + 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('name', help='Name of the plugin to disable') disable_parser.add_argument( '-f', '--force', action='store_true', default=False, - help=""" - 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. - """) + 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. - """) + help="Remove from ``core.enable`` list if applicable.") # sopel-plugin enable enable_parser = subparsers.add_parser( 'enable', - help="Enable a Sopel plugins", - description=""" + 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 - a "allow-only" policy for plugins, and enabled plugins must be - added to this list. - """) + + 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('name', help='Name of the plugin to enable') enable_parser.add_argument( @@ -119,10 +128,10 @@ def build_parser(): dest='allow_only', action='store_true', default=False, - help=""" - Enforce allow-only policy, adding the plugin to the ``core.enable`` - list. - """) + help=inspect.cleandoc(""" + Enforce allow-only policy. + It makes sure the plugin is added to the ``core.enable`` list. + """)) return parser @@ -150,7 +159,7 @@ def handle_list(options): 'status': 'enabled' if is_enabled else 'disabled', } - # option meta description from the plugin itself + # optional meta description from the plugin itself try: plugin.load() description.update(plugin.get_meta_description()) @@ -203,7 +212,7 @@ def handle_show(options): 'status': 'enabled' if is_enabled else 'disabled', } - # option meta description from the plugin itself + # optional meta description from the plugin itself loaded = False try: plugin.load() diff --git a/sopel/cli/utils.py b/sopel/cli/utils.py index 4639378ee2..0d081d4fab 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 @@ -272,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): From dc438512652ac3203198269e2c3a6ecb84ac0095 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sat, 20 Jul 2019 20:57:13 +0200 Subject: [PATCH 13/17] cli: sopel-plugins list alphabetical order --- sopel/cli/plugins.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 8061090ae9..fa0baa5115 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -4,6 +4,7 @@ import argparse import inspect +import operator from sopel import plugins, tools @@ -82,7 +83,7 @@ def build_parser(): action='store_true', default=False) - # sopel-plugin disable + # sopel-plugins disable disable_parser = subparsers.add_parser( 'disable', formatter_class=argparse.RawTextHelpFormatter, @@ -106,7 +107,7 @@ def build_parser(): '-r', '--remove', action='store_true', default=False, help="Remove from ``core.enable`` list if applicable.") - # sopel-plugin enable + # sopel-plugins enable enable_parser = subparsers.add_parser( 'enable', formatter_class=argparse.RawTextHelpFormatter, @@ -144,16 +145,32 @@ def handle_list(options): enabled_only = options.enabled_only disabled_only = options.disabled_only - for name, info in plugins.get_usable_plugins(settings).items(): - plugin, is_enabled = info - - if enabled_only and not is_enabled: - # hide disabled plugins when displaying enabled only - continue - elif disabled_only and is_enabled: - # hide enabled plugins when displaying disabled only - continue + # 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', From 7902ba600c2dbcc1ffe0367d0fc24c8841b540b6 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sat, 20 Jul 2019 23:50:23 +0200 Subject: [PATCH 14/17] cli: sopel-plugins disable name [name, ...] --- sopel/cli/plugins.py | 117 ++++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 34 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index fa0baa5115..0a0068c3ef 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -94,7 +94,13 @@ def build_parser(): It is not possible to disable the ``coretasks`` plugin. """)) utils.add_common_arguments(disable_parser) - disable_parser.add_argument('name', help='Name of the plugin to disable') + 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(""" @@ -294,39 +300,15 @@ def handle_configure(options): ) -def handle_disable(options): - """Disable a Sopel plugin""" - plugin_name = options.name - settings = utils.load_settings(options) - usable_plugins = plugins.get_usable_plugins(settings) +def _handle_disable_plugin(settings, plugin_name, force): excluded = settings.core.exclude - - # coretasks is sacred - if plugin_name == 'coretasks': - tools.stderr('Plugin coretasks cannot be disabled') - return 1 - - # plugin does not exist - if plugin_name not in usable_plugins: - tools.stderr('No plugin named %s' % plugin_name) - return 1 - - # remove from enabled if asked - if options.remove and plugin_name in settings.core.enable: - settings.core.enable = [ - name - for name in settings.core.enable - if name != plugin_name - ] - settings.save() - # nothing left to do if already excluded if plugin_name in excluded: - tools.stderr('Plugin %s already disabled' % plugin_name) - return 0 + 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 options.remove, the enable list may be empty, so we have + # 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 = ( @@ -335,17 +317,84 @@ def handle_disable(options): ) # if not enabled at this point, exclude if options.force is used - if not is_enabled and not options.force: + if not is_enabled and not force: tools.stderr( 'Plugin %s is disabled but not excluded; ' - 'use -f/--force to force its exclusion' + 'use -f/--force to force its exclusion.' % plugin_name) - return 0 + return False settings.core.exclude = excluded + [plugin_name] - settings.save() + return True + + +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 = [] - print('Plugin %s disabled' % plugin_name) + # 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: + # at least one of the plugins does not exist + unknown_count = len(unknown_plugins) + if unknown_count == 1: + tools.stderr('No plugin named %s.' % unknown_plugins[0]) + elif unknown_count == 2: + tools.stderr('No plugin named %s or %s.' % unknown_plugins) + else: + left = ', '.join(unknown_plugins[:-1]) + last = unknown_plugins[-1] + tools.stderr('No plugin named %s, or %s.' % (left, last)) + + 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 + plugins_count = len(actually_disabled) + if plugins_count == 1: + print('Plugin %s disabled.' % actually_disabled[0]) + elif plugins_count == 2: + print('Plugin %s and %s disabled.' % actually_disabled) + else: + left = ', '.join(actually_disabled[:-1]) + last = actually_disabled[-1] + print('Plugin %s, and %s disabled.' % (left, last)) + + return 0 def handle_enable(options): From 6ec2dea0e26feed7ae81b0f3dd168da02c28170d Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sun, 21 Jul 2019 00:17:55 +0200 Subject: [PATCH 15/17] cli: sopel-plugins enable name [name, ...] --- sopel/cli/plugins.py | 120 +++++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 38 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 0a0068c3ef..4212b2c15e 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -129,7 +129,13 @@ def build_parser(): all desired plugins must be added to this list. """)) utils.add_common_arguments(enable_parser) - enable_parser.add_argument('name', help='Name of the plugin to enable') + 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', @@ -328,6 +334,19 @@ def _handle_disable_plugin(settings, plugin_name, force): return True +def display_unknown_plugins(unknown_plugins): + # at least one of the plugins does not exist + unknown_count = len(unknown_plugins) + if unknown_count == 1: + tools.stderr('No plugin named %s.' % unknown_plugins[0]) + elif unknown_count == 2: + tools.stderr('No plugin named %s or %s.' % unknown_plugins) + else: + left = ', '.join(unknown_plugins[:-1]) + last = unknown_plugins[-1] + tools.stderr('No plugin named %s, or %s.' % (left, last)) + + def handle_disable(options): """Disable Sopel plugins""" plugin_names = options.names @@ -348,17 +367,7 @@ def handle_disable(options): if name not in usable_plugins ] if unknown_plugins: - # at least one of the plugins does not exist - unknown_count = len(unknown_plugins) - if unknown_count == 1: - tools.stderr('No plugin named %s.' % unknown_plugins[0]) - elif unknown_count == 2: - tools.stderr('No plugin named %s or %s.' % unknown_plugins) - else: - left = ', '.join(unknown_plugins[:-1]) - last = unknown_plugins[-1] - tools.stderr('No plugin named %s, or %s.' % (left, last)) - + display_unknown_plugins(unknown_plugins) return 1 # do nothing and return an error code # remove from enabled if asked @@ -397,56 +406,91 @@ def handle_disable(options): return 0 -def handle_enable(options): - """Enable a Sopel plugin""" - plugin_name = options.name - settings = utils.load_settings(options) - usable_plugins = plugins.get_usable_plugins(settings) +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 0 - - # plugin does not exist - if plugin_name not in usable_plugins: - tools.stderr('No plugin named %s' % plugin_name) - return 1 + 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 options.allow_only: + if is_enabled and not allow_only: # already enabled, and no allow-only option: all good - if plugin_name not in enabled: + 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' + 'use option -a/--allow-only to enforce allow only policy.' % plugin_name) - return 0 - # not enabled, or options.allow_only to enforce + 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 name != plugin_name + 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 0 + return False - if plugin_name not in enabled: - if enabled or options.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] + 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 + plugins_count = len(actually_enabled) + if plugins_count == 1: + print('Plugin %s enabled.' % actually_enabled[0]) + elif plugins_count == 2: + print('Plugin %s and %s enabled.' % actually_enabled) + else: + left = ', '.join(actually_enabled[:-1]) + last = actually_enabled[-1] + print('Plugin %s, and %s enabled.' % (left, last)) - settings.save() - tools.stderr('Plugin %s enabled' % plugin_name) return 0 From ee03204402b3b707bc9a5c241cde26e8238d26bc Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Sun, 21 Jul 2019 00:49:44 +0200 Subject: [PATCH 16/17] cli: utils.get_many_text Co-authored-by: dgw --- sopel/cli/plugins.py | 46 +++++++++++++++----------------------- sopel/cli/utils.py | 20 +++++++++++++++++ test/cli/test_cli_utils.py | 26 ++++++++++++++++++++- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py index 4212b2c15e..eb83ca745e 100644 --- a/sopel/cli/plugins.py +++ b/sopel/cli/plugins.py @@ -336,15 +336,12 @@ def _handle_disable_plugin(settings, plugin_name, force): def display_unknown_plugins(unknown_plugins): # at least one of the plugins does not exist - unknown_count = len(unknown_plugins) - if unknown_count == 1: - tools.stderr('No plugin named %s.' % unknown_plugins[0]) - elif unknown_count == 2: - tools.stderr('No plugin named %s or %s.' % unknown_plugins) - else: - left = ', '.join(unknown_plugins[:-1]) - last = unknown_plugins[-1] - tools.stderr('No plugin named %s, or %s.' % (left, last)) + 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): @@ -393,15 +390,12 @@ def handle_disable(options): return 0 # nothing to disable or save, but not an error case # display plugins actually disabled by the command - plugins_count = len(actually_disabled) - if plugins_count == 1: - print('Plugin %s disabled.' % actually_disabled[0]) - elif plugins_count == 2: - print('Plugin %s and %s disabled.' % actually_disabled) - else: - left = ', '.join(actually_disabled[:-1]) - last = actually_disabled[-1] - print('Plugin %s, and %s disabled.' % (left, last)) + 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 @@ -481,16 +475,12 @@ def handle_enable(options): return 0 # nothing to disable or save, but not an error case # display plugins actually disabled by the command - plugins_count = len(actually_enabled) - if plugins_count == 1: - print('Plugin %s enabled.' % actually_enabled[0]) - elif plugins_count == 2: - print('Plugin %s and %s enabled.' % actually_enabled) - else: - left = ', '.join(actually_enabled[:-1]) - last = actually_enabled[-1] - print('Plugin %s, and %s enabled.' % (left, last)) - + 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 diff --git a/sopel/cli/utils.py b/sopel/cli/utils.py index 0d081d4fab..afe9f461dc 100644 --- a/sopel/cli/utils.py +++ b/sopel/cli/utils.py @@ -339,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/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 From 4e2cd3fbf1176597863b6cd2e4890faa282cf5b3 Mon Sep 17 00:00:00 2001 From: dgw Date: Sun, 21 Jul 2019 14:31:25 -0500 Subject: [PATCH 17/17] doc: Add missing command `sopel-config` to CLI docs --- docs/source/cli.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 419b19c323..347d421d1c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -20,6 +20,7 @@ configuration file unless one is provided using the ``-c``/``--config`` option. :local: :depth: 1 + The ``sopel`` command ===================== @@ -44,6 +45,18 @@ The ``sopel`` command :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 =============================