From efbdd8cff4e7ad7511b60462f01f44f4cb95bb70 Mon Sep 17 00:00:00 2001
From: Ben Sturmfels <ben@sturm.com.au>
Date: Fri, 22 Nov 2019 15:43:10 +1100
Subject: [PATCH] Allow plugins to register custom gmg commands.

This change adds a `register_commands` function to the plugins API to be called
during plugin setup hook. The `sampleplugin` now includes a `samplecommand`.
---
 mediagoblin/gmg_commands/__init__.py         | 62 ++++++++++++++++----
 mediagoblin/plugins/sampleplugin/__init__.py | 20 ++++++-
 mediagoblin/tools/pluginapi.py               | 27 +++++++++
 3 files changed, 98 insertions(+), 11 deletions(-)

diff --git a/mediagoblin/gmg_commands/__init__.py b/mediagoblin/gmg_commands/__init__.py
index 0034fd98..ba5339ca 100644
--- a/mediagoblin/gmg_commands/__init__.py
+++ b/mediagoblin/gmg_commands/__init__.py
@@ -20,6 +20,8 @@ import shutil
 
 import six
 
+from mediagoblin.init import plugins, setup_global_and_app_config
+from mediagoblin.tools.pluginapi import PluginManager
 from mediagoblin.tools.common import import_component
 
 import logging
@@ -90,18 +92,64 @@ SUBCOMMAND_MAP = {
     }
 
 
-def main_cli():
+def argparser_with_conf_file(add_help=True):
+    """Makes an base ArgumentParser including the --conf_file option.
+
+    Factored out to avoid duplication caused by initial parsing before plugins
+    are loaded.
+
+    """
     parser = argparse.ArgumentParser(
-        description='GNU MediaGoblin utilities.')
+        description='GNU MediaGoblin utilities.',
+        add_help=add_help)
     parser.add_argument(
         '-cf', '--conf_file', default=None,
         help=(
             "Config file used to set up environment.  "
             "Default to mediagoblin_local.ini if readable, "
             "otherwise mediagoblin.ini"))
+    return parser
+
+
+def find_conf_file(conf_file=None):
+    """Find the config file when not explictly specified.
+
+    Factored out to avoid duplication caused by initial parsing before plugins
+    are loaded.
+
+    TODO: Give that mediagoblin_local.ini is now deprecated, this could be
+    removed entirely. How would we warn people that are still using
+    mediagoblin_local.ini?
+
+    """
+    if conf_file is None:
+        if os.path.exists('mediagoblin_local.ini') \
+                and os.access('mediagoblin_local.ini', os.R_OK):
+            conf_file = 'mediagoblin_local.ini'
+        else:
+            conf_file = 'mediagoblin.ini'
+    return conf_file
+
 
+def main_cli():
+    # Initial argument parse to load config file and set up plugins.
+    #
+    # Until the config file has been read, we don't know what plugins will be
+    # activated and what custom gmg commands they define. Here we custom
+    # commands using "parse_known_args". The command line help is also not
+    # useful yet, so that's disabled.
+    initial_parser = argparser_with_conf_file(add_help=False)
+    initial_args, _ = initial_parser.parse_known_args()
+    initial_args.conf_file = find_conf_file(initial_args.conf_file)
+    global_config, app_config = setup_global_and_app_config(initial_args.conf_file)
+    plugins.setup_plugins()
+
+    # Full argument parse including any gmg commands from now loaded plugins.
+    parser = argparser_with_conf_file()
     subparsers = parser.add_subparsers(help='sub-command help')
-    for command_name, command_struct in six.iteritems(SUBCOMMAND_MAP):
+    # Merge command dicts without mutating - ugly in Python < 3.9.
+    subcommands = {**SUBCOMMAND_MAP, **PluginManager().get_commands()}
+    for command_name, command_struct in six.iteritems(subcommands):
         if 'help' in command_struct:
             subparser = subparsers.add_parser(
                 command_name, help=command_struct['help'])
@@ -116,13 +164,7 @@ def main_cli():
         subparser.set_defaults(func=exec_func)
 
     args = parser.parse_args()
-    args.orig_conf_file = args.conf_file
-    if args.conf_file is None:
-        if os.path.exists('mediagoblin_local.ini') \
-                and os.access('mediagoblin_local.ini', os.R_OK):
-            args.conf_file = 'mediagoblin_local.ini'
-        else:
-            args.conf_file = 'mediagoblin.ini'
+    args.conf_file = initial_args.conf_file
 
     # This is a hopefully TEMPORARY hack for adding a mediagoblin.ini
     # if none exists, to make up for a deficiency as we are migrating
diff --git a/mediagoblin/plugins/sampleplugin/__init__.py b/mediagoblin/plugins/sampleplugin/__init__.py
index 2cd077a2..9f83a069 100644
--- a/mediagoblin/plugins/sampleplugin/__init__.py
+++ b/mediagoblin/plugins/sampleplugin/__init__.py
@@ -17,7 +17,8 @@
 
 import logging
 
-from mediagoblin.tools.pluginapi import get_config
+from mediagoblin.gmg_commands import util as commands_util
+from mediagoblin.tools.pluginapi import get_config, register_commands
 
 
 _log = logging.getLogger(__name__)
@@ -36,7 +37,24 @@ def setup_plugin():
         _log.info('There is no configuration set.')
     _setup_plugin_called += 1
 
+    register_commands({
+        'samplecommand': {
+            'setup': 'mediagoblin.plugins.sampleplugin:samplecommand_parser_setup',
+            'func': 'mediagoblin.plugins.sampleplugin:samplecommand',
+            'help': 'Do something'},
+        })
 
 hooks = {
     'setup': setup_plugin
     }
+
+
+def samplecommand_parser_setup(subparser):
+    subparser.add_argument(
+        '--username','-u',
+        help="Username to greet")
+
+
+def samplecommand(args):
+    args.username = commands_util.prompt_if_not_set(args.username, "Username:")
+    print("Hello {args.username}, I'm a command!".format(args=args))
diff --git a/mediagoblin/tools/pluginapi.py b/mediagoblin/tools/pluginapi.py
index 1eabe9f1..9071f201 100644
--- a/mediagoblin/tools/pluginapi.py
+++ b/mediagoblin/tools/pluginapi.py
@@ -88,6 +88,9 @@ class PluginManager(object):
 
         # list of registered routes
         "routes": [],
+
+        # dictionary of commands
+        "commands": {},
         }
 
     def clear(self):
@@ -146,6 +149,12 @@ class PluginManager(object):
     def get_template_hooks(self, hook_name):
         return self.template_hooks.get(hook_name, [])
 
+    def register_commands(self, commands):
+        self.commands.update(commands)
+
+    def get_commands(self):
+        return self.commands
+
 
 def register_routes(routes):
     """Registers one or more routes
@@ -274,6 +283,24 @@ def get_hook_templates(hook_name):
     return PluginManager().get_template_hooks(hook_name)
 
 
+def register_commands(commands):
+    """
+    Register a list of gmg commands.
+
+    Takes a dictionary of gmg subcommands, in the same format as mediagoblin.gmg_commands.SUBCOMMAND_MAP.
+
+    Example:
+
+    .. code-block:: python
+
+    {'batchaddmedia': {
+        'setup': 'mediagoblin.gmg_commands.batchaddmedia:parser_setup',
+        'func': 'mediagoblin.gmg_commands.batchaddmedia:batchaddmedia',
+        'help': 'Add many media entries at once'}}
+    """
+    PluginManager().register_commands(commands)
+
+
 #############################
 ## Hooks: The Next Generation
 #############################
-- 
2.26.2

