diff --git a/moulinette/actionsmap.py b/moulinette/actionsmap.py index 5546913b..cd58b880 100644 --- a/moulinette/actionsmap.py +++ b/moulinette/actionsmap.py @@ -456,8 +456,16 @@ class ActionsMap(object): return arguments.get(TO_RETURN_PROP) # Retrieve action information - namespace, category, action = tid - func_name = '%s_%s' % (category, action.replace('-', '_')) + if len(tid) == 4: + namespace, category, subcategory, action = tid + func_name = '%s_%s_%s' % (category, subcategory, action.replace('-', '_')) + full_action_name = "%s.%s.%s.%s" % (namespace, category, subcategory, action) + else: + assert len(tid) == 3 + namespace, category, action = tid + subcategory = None + func_name = '%s_%s' % (category, action.replace('-', '_')) + full_action_name = "%s.%s.%s" % (namespace, category, action) # Lock the moulinette for the namespace with MoulinetteLock(namespace, timeout): @@ -467,18 +475,18 @@ class ActionsMap(object): fromlist=[func_name]) func = getattr(mod, func_name) except (AttributeError, ImportError): - logger.exception("unable to load function %s.%s.%s", - namespace, category, func_name) + logger.exception("unable to load function %s.%s", + namespace, func_name) raise MoulinetteError(errno.EIO, m18n.g('error_see_log')) else: log_id = start_action_logging() if logger.isEnabledFor(logging.DEBUG): # Log arguments in debug mode only for safety reasons - logger.info('processing action [%s]: %s.%s.%s with args=%s', - log_id, namespace, category, action, arguments) + logger.info('processing action [%s]: %s with args=%s', + log_id, full_action_name, arguments) else: - logger.info('processing action [%s]: %s.%s.%s', - log_id, namespace, category, action) + logger.info('processing action [%s]: %s', + log_id, full_action_name) # Load translation and process the action m18n.load_namespace(namespace) @@ -595,7 +603,16 @@ class ActionsMap(object): # category_name is stuff like "user", "domain", "hooks"... # category_values is the values of this category (like actions) for category_name, category_values in actionsmap.items(): - actions = category_values.pop('actions') + + if "actions" in category_values: + actions = category_values.pop('actions') + else: + actions = {} + + if "subcategories" in category_values: + subcategories = category_values.pop('subcategories') + else: + subcategories = {} # Get category parser category_parser = top_parser.add_category_parser(category_name, @@ -625,4 +642,36 @@ class ActionsMap(object): if 'configuration' in action_options: category_parser.set_conf(tid, action_options['configuration']) + # subcategory_name is like "cert" in "domain cert status" + # subcategory_values is the values of this subcategory (like actions) + for subcategory_name, subcategory_values in subcategories.items(): + + actions = subcategory_values.pop('actions') + + # Get subcategory parser + subcategory_parser = category_parser.add_subcategory_parser(subcategory_name, **subcategory_values) + + # action_name is like "status" of "domain cert status" + # action_options are the values + for action_name, action_options in actions.items(): + arguments = action_options.pop('arguments', {}) + tid = (namespace, category_name, subcategory_name, action_name) + + try: + # Get action parser + action_parser = subcategory_parser.add_action_parser(action_name, tid, **action_options) + except AttributeError: + # No parser for the action + continue + + # Store action identifier and add arguments + action_parser.set_defaults(_tid=tid) + action_parser.add_arguments(arguments, + extraparser=self.extraparser, + format_arg_names=top_parser.format_arg_names, + validate_extra=validate_extra) + + if 'configuration' in action_options: + category_parser.set_conf(tid, action_options['configuration']) + return top_parser diff --git a/moulinette/interfaces/__init__.py b/moulinette/interfaces/__init__.py index 994d18e3..108a003d 100644 --- a/moulinette/interfaces/__init__.py +++ b/moulinette/interfaces/__init__.py @@ -5,7 +5,8 @@ import os import errno import logging import argparse -from collections import deque +import copy +from collections import deque, OrderedDict from moulinette import msignals, msettings, m18n from moulinette.core import (init_authenticator, MoulinetteError) @@ -461,7 +462,7 @@ class _ExtendedSubParsersAction(argparse._SubParsersAction): self.required = required self._deprecated_command_map = {} - def add_parser(self, name, **kwargs): + def add_parser(self, name, type_=None, **kwargs): deprecated = kwargs.pop('deprecated', False) deprecated_alias = kwargs.pop('deprecated_alias', []) @@ -478,6 +479,8 @@ class _ExtendedSubParsersAction(argparse._SubParsersAction): self._deprecated_command_map[command] = name self._name_parser_map[command] = parser + parser.type = type_ + return parser def __call__(self, parser, namespace, values, option_string=None): @@ -576,6 +579,63 @@ class ExtendedArgumentParser(argparse.ArgumentParser): action, arg_strings) return value + # Adapted from : + # https://github.com/python/cpython/blob/af26c15110b76195e62a06d17e39176d42c0511c/Lib/argparse.py#L2293-L2314 + def format_help(self): + formatter = self._get_formatter() + + # usage + formatter.add_usage(self.usage, self._actions, + self._mutually_exclusive_groups) + + # description + formatter.add_text(self.description) + + # positionals, optionals and user-defined groups + for action_group in self._action_groups: + + # Dirty hack to separate 'subcommands' + # into 'actions' and 'subcategories' + if action_group.title == "subcommands": + + # Make a copy of the "action group actions"... + choice_actions = action_group._group_actions[0]._choices_actions + actions_subparser = copy.copy(action_group._group_actions[0]) + subcategories_subparser = copy.copy(action_group._group_actions[0]) + + # Filter "action"-type and "subcategory"-type commands + actions_subparser.choices = OrderedDict([(k,v) for k,v in actions_subparser.choices.items() if v.type == "action"]) + subcategories_subparser.choices = OrderedDict([(k,v) for k,v in subcategories_subparser.choices.items() if v.type == "subcategory"]) + + actions_choices = actions_subparser.choices.keys() + subcategories_choices = subcategories_subparser.choices.keys() + + actions_subparser._choices_actions = [ c for c in choice_actions if c.dest in actions_choices ] + subcategories_subparser._choices_actions = [ c for c in choice_actions if c.dest in subcategories_choices ] + + # Display each section (actions and subcategories) + if actions_choices != []: + formatter.start_section("actions") + formatter.add_arguments([actions_subparser]) + formatter.end_section() + + if subcategories_choices != []: + formatter.start_section("subcategories") + formatter.add_arguments([subcategories_subparser]) + formatter.end_section() + + else: + formatter.start_section(action_group.title) + formatter.add_text(action_group.description) + formatter.add_arguments(action_group._group_actions) + formatter.end_section() + + # epilog + formatter.add_text(self.epilog) + + # determine help from format above + return formatter.format_help() + # This is copy-pasta from the original argparse.HelpFormatter : # https://github.com/python/cpython/blob/1e73dbbc29c96d0739ffef92db36f63aa1aa30da/Lib/argparse.py#L293-L383 diff --git a/moulinette/interfaces/api.py b/moulinette/interfaces/api.py index cc469916..8f6b25ed 100644 --- a/moulinette/interfaces/api.py +++ b/moulinette/interfaces/api.py @@ -579,6 +579,9 @@ class ActionsMapParser(BaseActionsMapParser): def add_category_parser(self, name, **kwargs): return self + def add_subcategory_parser(self, name, **kwargs): + return self + def add_action_parser(self, name, tid, api=None, **kwargs): """Add a parser for an action diff --git a/moulinette/interfaces/cli.py b/moulinette/interfaces/cli.py index 419af1ab..203e62c3 100644 --- a/moulinette/interfaces/cli.py +++ b/moulinette/interfaces/cli.py @@ -268,6 +268,24 @@ class ActionsMapParser(BaseActionsMapParser): """ parser = self._subparsers.add_parser(name, help=category_help, **kwargs) + return self.__class__(self, parser, { + 'title': "subcommands", 'required': True + }) + + def add_subcategory_parser(self, name, subcategory_help=None, **kwargs): + """Add a parser for a subcategory + + Keyword arguments: + - subcategory_help -- A brief description for the category + + Returns: + A new ActionsMapParser object for the category + + """ + parser = self._subparsers.add_parser(name, + type_="subcategory", + help=subcategory_help, + **kwargs) return self.__class__(self, parser, { 'title': "actions", 'required': True }) @@ -285,7 +303,9 @@ class ActionsMapParser(BaseActionsMapParser): A new ExtendedArgumentParser object for the action """ - return self._subparsers.add_parser(name, help=action_help, + return self._subparsers.add_parser(name, + type_="action", + help=action_help, deprecated=deprecated, deprecated_alias=deprecated_alias)