Repository: sopel-irc/sopel

Issue:
cli: legacy argument before subcommand breaks parser
The current usage specifies: 
```
usage: sopel.py [options]                                                      
                                                                               
Sopel IRC Bot                                                                  
                                                                               
optional arguments:                                                            
 ...              
  -c filename, --config filename                                               
                        Use a specific configuration file                      

sub-commands:                                           
  List of Sopel's sub-commands                          
                                                        
  {start,configure,stop,restart}                        
    start               Start a Sopel instance          
    configure           Sopel's Wizard tool             
    stop                Stop a running Sopel instance   
    restart             Restart a running Sopel instance
```

If a user provides an option, then also specifies a subcommand (e.g., `sopel -c my_config.cfg start`), the parser may get confused and give an error like: <pre>error: unrecognized arguments: <i>name_of_subcommand</i></pre>

@Exirel pointed out on IRC that:
```
> it is due to Python 2's argparse. 
> to be more accurate: it's because of how argparse's subparsers in Python 2 are always required
> and they are not in Python 3
> so if you want (almost) the same behavior in both, you need to always provide a sub-parser action
> which you do only if someone provides an option first
```

Essentially, this "bug" (not-a-bug)™<sup> @dgw</sup>, is only due to legacy support that is being provided until (no later than) Sopel v8.0.0, at which point Sopel will stop supporting Python 2. 
> _Then, all of our problems will be solved_ :tada:
> --  _someone (probably...)_


Discussion:
Working on that:

![image](https://user-images.githubusercontent.com/3259552/123512078-b627fb00-d685-11eb-99f6-04353fb206a2.png)

* legacy mode is gone
* a subcommand is now always required
* having a "default" command doesn't really work the way I wanted to, so there will be breaking changes
* the doc looks so much better now!

![image](https://user-images.githubusercontent.com/3259552/123512195-5bdb6a00-d686-11eb-896a-ad4fd1eec2cd.png)



Resolving patch:
diff --git a/sopel/cli/run.py b/sopel/cli/run.py
--- a/sopel/cli/run.py
+++ b/sopel/cli/run.py
@@ -120,77 +120,17 @@ def run(settings, pid_file, daemon=False):
     os._exit(0)
 
 
-def add_legacy_options(parser):
-    """Add legacy options to the argument parser.
-
-    :param parser: argument parser
-    :type parser: :class:`argparse.ArgumentParser`
-    """
-    # TL;DR: option -d/--fork is not deprecated.
-    # When the legacy action is replaced in Sopel 8, 'start' will become the
-    # new default action, with its arguments.
-    # The option -d/--fork is used by both actions (start and legacy),
-    # and it has the same meaning and behavior, therefore it is not deprecated.
-    parser.add_argument("-d", '--fork', action="store_true",
-                        dest="daemonize",
-                        help="Daemonize Sopel.")
-    parser.add_argument("-q", '--quit', action="store_true", dest="quit",
-                        help=(
-                            "Gracefully quit Sopel "
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use ``sopel stop`` instead)"))
-    parser.add_argument("-k", '--kill', action="store_true", dest="kill",
-                        help=(
-                            "Kill Sopel "
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use ``sopel stop --kill`` instead)"))
-    parser.add_argument("-r", '--restart', action="store_true", dest="restart",
-                        help=(
-                            "Restart Sopel "
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use `sopel restart` instead)"))
-    parser.add_argument("-l", '--list', action="store_true",
-                        dest="list_configs",
-                        help=(
-                            "List all config files found"
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use ``sopel-config list`` instead)"))
-    parser.add_argument('--quiet', action="store_true", dest="quiet",
-                        help="Suppress all output")
-    parser.add_argument('-w', '--configure-all', action='store_true',
-                        dest='wizard',
-                        help=(
-                            "Run the configuration wizard "
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use `sopel configure` instead)"))
-    parser.add_argument('--configure-modules', action='store_true',
-                        dest='mod_wizard',
-                        help=(
-                            "Run the configuration wizard, but only for the "
-                            "plugin configuration options "
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use ``sopel configure --plugins`` instead)"))
-    parser.add_argument('-v', action="store_true",
-                        dest='version_legacy',
-                        help=(
-                            "Show version number and exit "
-                            "(deprecated, and will be removed in Sopel 8; "
-                            "use ``-V/--version`` instead)"))
-    parser.add_argument('-V', '--version', action='store_true',
-                        dest='version',
-                        help='Show version number and exit')
-
-
 def build_parser():
     """Build an argument parser for the bot.
 
     :return: the argument parser
     :rtype: :class:`argparse.ArgumentParser`
     """
-    parser = argparse.ArgumentParser(description='Sopel IRC Bot',
-                                     usage='%(prog)s [options]')
-    add_legacy_options(parser)
-    utils.add_common_arguments(parser)
+    parser = argparse.ArgumentParser(description='Sopel IRC Bot')
+
+    parser.add_argument('-V', '--version', action='store_true',
+                        dest='version',
+                        help='Show version number and exit')
 
     subparsers = parser.add_subparsers(
         title='subcommands',
@@ -198,11 +138,6 @@ def build_parser():
         dest='action',
         metavar='{start,configure,stop,restart}')
 
-    # manage `legacy` subcommand
-    parser_legacy = subparsers.add_parser('legacy')
-    add_legacy_options(parser_legacy)
-    utils.add_common_arguments(parser_legacy)
-
     # manage `start` subcommand
     parser_start = subparsers.add_parser(
         'start',
@@ -312,19 +247,6 @@ def print_version():
     print('https://sopel.chat/')
 
 
-def print_config(configdir):
-    """Print list of available configurations from config directory."""
-    configs = utils.enumerate_configs(configdir)
-    print('Config files in %s:' % configdir)
-    configfile = None
-    for configfile in configs:
-        print('\t%s' % configfile)
-    if not configfile:
-        print('\tNone found')
-
-    print('-------------------------')
-
-
 def get_configuration(options):
     """Get or create a configuration object from ``options``.
 
@@ -544,179 +466,56 @@ def command_restart(opts):
         os.kill(pid, signal.SIGILL)
 
 
-def command_legacy(opts):
-    """Legacy Sopel run script.
-
-    :param opts: parsed arguments
-    :type opts: :class:`argparse.Namespace`
-
-    The ``legacy`` command manages the old-style ``sopel`` command line tool.
-    Most of its features are replaced by the following commands:
-
-    * ``sopel start`` replaces the default behavior (run the bot)
-    * ``sopel stop`` replaces the ``--quit/--kill`` options
-    * ``sopel restart`` replaces the ``--restart`` option
-    * ``sopel configure`` replaces the
-      ``-w/--configure-all/--configure-modules`` options
+def main(argv=None):
+    """Sopel run script entry point.
 
-    The ``-v`` option for "version" is deprecated, ``-V/--version`` should be
-    used instead.
+    :param list argv: command line arguments
+    """
+    # Build parser and handle default command
+    global_options = ['-h', '--help', '-V', '--version']
+    parser = build_parser()
 
-    .. seealso::
+    argv = argv or sys.argv[1:]
+    if not argv:
+        # No argument: assume start sub-command
+        argv = ['start']
 
-       The github issue `#1471`__ tracks various changes requested for future
-       versions of Sopel, some of them related to this legacy command.
+    elif argv[0].startswith('-') and argv[0] not in global_options:
+        # No sub-command and no global option
+        argv = ['start'] + argv
 
-       .. __: https://github.com/sopel-irc/sopel/issues/1471
+    # Parse The Command Line
+    opts = parser.parse_args(argv)
 
-    """
-    # Step One: Handle "No config needed" options
+    # Handle "-V/--version" option
     if opts.version:
         print_version()
         return
-    elif opts.version_legacy:
-        tools.stderr(
-            'WARNING: option -v is deprecated; '
-            'use `sopel -V/--version` instead')
-        print_version()
-        return
-
-    configpath = utils.find_config(opts.configdir, opts.config)
-
-    if opts.wizard:
-        tools.stderr(
-            'WARNING: option -w/--configure-all is deprecated; '
-            'use `sopel configure` instead')
-        utils.wizard(configpath)
-        return
-
-    if opts.mod_wizard:
-        tools.stderr(
-            'WARNING: option --configure-modules is deprecated; '
-            'use `sopel configure --plugins` instead')
-        utils.plugins_wizard(configpath)
-        return
 
-    if opts.list_configs:
-        tools.stderr(
-            'WARNING: option --list is deprecated; '
-            'use `sopel-config list` instead')
-        print_config(opts.configdir)
-        return
-
-    # Step Two: Get the configuration file and prepare to run
     try:
-        settings = get_configuration(opts)
-    except config.ConfigurationError as e:
-        tools.stderr(e)
-        return ERR_CODE_NO_RESTART
-
-    if settings.core.not_configured:
-        tools.stderr('Bot is not configured, can\'t start')
-        return ERR_CODE_NO_RESTART
-
-    # Step Three: Handle process-lifecycle options and manage the PID file
-    pid_dir = settings.core.pid_dir
-    pid_file_path = get_pid_filename(settings, pid_dir)
-    old_pid = get_running_pid(pid_file_path)
-
-    if old_pid is not None and tools.check_pid(old_pid):
-        if not opts.quit and not opts.kill and not opts.restart:
-            tools.stderr(
-                'There\'s already a Sopel instance running with this config file')
-            tools.stderr(
-                'Try using either the `sopel stop` command or the `sopel restart` command')
-            return ERR_CODE
-        elif opts.kill:
-            tools.stderr(
-                'WARNING: option -k/--kill is deprecated; '
-                'use `sopel stop --kill` instead')
-            tools.stderr('Killing the Sopel')
-            os.kill(old_pid, signal.SIGKILL)
-            return
-        elif opts.quit:
-            tools.stderr(
-                'WARNING: options -q/--quit is deprecated; '
-                'use `sopel stop` instead')
-            tools.stderr('Signaling Sopel to stop gracefully')
-            if hasattr(signal, 'SIGUSR1'):
-                os.kill(old_pid, signal.SIGUSR1)
-            else:
-                # Windows will not generate SIGTERM itself
-                # https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/signal
-                os.kill(old_pid, signal.SIGTERM)
-            return
-        elif opts.restart:
-            tools.stderr(
-                'WARNING: options --restart is deprecated; '
-                'use `sopel restart` instead')
-            tools.stderr('Asking Sopel to restart')
-            if hasattr(signal, 'SIGUSR2'):
-                os.kill(old_pid, signal.SIGUSR2)
-            else:
-                # Windows will not generate SIGILL itself
-                # https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/signal
-                os.kill(old_pid, signal.SIGILL)
-            return
-    elif opts.kill or opts.quit or opts.restart:
-        tools.stderr('Sopel is not running!')
-        return ERR_CODE
+        # Check "Do not run as root"
+        check_not_root()
 
-    if opts.daemonize:
-        child_pid = os.fork()
-        if child_pid != 0:
-            return
-    with open(pid_file_path, 'w') as pid_file:
-        pid_file.write(str(os.getpid()))
-
-    # Step Four: Initialize and run Sopel
-    ret = run(settings, pid_file_path)
-    os.unlink(pid_file_path)
-    if ret == -1:
-        os.execv(sys.executable, ['python'] + sys.argv)
-    else:
-        return ret
-
-
-def main(argv=None):
-    """Sopel run script entry point.
-
-    :param list argv: command line arguments
-    """
-    try:
-        # Step One: Parse The Command Line
-        parser = build_parser()
-
-        # make sure to have an action first (`legacy` by default)
-        # TODO: `start` should be the default in Sopel 8
-        argv = argv or sys.argv[1:]
-        if not argv:
-            argv = ['legacy']
-        elif argv[0].startswith('-') and argv[0] not in ['-h', '--help']:
-            argv = ['legacy'] + argv
-
-        opts = parser.parse_args(argv)
-
-        # Step Two: "Do not run as root" checks
-        try:
-            check_not_root()
-        except RuntimeError as err:
-            tools.stderr('%s' % err)
-            return ERR_CODE
-
-        # Step Three: Handle command
-        action = getattr(opts, 'action', 'legacy')
+        # Select command
+        action = getattr(opts, 'action', None)
         command = {
-            'legacy': command_legacy,
             'start': command_start,
             'configure': command_configure,
             'stop': command_stop,
             'restart': command_restart,
-        }.get(action)
+        }[action]
+
+        # Run command
         return command(opts)
+    except KeyError:
+        parser.print_usage()
+        return ERR_CODE
     except KeyboardInterrupt:
         print("\n\nInterrupted")
         return ERR_CODE
+    except RuntimeError as err:
+        tools.stderr(str(err))
+        return ERR_CODE
 
 
 if __name__ == '__main__':


Test patch:
diff --git a/test/cli/test_cli_run.py b/test/cli/test_cli_run.py
--- a/test/cli/test_cli_run.py
+++ b/test/cli/test_cli_run.py
@@ -53,136 +53,6 @@ def default_empty_config_env(monkeypatch):
     monkeypatch.delenv("SOPEL_CONFIG_DIR", raising=False)
 
 
-def test_build_parser_legacy():
-    """Assert parser's namespace exposes legacy's options (default values)"""
-    parser = build_parser()
-    options = parser.parse_args(['legacy'])
-
-    assert isinstance(options, argparse.Namespace)
-    assert hasattr(options, 'config')
-    assert hasattr(options, 'configdir')
-    assert hasattr(options, 'daemonize')
-    assert hasattr(options, 'quiet')
-    assert hasattr(options, 'quit')
-    assert hasattr(options, 'kill')
-    assert hasattr(options, 'restart')
-    assert hasattr(options, 'version')
-    assert hasattr(options, 'version_legacy')
-    assert hasattr(options, 'wizard')
-    assert hasattr(options, 'mod_wizard')
-    assert hasattr(options, 'list_configs')
-
-    assert options.config == 'default'
-    assert options.configdir == config.DEFAULT_HOMEDIR
-    assert options.daemonize is False
-    assert options.quiet is False
-    assert options.quit is False
-    assert options.kill is False
-    assert options.restart is False
-    assert options.version is False
-    assert options.version_legacy is False
-    assert options.wizard is False
-    assert options.mod_wizard is False
-    assert options.list_configs is False
-
-
-def test_build_parser_legacy_config():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-c', 'custom'])
-    assert options.config == 'custom'
-
-    options = parser.parse_args(['legacy', '--config', 'custom'])
-    assert options.config == 'custom'
-
-
-def test_build_parser_legacy_configdir():
-    parser = build_parser()
-
-    options = parser.parse_args(['legacy', '--config-dir', 'custom'])
-    assert options.configdir == 'custom'
-
-
-def test_build_parser_legacy_daemonize():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-d'])
-    assert options.daemonize is True
-
-    options = parser.parse_args(['legacy', '--fork'])
-    assert options.daemonize is True
-
-
-def test_build_parser_legacy_quiet():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '--quiet'])
-    assert options.quiet is True
-
-
-def test_build_parser_legacy_quit():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-q'])
-    assert options.quit is True
-
-    options = parser.parse_args(['legacy', '--quit'])
-    assert options.quit is True
-
-
-def test_build_parser_legacy_kill():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-k'])
-    assert options.kill is True
-
-    options = parser.parse_args(['legacy', '--kill'])
-    assert options.kill is True
-
-
-def test_build_parser_legacy_restart():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-r'])
-    assert options.restart is True
-
-    options = parser.parse_args(['legacy', '--restart'])
-    assert options.restart is True
-
-
-def test_build_parser_legacy_version():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-v'])
-    assert options.version is False
-    assert options.version_legacy is True
-
-    options = parser.parse_args(['legacy', '-V'])
-    assert options.version is True
-    assert options.version_legacy is False
-
-    options = parser.parse_args(['legacy', '--version'])
-    assert options.version is True
-    assert options.version_legacy is False
-
-
-def test_build_parser_legacy_wizard():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-w'])
-    assert options.wizard is True
-    assert options.mod_wizard is False
-
-    options = parser.parse_args(['legacy', '--configure-all'])
-    assert options.wizard is True
-    assert options.mod_wizard is False
-
-    options = parser.parse_args(['legacy', '--configure-modules'])
-    assert options.wizard is False
-    assert options.mod_wizard is True
-
-
-def test_build_parser_legacy_list_config():
-    parser = build_parser()
-    options = parser.parse_args(['legacy', '-l'])
-    assert options.list_configs is True
-
-    options = parser.parse_args(['legacy', '--list'])
-    assert options.list_configs is True
-
-
 def test_build_parser_start():
     """Assert parser's namespace exposes start's options (default values)"""
     parser = build_parser()
@@ -372,7 +242,7 @@ def test_get_configuration(tmpdir):
     ]))
 
     parser = build_parser()
-    options = parser.parse_args(['legacy', '-c', 'default.cfg'])
+    options = parser.parse_args(['start', '-c', 'default.cfg'])
 
     with cd(working_dir.strpath):
         result = get_configuration(options)