diff options
-rw-r--r-- | cmd2/__init__.py | 2 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 32 | ||||
-rw-r--r-- | cmd2/cmd2.py | 99 | ||||
-rw-r--r-- | cmd2/constants.py | 4 | ||||
-rw-r--r-- | cmd2/decorators.py | 31 | ||||
-rw-r--r-- | docs/features/modular_commands.rst | 126 | ||||
-rw-r--r-- | examples/modular_subcommands.py | 110 |
7 files changed, 396 insertions, 8 deletions
diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 1fb01b16..19e620be 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -30,7 +30,7 @@ from .argparse_custom import DEFAULT_ARGUMENT_PARSER from .cmd2 import Cmd from .command_definition import CommandSet, with_default_category from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category +from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, as_subcommand_to from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks from . import plugin from .parsing import Statement diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 74bddfc7..689c1db7 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -724,6 +724,24 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): return result +class _UnloadableSubParsersAction(argparse._SubParsersAction): + """Extends the argparse internal SubParsers action to allow sub-parsers to be removed dynamically""" + def remove_parser(self, name): + """Removes a sub-parser from the sub-parsers group""" + for choice_action in self._choices_actions: + if choice_action.dest == name: + self._choices_actions.remove(choice_action) + break + + subparser = self._name_parser_map[name] + to_remove = [] + for name, parser in self._name_parser_map.items(): + if parser is subparser: + to_remove.append(name) + for name in to_remove: + del self._name_parser_map[name] + + # noinspection PyCompatibility class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" @@ -754,12 +772,22 @@ class Cmd2ArgumentParser(argparse.ArgumentParser): conflict_handler=conflict_handler, add_help=add_help, allow_abbrev=allow_abbrev) + self.register('action', 'unloadable_parsers', _UnloadableSubParsersAction) + + def add_subparsers(self, unloadable=False, **kwargs): + """ + Custom override. Sets a default title if one was not given. - def add_subparsers(self, **kwargs): - """Custom override. Sets a default title if one was not given.""" + :param unloadable: Flag whether this sub-parsers group will support unloading parsers + :param kwargs: additional keyword arguments + :return: argparse Subparser Action + """ if 'title' not in kwargs: kwargs['title'] = 'subcommands' + if unloadable: + kwargs['action'] = 'unloadable_parsers' + return super().add_subparsers(**kwargs) def error(self, message: str) -> None: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ca60a461..2a1bcbf2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -43,7 +43,7 @@ from contextlib import redirect_stdout from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union from . import ansi, constants, plugin, utils -from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem +from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem, _UnloadableSubParsersAction from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .command_definition import CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX @@ -260,6 +260,8 @@ class Cmd(cmd.Cmd): if not valid: raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg)) + self._register_subcommands(self) + # Stores results from the last command run to enable usage of results in a Python script or interactive console # Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. self.last_result = None @@ -429,12 +431,12 @@ class Cmd(cmd.Cmd): installed_attributes = [] try: - for method in methods: - command = method[0][len(COMMAND_FUNC_PREFIX):] - command_wrapper = _partial_passthru(method[1], self) + for method_name, method in methods: + command = method_name[len(COMMAND_FUNC_PREFIX):] + command_wrapper = _partial_passthru(method, self) self.__install_command_function(command, command_wrapper, type(cmdset).__name__) - installed_attributes.append(method[0]) + installed_attributes.append(method_name) completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) @@ -451,6 +453,8 @@ class Cmd(cmd.Cmd): installed_attributes.append(help_func_name) self._installed_command_sets.append(cmdset) + + self._register_subcommands(cmdset) except Exception: for attrib in installed_attributes: delattr(self, attrib) @@ -500,6 +504,9 @@ class Cmd(cmd.Cmd): :param cmdset: CommandSet to uninstall """ if cmdset in self._installed_command_sets: + + self._unregister_subcommands(cmdset) + methods = inspect.getmembers( cmdset, predicate=lambda meth: inspect.ismethod(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) @@ -517,6 +524,88 @@ class Cmd(cmd.Cmd): cmdset.on_unregister(self) self._installed_command_sets.remove(cmdset) + def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + """ + Register sub-commands with their base command + + :param cmdset: CommandSet containing sub-commands + """ + if not (cmdset is self or cmdset in self._installed_command_sets): + raise ValueError('Adding sub-commands from an unregistered CommandSet') + + # find all methods that start with the sub-command prefix + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + and hasattr(meth, constants.SUBCMD_ATTR_NAME) + and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) + and hasattr(meth, constants.CMD_ATTR_ARGPARSER) + ) + + # iterate through all matching methods + for method_name, method in methods: + subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) + command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER) + + # Search for the base command function and verify it has an argparser defined + command_func = self.cmd_func(command_name) + if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): + raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) + if command_parser is None: + raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + + if hasattr(method, '__doc__') and method.__doc__ is not None: + help_text = method.__doc__.splitlines()[0] + else: + help_text = subcommand_name + + if isinstance(cmdset, CommandSet): + command_handler = _partial_passthru(method, self) + else: + command_handler = method + subcmd_parser.set_defaults(handler=command_handler) + + for action in command_parser._actions: + if isinstance(action, _UnloadableSubParsersAction): + action.add_parser(subcommand_name, parents=[subcmd_parser], help=help_text) + + def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + """ + Unregister sub-commands from their base command + + :param cmdset: CommandSet containing sub-commands + """ + if not (cmdset is self or cmdset in self._installed_command_sets): + raise ValueError('Removing sub-commands from an unregistered CommandSet') + + # find all methods that start with the sub-command prefix + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + and hasattr(meth, constants.SUBCMD_ATTR_NAME) + and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) + and hasattr(meth, constants.CMD_ATTR_ARGPARSER) + ) + + # iterate through all matching methods + for method_name, method in methods: + subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) + command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + + # Search for the base command function and verify it has an argparser defined + command_func = self.cmd_func(command_name) + if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): + raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) + if command_parser is None: + raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + + for action in command_parser._actions: + if isinstance(action, _UnloadableSubParsersAction): + action.remove_parser(subcommand_name) + def add_settable(self, settable: Settable) -> None: """ Convenience method to add a settable parameter to ``self.settables`` diff --git a/cmd2/constants.py b/cmd2/constants.py index 81d1a29b..0135e328 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -49,3 +49,7 @@ CMD_ATTR_ARGPARSER = 'argparser' # Whether or not tokens are unquoted before sending to argparse CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' + +# sub-command attributes for the base command name and the sub-command name +SUBCMD_ATTR_COMMAND = 'parent_command' +SUBCMD_ATTR_NAME = 'subcommand_name' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 8c3739f1..6e3b7acf 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -335,3 +335,34 @@ def with_argparser(parser: argparse.ArgumentParser, *, # noinspection PyTypeChecker return arg_decorator + + +def as_subcommand_to(command: str, + subcommand: str, + parser: argparse.ArgumentParser) -> Callable[[argparse.Namespace], Optional[bool]]: + """ + Tag this method as a sub-command to an existing argparse decorated command. + + :param command: Command Name + :param subcommand: Sub-command name + :param parser: argparse Parser to for this sub-command + :return: Wrapper function that can receive an argparse.Namespace + """ + def arg_decorator(func: Callable): + _set_parser_prog(parser, subcommand) + + # If the description has not been set, then use the method docstring if one exists + if parser.description is None and func.__doc__: + parser.description = func.__doc__ + + parser.set_defaults(func=func) + + # # Set some custom attributes for this command + setattr(func, constants.SUBCMD_ATTR_COMMAND, command) + setattr(func, constants.CMD_ATTR_ARGPARSER, parser) + setattr(func, constants.SUBCMD_ATTR_NAME, subcommand) + + return func + + # noinspection PyTypeChecker + return arg_decorator diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index d94e225a..82298c8f 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -19,6 +19,9 @@ Features * Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded dynamically during application execution. This can enable features such as dynamically loaded modules that add additional commands. +* Sub-command Injection - Sub-commands can be defined separately from the base command. This allows for a more + action-centric instead of object-centric command system while still organizing your code and handlers around the + objects being managed. See the examples for more details: https://github.com/python-cmd2/cmd2/tree/master/plugins/command_sets/examples @@ -199,3 +202,126 @@ You may need to disable command auto-loading if you need dynamically load comman if __name__ == '__main__': app = ExampleApp() app.cmdloop() + + +Injecting Sub-Commands +---------------------- + +Description +~~~~~~~~~~~ +Using the `with_argparse` decorator, it is possible to define sub-commands for your command. This has a tendency to +either drive your interface into an object-centric interface. For example, imagine you have a tool that manages your +media collection and you want to manage movies or shows. An object-centric approach would push you to have base commands +such as `movies` and `shows` which each have sub-commands `add`, `edit`, `list`, `delete`. If you wanted to present an +action-centric command set, so that `add`, `edit`, `list`, and `delete` are the base commands, you'd have to organize +your code around these similar actions rather than organizing your code around similar objects being managed. + +Sub-command injection allows you to inject sub-commands into a base command to present an interface that is sensible to +a user while still organizing your code in whatever structure make more logical sense to the developer. + +Example +~~~~~~~ + +This example is a variation on the Dynamic Commands example above. A `cut` command is introduced as a base +command and each CommandSet + +.. code-block:: python + + import argparse + import cmd2 + from cmd2 import CommandSet, with_argparser, with_category, with_default_category + + + @with_default_category('Fruits') + class LoadableFruits(CommandSet): + def __init__(self): + super().__init__() + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + + @with_default_category('Vegetables') + class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + + class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=False, **kwargs) + + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + load_parser = cmd2.Cmd2ArgumentParser('load') + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category('Command Loading') + def do_load(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + try: + self.install_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.install_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + def do_unload(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + self.uninstall_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.uninstall_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + + @with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + + if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py new file mode 100644 index 00000000..e4d2fe45 --- /dev/null +++ b/examples/modular_subcommands.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# coding=utf-8 +"""A simple example demonstracting modular sub-command loading through CommandSets + +In this example, there are loadable CommandSets defined. Each CommandSet has 1 sub-command defined that will be +attached to the 'cut' command. + +The cut command is implemented with the `do_cut` function that has been tagged as an argparse command. + +The `load` and `unload` command will load and unload the CommandSets. The available top level commands as well as +sub-commands to the `cut` command will change depending on which CommandSets are loaded. +""" +import argparse +import cmd2 +from cmd2 import CommandSet, with_argparser, with_category, with_default_category + + +@with_default_category('Fruits') +class LoadableFruits(CommandSet): + def __init__(self): + super().__init__() + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + +@with_default_category('Vegetables') +class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=False, **kwargs) + + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + load_parser = cmd2.Cmd2ArgumentParser('load') + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category('Command Loading') + def do_load(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + try: + self.install_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.install_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + def do_unload(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + self.uninstall_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.uninstall_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + + @with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() |