Repository: sopel-irc/sopel

Issue:
cli: tracebacks shown in too many places
Playing with Sopel's CLI tools on a fresh cloud instance, I found the following:

- [x] `sopel-config init`: mid-wizard, hitting <kbd>Ctrl</kbd>+<kbd>C</kbd> spits out a `KeyboardInterrupt` traceback
  - This should just abort creating the config, maybe with a "Config operation cancelled" message
- [x] `sopel-plugins` actions run when no config has been created yet spit out a `sopel.config.ConfigurationNotFound` traceback
  - These should catch and display a friendly error message only
  - ~~Things like `sopel-plugins show` should probably work for core plugins _at least_ even if no config exists?~~ NAK, meh. Just say there's no config, same as the others
- [x] `sopel-config list`: no output if no configs exist
  - Not a traceback, but might as well be addressed at the same time.
  - An empty list would be appropriate for output intended for consumption by a script, but we intend Sopel's CLI utilities to be used by humans. A helpful message suggesting to run `sopel-config init` might be good.


Discussion:
> `sopel-config init`: mid-wizard, hitting Ctrl+C spits out a `KeyboardInterrupt` traceback

Aw indeed.

> Things like `sopel-plugins show` should probably work for core plugins _at least_ even if no config exists?

Yes but ultimately no, because a config can add extra plugin directories allowing to shadow an existing core plugin by another one, which would "show" a different plugin. 

> A helpful message suggesting to run `sopel-config init` might be good.

Ha yes, and we can keep it script-friendly by having that output on stderr instead of stdout.


@dgw @Exirel You can assign to me, I'll see what I can do from my fork, but I'll remove myself if I feel it's too advanced for me.
@BlackWidowMovie0 Sure, take a crack at it.

Resolving patch:
diff --git a/sopel/cli/config.py b/sopel/cli/config.py
--- a/sopel/cli/config.py
+++ b/sopel/cli/config.py
@@ -93,13 +93,19 @@ def handle_list(options):
         extension = '.' + extension
     configs = utils.enumerate_configs(configdir, extension)
 
+    found = False
     for config_filename in configs:
+        found = True
         if display_path:
             print(os.path.join(configdir, config_filename))
         else:
             name, _ = os.path.splitext(config_filename)
             print(name)
 
+    if not found:
+        tools.stderr('No config file found at this location: %s' % configdir)
+        tools.stderr('Use `sopel-config init` to create a new config file.')
+
     return 0  # successful operation
 
 
@@ -131,7 +137,12 @@ def handle_init(options):
         return 1
 
     print('Starting Sopel config wizard for: %s' % config_filename)
-    utils.wizard(config_name)
+    try:
+        utils.wizard(config_name)
+    except KeyboardInterrupt:
+        tools.stderr('\nOperation cancelled; no file has been created.')
+        return 1  # cancelled operation
+
     return 0  # successful operation
 
 
@@ -177,10 +188,14 @@ def main():
         parser.print_help()
         return
 
-    # init command does not require existing settings
-    if action == 'list':
-        return handle_list(options)
-    elif action == 'init':
-        return handle_init(options)
-    elif action == 'get':
-        return handle_get(options)
+    try:
+        # init command does not require existing settings
+        if action == 'list':
+            return handle_list(options)
+        elif action == 'init':
+            return handle_init(options)
+        elif action == 'get':
+            return handle_get(options)
+    except KeyboardInterrupt:
+        # ctrl+c was used, nothing to report here
+        pass
diff --git a/sopel/cli/plugins.py b/sopel/cli/plugins.py
--- a/sopel/cli/plugins.py
+++ b/sopel/cli/plugins.py
@@ -6,10 +6,14 @@
 import inspect
 import operator
 
-from sopel import plugins, tools
+from sopel import config, plugins, tools
 from . import utils
 
 
+ERR_CODE = 1
+"""Error code: program exited with an error"""
+
+
 def build_parser():
     """Configure an argument parser for ``sopel-plugins``.
 
@@ -249,7 +253,7 @@ def handle_show(options):
     # plugin does not exist
     if plugin_name not in usable_plugins:
         tools.stderr('No plugin named %s' % plugin_name)
-        return 1
+        return ERR_CODE
 
     plugin, is_enabled = usable_plugins[plugin_name]
     description = {
@@ -281,7 +285,7 @@ def handle_show(options):
 
     if not loaded:
         print('Loading failed')
-        return 1
+        return ERR_CODE
 
     print('Loaded successfully')
     print('Setup:', 'yes' if plugin.has_setup() else 'no')
@@ -305,21 +309,27 @@ def handle_configure(options):
     # plugin does not exist
     if plugin_name not in usable_plugins:
         tools.stderr('No plugin named %s' % plugin_name)
-        return 1
+        return ERR_CODE
 
     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
+        return ERR_CODE
 
     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)
+    try:
+        plugin.configure(settings)
+    except KeyboardInterrupt:
+        tools.stderr(
+            '\nOperation cancelled; the config file has not been modified.')
+        return ERR_CODE  # cancelled operation
+
     settings.save()
 
     if not is_enabled:
@@ -392,7 +402,7 @@ def handle_disable(options):
     # coretasks is sacred
     if 'coretasks' in plugin_names:
         tools.stderr('Plugin coretasks cannot be disabled.')
-        return 1  # do nothing and return an error code
+        return ERR_CODE  # do nothing and return an error code
 
     unknown_plugins = [
         name
@@ -401,7 +411,7 @@ def handle_disable(options):
     ]
     if unknown_plugins:
         display_unknown_plugins(unknown_plugins)
-        return 1  # do nothing and return an error code
+        return ERR_CODE  # do nothing and return an error code
 
     # remove from enabled if asked
     if ensure_remove:
@@ -502,7 +512,7 @@ def handle_enable(options):
     ]
     if unknown_plugins:
         display_unknown_plugins(unknown_plugins)
-        return 1  # do nothing and return an error code
+        return ERR_CODE  # do nothing and return an error code
 
     actually_enabled = tuple(
         name
@@ -534,15 +544,23 @@ def main():
 
     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)
+        return ERR_CODE
+
+    try:
+        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)
+    except KeyboardInterrupt:
+        tools.stderr('Bye!')
+        return ERR_CODE
+    except config.ConfigurationNotFound as err:
+        tools.stderr(err)
+        tools.stderr('Use `sopel-config init` to create a new config file.')
+        return ERR_CODE


Test patch: