diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a6b4fcb..d3dfb1f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.3.0 (August 4, 2020) +* Enchancements + * Added CommandSet - Enables defining a separate loadable module of commands to register/unregister + with your cmd2 application. + ## 1.2.1 (July 14, 2020) * Bug Fixes * Relax minimum version of `importlib-metadata` to >= 1.6.0 when using Python < 3.8 diff --git a/Pipfile b/Pipfile index e03fc25d9..b384709cb 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ wcwidth = ">=0.1.7" [dev-packages] cmd2 = {editable = true,path = "."} +cmd2_ext_test = {editable = true,path = "plugins/ext_test"} codecov = "*" doc8 = "*" flake8 = "*" diff --git a/cmd2/__init__.py b/cmd2/__init__.py index c3c1f87e7..19e620bed 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -28,8 +28,9 @@ # Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER 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_completer.py b/cmd2/argparse_completer.py index 6acb5abc3..0225d22f0 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -23,6 +23,7 @@ CompletionItem, generate_range_error, ) +from .command_definition import CommandSet from .table_creator import Column, SimpleTable from .utils import CompletionError, basic_complete @@ -181,7 +182,8 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, if isinstance(action, argparse._SubParsersAction): self._subcommand_action = action - def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int, *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Complete the command using the argparse metadata and provided argument dictionary :raises: CompletionError for various types of tab completion errors @@ -358,7 +360,8 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, parent_tokens=parent_tokens) - return completer.complete_command(tokens[token_index:], text, line, begidx, endidx) + return completer.complete_command(tokens[token_index:], text, line, begidx, endidx, + cmd_set=cmd_set) else: # Invalid subcommand entered, so no way to complete remaining tokens return [] @@ -403,7 +406,8 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: # Check if we are completing a flag's argument if flag_arg_state is not None: completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) + begidx, endidx, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -423,7 +427,8 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: pos_arg_state = _ArgumentState(action) completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) + begidx, endidx, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -543,7 +548,8 @@ def format_help(self, tokens: List[str]) -> str: def _complete_for_arg(self, arg_action: argparse.Action, text: str, line: str, begidx: int, endidx: int, - consumed_arg_values: Dict[str, List[str]]) -> List[str]: + consumed_arg_values: Dict[str, List[str]], *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Tab completion routine for an argparse argument :return: list of completions @@ -563,6 +569,12 @@ def _complete_for_arg(self, arg_action: argparse.Action, kwargs = {} if isinstance(arg_choices, ChoicesCallable): if arg_choices.is_method: + cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set) + if cmd_set is not None: + if isinstance(cmd_set, CommandSet): + # If command is part of a CommandSet, `self` should be the CommandSet and Cmd will be next + if cmd_set is not None: + args.append(cmd_set) args.append(self._cmd2_app) # Check if arg_choices.to_call expects arg_tokens diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 485f65c2a..5dbb9f66e 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -60,18 +60,32 @@ def my_choices_function(): parser.add_argument('-o', '--options', choices_function=my_choices_function) -``choices_method`` - this is exactly like choices_function, but the function -needs to be an instance method of a cmd2-based class. When ArgparseCompleter -calls the method, it will pass the app instance as the self argument. This is -good in cases where the list of choices being generated relies on state data of -the cmd2-based app - - Example:: +``choices_method`` - this is equivalent to choices_function, but the function +needs to be an instance method of a cmd2.Cmd or cmd2.CommandSet subclass. When +ArgparseCompleter calls the method, it well detect whether is is bound to a +CommandSet or Cmd subclass. +If bound to a cmd2.Cmd subclass, it will pass the app instance as the `self` +argument. This is good in cases where the list of choices being generated +relies on state data of the cmd2-based app. +If bound to a cmd2.CommandSet subclass, it will pass the CommandSet instance +as the `self` argument, and the app instance as the positional argument. + + Example bound to cmd2.Cmd:: def my_choices_method(self): ... return my_generated_list + parser.add_argument("arg", choices_method=my_choices_method) + + Example bound to cmd2.CommandSEt:: + + def my_choices_method(self, app: cmd2.Cmd): + ... + return my_generated_list + + parser.add_argument("arg", choices_method=my_choices_method) + ``completer_function`` - pass a tab completion function that does custom completion. Since custom tab completion operations commonly need to modify cmd2's instance variables related to tab completion, it will be rare to need a @@ -84,10 +98,16 @@ def my_completer_function(text, line, begidx, endidx): return completions parser.add_argument('-o', '--options', completer_function=my_completer_function) -``completer_method`` - this is exactly like completer_function, but the -function needs to be an instance method of a cmd2-based class. When -ArgparseCompleter calls the method, it will pass the app instance as the self -argument. cmd2 provides a few completer methods for convenience (e.g., +``completer_method`` - this is equivalent to completer_function, but the function +needs to be an instance method of a cmd2.Cmd or cmd2.CommandSet subclass. When +ArgparseCompleter calls the method, it well detect whether is is bound to a +CommandSet or Cmd subclass. +If bound to a cmd2.Cmd subclass, it will pass the app instance as the `self` +argument. This is good in cases where the list of choices being generated +relies on state data of the cmd2-based app. +If bound to a cmd2.CommandSet subclass, it will pass the CommandSet instance +as the `self` argument, and the app instance as the positional argument. +cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) Example:: @@ -192,11 +212,15 @@ def my_completer_method(self, text, line, begidx, endidx, arg_tokens) completion and enables nargs range parsing. See _add_argument_wrapper for more details on these arguments. -``argparse.ArgumentParser._get_nargs_pattern`` - adds support to for nargs -ranges. See _get_nargs_pattern_wrapper for more details. +``argparse.ArgumentParser._get_nargs_pattern`` - adds support for nargs ranges. +See _get_nargs_pattern_wrapper for more details. -``argparse.ArgumentParser._match_argument`` - adds support to for nargs ranges. +``argparse.ArgumentParser._match_argument`` - adds support for nargs ranges. See _match_argument_wrapper for more details. + +``argparse._SubParsersAction.remove_parser`` - new function which removes a +sub-parser from a sub-parsers group. See _SubParsersAction_remove_parser for +more details. """ import argparse @@ -528,6 +552,42 @@ def _match_argument_wrapper(self, action, arg_strings_pattern) -> int: # noinspection PyProtectedMember argparse.ArgumentParser._match_argument = _match_argument_wrapper + +############################################################################################################ +# Patch argparse._SubParsersAction to add remove_parser function +############################################################################################################ + +def _SubParsersAction_remove_parser(self, name: str): + """ + Removes a sub-parser from a sub-parsers group + + This is a custom method being added to the argparse._SubParsersAction + class so cmd2 can remove subcommands from a parser. + + :param self: instance of the _SubParsersAction being edited + :param name: name of the sub-parser to remove + """ + 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] + + if name in self.choices: + del self.choices[name] + + +# noinspection PyProtectedMember +setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) + + ############################################################################################################ # Unless otherwise noted, everything below this point are copied from Python's # argparse implementation with minor tweaks to adjust output. @@ -728,14 +788,40 @@ def _format_args(self, action, default_metavar) -> str: class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" - def __init__(self, *args, **kwargs) -> None: - if 'formatter_class' not in kwargs: - kwargs['formatter_class'] = Cmd2HelpFormatter - - super().__init__(*args, **kwargs) + def __init__(self, + prog=None, + usage=None, + description=None, + epilog=None, + parents=None, + formatter_class=Cmd2HelpFormatter, + prefix_chars='-', + fromfile_prefix_chars=None, + argument_default=None, + conflict_handler='error', + add_help=True, + allow_abbrev=True) -> None: + super(Cmd2ArgumentParser, self).__init__( + prog=prog, + usage=usage, + description=description, + epilog=epilog, + parents=parents if parents else [], + formatter_class=formatter_class, + prefix_chars=prefix_chars, + fromfile_prefix_chars=fromfile_prefix_chars, + argument_default=argument_default, + conflict_handler=conflict_handler, + add_help=add_help, + allow_abbrev=allow_abbrev) def add_subparsers(self, **kwargs): - """Custom override. Sets a default title if one was not given.""" + """ + Custom override. Sets a default title if one was not given. + + :param kwargs: additional keyword arguments + :return: argparse Subparser Action + """ if 'title' not in kwargs: kwargs['title'] = 'subcommands' diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 70ec508c2..855431d04 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -45,8 +45,17 @@ from . import ansi, constants, plugin, utils from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem 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 from .decorators import with_argparser -from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks +from .exceptions import ( + CommandSetRegistrationError, + Cmd2ShlexError, + EmbeddedConsoleExit, + EmptyStatement, + RedirectionError, + SkipPostcommandHooks +) from .history import History, HistoryItem from .parsing import Macro, MacroArg, Statement, StatementParser, shlex_split from .rl_utils import RlType, rl_get_point, rl_make_safe_prompt, rl_set_prompt, rl_type, rl_warning, vt100_support @@ -87,6 +96,7 @@ class _SavedReadlineSettings: """readline settings that are backed up when switching between readline environments""" + def __init__(self): self.completer = None self.delims = '' @@ -95,6 +105,7 @@ def __init__(self): class _SavedCmd2Env: """cmd2 environment settings that are backed up when entering an interactive Python shell""" + def __init__(self): self.readline_settings = _SavedReadlineSettings() self.readline_module = None @@ -130,7 +141,9 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, startup_script: str = '', use_ipython: bool = False, allow_cli_args: bool = True, transcript_files: Optional[List[str]] = None, allow_redirection: bool = True, multiline_commands: Optional[List[str]] = None, - terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None) -> None: + terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None, + command_sets: Optional[Iterable[CommandSet]] = None, + auto_load_commands: bool = True) -> None: """An easy but powerful framework for writing line-oriented command interpreters. Extends Python's cmd package. @@ -238,6 +251,16 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, multiline_commands=multiline_commands, shortcuts=shortcuts) + # Load modular commands + self._installed_command_sets = [] # type: List[CommandSet] + self._cmd_to_command_sets = {} # type: Dict[str, CommandSet] + if command_sets: + for command_set in command_sets: + self.install_command_set(command_set) + + if auto_load_commands: + self._autoload_commands() + # Verify commands don't have invalid names (like starting with a shortcut) for cur_cmd in self.get_all_commands(): valid, errmsg = self.statement_parser.is_valid_command(cur_cmd) @@ -381,6 +404,289 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. self.matches_sorted = False + self._register_subcommands(self) + + def _autoload_commands(self) -> None: + """Load modular command definitions.""" + # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor + all_commandset_defs = CommandSet.__subclasses__() + existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] + for cmdset_type in all_commandset_defs: + init_sig = inspect.signature(cmdset_type.__init__) + if not (cmdset_type in existing_commandset_types + or len(init_sig.parameters) != 1 + or 'self' not in init_sig.parameters): + cmdset = cmdset_type() + self.install_command_set(cmdset) + + def install_command_set(self, cmdset: CommandSet) -> None: + """ + Installs a CommandSet, loading all commands defined in the CommandSet + + :param cmdset: CommandSet to load + """ + existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] + if type(cmdset) in existing_commandset_types: + raise CommandSetRegistrationError('CommandSet ' + type(cmdset).__name__ + ' is already installed') + + cmdset.on_register(self) + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: isinstance(meth, Callable) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + installed_attributes = [] + try: + 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_name) + + completer_func_name = COMPLETER_FUNC_PREFIX + command + cmd_completer = getattr(cmdset, completer_func_name, None) + if cmd_completer is not None: + completer_wrapper = _partial_passthru(cmd_completer, self) + self._install_completer_function(command, completer_wrapper) + installed_attributes.append(completer_func_name) + + help_func_name = HELP_FUNC_PREFIX + command + cmd_help = getattr(cmdset, help_func_name, None) + if cmd_help is not None: + help_wrapper = _partial_passthru(cmd_help, self) + self._install_help_function(command, help_wrapper) + installed_attributes.append(help_func_name) + + self._cmd_to_command_sets[command] = cmdset + + self._installed_command_sets.append(cmdset) + + self._register_subcommands(cmdset) + except Exception: + for attrib in installed_attributes: + delattr(self, attrib) + if cmdset in self._installed_command_sets: + self._installed_command_sets.remove(cmdset) + if cmdset in self._cmd_to_command_sets.values(): + self._cmd_to_command_sets = \ + {key: val for key, val in self._cmd_to_command_sets.items() if val is not cmdset} + raise + + def _install_command_function(self, command: str, command_wrapper: Callable, context=''): + cmd_func_name = COMMAND_FUNC_PREFIX + command + + # Make sure command function doesn't share name with existing attribute + if hasattr(self, cmd_func_name): + raise CommandSetRegistrationError('Attribute already exists: {} ({})'.format(cmd_func_name, context)) + + # Check if command has an invalid name + valid, errmsg = self.statement_parser.is_valid_command(command) + if not valid: + raise CommandSetRegistrationError("Invalid command name {!r}: {}".format(command, errmsg)) + + # Check if command shares a name with an alias + if command in self.aliases: + self.pwarning("Deleting alias '{}' because it shares its name with a new command".format(command)) + del self.aliases[command] + + # Check if command shares a name with a macro + if command in self.macros: + self.pwarning("Deleting macro '{}' because it shares its name with a new command".format(command)) + del self.macros[command] + + setattr(self, cmd_func_name, command_wrapper) + + def _install_completer_function(self, cmd_name: str, cmd_completer: Callable): + completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name + + if hasattr(self, completer_func_name): + raise CommandSetRegistrationError('Attribute already exists: {}'.format(completer_func_name)) + setattr(self, completer_func_name, cmd_completer) + + def _install_help_function(self, cmd_name: str, cmd_help: Callable): + help_func_name = HELP_FUNC_PREFIX + cmd_name + + if hasattr(self, help_func_name): + raise CommandSetRegistrationError('Attribute already exists: {}'.format(help_func_name)) + setattr(self, help_func_name, cmd_help) + + def uninstall_command_set(self, cmdset: CommandSet): + """ + Uninstalls a CommandSet and unloads all associated commands + :param cmdset: CommandSet to uninstall + """ + if cmdset in self._installed_command_sets: + self._check_uninstallable(cmdset) + self._unregister_subcommands(cmdset) + + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: isinstance(meth, Callable) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + for method in methods: + cmd_name = method[0][len(COMMAND_FUNC_PREFIX):] + + # Enable the command before uninstalling it to make sure we remove both + # the real functions and the ones used by the DisabledCommand object. + if cmd_name in self.disabled_commands: + self.enable_command(cmd_name) + + if cmd_name in self._cmd_to_command_sets: + del self._cmd_to_command_sets[cmd_name] + + delattr(self, COMMAND_FUNC_PREFIX + cmd_name) + + if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name): + delattr(self, COMPLETER_FUNC_PREFIX + cmd_name) + if hasattr(self, HELP_FUNC_PREFIX + cmd_name): + delattr(self, HELP_FUNC_PREFIX + cmd_name) + + cmdset.on_unregister(self) + self._installed_command_sets.remove(cmdset) + + def _check_uninstallable(self, cmdset: CommandSet): + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: isinstance(meth, Callable) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + for method in methods: + command_name = method[0][len(COMMAND_FUNC_PREFIX):] + + # Search for the base command function and verify it has an argparser defined + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) + + def check_parser_uninstallable(parser): + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None) + if attached_cmdset is not None and attached_cmdset is not cmdset: + raise CommandSetRegistrationError( + 'Cannot uninstall CommandSet when another CommandSet depends on it') + check_parser_uninstallable(subparser) + if command_parser is not None: + check_parser_uninstallable(command_parser) + + def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + """ + Register subcommands with their base command + + :param cmdset: CommandSet or cmd2.Cmd subclass containing subcommands + """ + if not (cmdset is self or cmdset in self._installed_command_sets): + raise CommandSetRegistrationError('Cannot register subcommands with an unregistered CommandSet') + + # find all methods that start with the subcommand prefix + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: 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) + full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) # type: str + subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER) + parser_args = getattr(method, constants.SUBCMD_ATTR_PARSER_ARGS, {}) + + command_tokens = full_command_name.split() + command_name = command_tokens[0] + subcommand_names = command_tokens[1:] + + # Search for the base command function and verify it has an argparser defined + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + if command_func is None: + raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' + .format(command_name, str(method))) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) + if command_parser is None: + raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' + .format(command_name, str(method))) + + if isinstance(cmdset, CommandSet): + command_handler = _partial_passthru(method, self) + else: + command_handler = method + subcmd_parser.set_defaults(cmd2_handler=command_handler) + + def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: + if not subcmd_names: + return action + cur_subcmd = subcmd_names.pop(0) + for sub_action in action._actions: + if isinstance(sub_action, argparse._SubParsersAction): + for choice_name, choice in sub_action.choices.items(): + if choice_name == cur_subcmd: + return find_subcommand(choice, subcmd_names) + raise CommandSetRegistrationError('Could not find sub-command "{}"'.format(full_command_name)) + + target_parser = find_subcommand(command_parser, subcommand_names) + + for action in target_parser._actions: + if isinstance(action, argparse._SubParsersAction): + attached_parser = action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) + setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) + + def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + """ + Unregister subcommands from their base command + + :param cmdset: CommandSet containing subcommands + """ + if not (cmdset is self or cmdset in self._installed_command_sets): + raise CommandSetRegistrationError('Cannot unregister subcommands with an unregistered CommandSet') + + # find all methods that start with the subcommand prefix + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: 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 + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + if command_func is None: # pragma: no cover + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason + raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' + .format(command_name, str(method))) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) + if command_parser is None: # pragma: no cover + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason + raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' + .format(command_name, str(method))) + + for action in command_parser._actions: + if isinstance(action, argparse._SubParsersAction): + action.remove_parser(subcommand_name) + def add_settable(self, settable: Settable) -> None: """ Convenience method to add a settable parameter to ``self.settables`` @@ -1198,6 +1504,7 @@ def _completion_for_command(self, text: str, line: str, begidx: int, # Parse the command line statement = self.statement_parser.parse_command_only(line) command = statement.command + cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None expanded_line = statement.command_and_args # We overwrote line with a properly formatted but fully stripped version @@ -1268,7 +1575,8 @@ def _completion_for_command(self, text: str, line: str, begidx: int, import functools compfunc = functools.partial(self._complete_argparse_command, argparser=argparser, - preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)) + preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES), + cmd_set=cmd_set) else: compfunc = self.completedefault @@ -1436,7 +1744,9 @@ def complete(self, text: str, state: int) -> Optional[str]: return None def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: int, *, - argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: + argparser: argparse.ArgumentParser, + preserve_quotes: bool, + cmd_set: Optional[CommandSet] = None) -> List[str]: """Completion function for argparse commands""" from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) @@ -1445,7 +1755,7 @@ def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: # To have tab completion parsing match command line parsing behavior, # use preserve_quotes to determine if we parse the quoted or unquoted tokens. tokens_to_parse = raw_tokens if preserve_quotes else tokens - return completer.complete_command(tokens_to_parse, text, line, begidx, endidx) + return completer.complete_command(tokens_to_parse, text, line, begidx, endidx, cmd_set=cmd_set) def in_script(self) -> bool: """Return whether a text script is running""" @@ -1954,7 +2264,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if proc.returncode is not None: subproc_stdin.close() new_stdout.close() - raise RedirectionError('Pipe process exited with code {} before command could run'.format(proc.returncode)) + raise RedirectionError( + 'Pipe process exited with code {} before command could run'.format(proc.returncode)) else: redir_saved_state.redirecting = True cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) @@ -1963,7 +2274,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: elif statement.output: import tempfile if (not statement.output_to) and (not self._can_clip): - raise RedirectionError("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies") + raise RedirectionError( + "Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies") # Redirecting to a file elif statement.output_to: @@ -2069,7 +2381,6 @@ def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = Tru # Check to see if this command should be stored in history if statement.command not in self.exclude_from_history and \ statement.command not in self.disabled_commands and add_to_history: - self.history.append(statement) stop = func(statement) @@ -3156,7 +3467,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: if 'gnureadline' in sys.modules: # Restore what the readline module pointed to if cmd2_env.readline_module is None: - del(sys.modules['readline']) + del sys.modules['readline'] else: sys.modules['readline'] = cmd2_env.readline_module @@ -3185,6 +3496,7 @@ def do_py(self, args: argparse.Namespace, *, pyscript: Optional[str] = None) -> other arguments. (Defaults to None) :return: True if running of commands should stop """ + def py_quit(): """Function callable from the interactive Python console to exit that environment""" raise EmbeddedConsoleExit diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py new file mode 100644 index 000000000..1858c80b0 --- /dev/null +++ b/cmd2/command_definition.py @@ -0,0 +1,104 @@ +# coding=utf-8 +""" +Supports the definition of commands in separate classes to be composed into cmd2.Cmd +""" +import functools +from typing import Callable, Iterable, Optional, Type + +from .constants import COMMAND_FUNC_PREFIX + +# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues +try: # pragma: no cover + from typing import TYPE_CHECKING + if TYPE_CHECKING: + import cmd2 + +except ImportError: # pragma: no cover + pass + + +def _partial_passthru(func: Callable, *args, **kwargs) -> functools.partial: + """ + Constructs a partial function that passes arguments through to the wrapped function. + Must construct a new type every time so that each wrapped function's __doc__ can be copied correctly. + + :param func: wrapped function + :param args: positional arguments + :param kwargs: keyword arguments + :return: partial function that exposes attributes of wrapped function + """ + def __getattr__(self, item): + return getattr(self.func, item) + + def __setattr__(self, key, value): + return setattr(self.func, key, value) + + def __dir__(self) -> Iterable[str]: + return dir(self.func) + + passthru_type = type('PassthruPartial' + func.__name__, + (functools.partial,), + { + '__getattr__': __getattr__, + '__setattr__': __setattr__, + '__dir__': __dir__, + }) + passthru_type.__doc__ = func.__doc__ + return passthru_type(func, *args, **kwargs) + + +def with_default_category(category: str): + """ + Decorator that applies a category to all ``do_*`` command methods in a class that do not already + have a category specified. + + :param category: category to put all uncategorized commands in + :return: decorator function + """ + + def decorate_class(cls: Type[CommandSet]): + from .constants import CMD_ATTR_HELP_CATEGORY + import inspect + from .decorators import with_category + methods = inspect.getmembers( + cls, + predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + category_decorator = with_category(category) + for method in methods: + if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY): + setattr(cls, method[0], category_decorator(method[1])) + return cls + return decorate_class + + +class CommandSet(object): + """ + Base class for defining sets of commands to load in cmd2. + + ``with_default_category`` can be used to apply a default category to all commands in the CommandSet. + + ``do_``, ``help_``, and ``complete_`` functions differ only in that they're now required to accept + a reference to ``cmd2.Cmd`` as the first argument after self. + """ + + def __init__(self): + self._cmd = None # type: Optional[cmd2.Cmd] + + def on_register(self, cmd): + """ + Called by cmd2.Cmd when a CommandSet is registered. Subclasses can override this + to perform an initialization requiring access to the Cmd object. + + :param cmd: The cmd2 main application + :type cmd: cmd2.Cmd + """ + self._cmd = cmd + + def on_unregister(self, cmd): + """ + Called by ``cmd2.Cmd`` when a CommandSet is unregistered and removed. + + :param cmd: + :type cmd: cmd2.Cmd + """ + self._cmd = None diff --git a/cmd2/constants.py b/cmd2/constants.py index 81d1a29ba..aa2ccb6ab 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -49,3 +49,14 @@ # Whether or not tokens are unquoted before sending to argparse CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' + +# optional attribute +SUBCMD_HANDLER = 'cmd2_handler' + +# subcommand attributes for the base command name and the subcommand name +SUBCMD_ATTR_COMMAND = 'parent_command' +SUBCMD_ATTR_NAME = 'subcommand_name' +SUBCMD_ATTR_PARSER_ARGS = 'subcommand_parser_args' + +# arpparse attribute linking to command set instance +PARSER_ATTR_COMMANDSET = 'command_set' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index d2fdf9c7e..5947020f5 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,12 +1,16 @@ # coding=utf-8 """Decorators for ``cmd2`` commands""" import argparse -from typing import Any, Callable, Dict, List, Optional, Union +import types +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union from . import constants from .exceptions import Cmd2ArgparseError from .parsing import Statement +if TYPE_CHECKING: # pragma: no cover + import cmd2 + def with_category(category: str) -> Callable: """A decorator to apply a category to a ``do_*`` command method. @@ -30,6 +34,45 @@ def cat_decorator(func): return func return cat_decorator +########################## +# The _parse_positionals and _swap_args decorators allow for additional positional args to be preserved +# in cmd2 command functions/callables. As long as the 2-ple of arguments we expect to be there can be +# found we can swap out the statement with each decorator's specific parameters +########################## + + +def _parse_positionals(args: Tuple) -> Tuple['cmd2.Cmd', Union[Statement, str]]: + """ + Helper function for cmd2 decorators to inspect the positional arguments until the cmd2.Cmd argument is found + Assumes that we will find cmd2.Cmd followed by the command statement object or string. + :arg args: The positional arguments to inspect + :return: The cmd2.Cmd reference and the command line statement + """ + for pos, arg in enumerate(args): + from cmd2 import Cmd + if isinstance(arg, Cmd) and len(args) > pos: + next_arg = args[pos + 1] + if isinstance(next_arg, (Statement, str)): + return arg, args[pos + 1] + + # This shouldn't happen unless we forget to pass statement in `Cmd.onecmd` or + # somehow call the unbound class method. + raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') # pragma: no cover + + +def _arg_swap(args: Union[Tuple[Any], List[Any]], search_arg: Any, *replace_arg: Any) -> List[Any]: + """ + Helper function for cmd2 decorators to swap the Statement parameter with one or more decorator-specific parameters + :param args: The original positional arguments + :param search_arg: The argument to search for (usually the Statement) + :param replace_arg: The arguments to substitute in + :return: The new set of arguments to pass to the command function + """ + index = args.index(search_arg) + args_list = list(args) + args_list[index:index + 1] = replace_arg + return args_list + def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> Callable[[List], Optional[bool]]: """ @@ -53,20 +96,22 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> def arg_decorator(func: Callable): @functools.wraps(func) - def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args, **kwargs: Dict[str, Any]) -> Optional[bool]: """ Command function wrapper which translates command line into an argument list and calls actual command function - :param cmd2_app: CLI instance passed as self parameter to command function - :param statement: command line string or already generated Statement + :param args: All positional arguments to this function. We're expecting there to be: + cmd2_app, statement: Union[Statement, str] + contiguously somewhere in the list :param kwargs: any keyword arguments being passed to command function :return: return value of command function """ + cmd2_app, statement = _parse_positionals(args) _, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) - - return func(cmd2_app, parsed_arglist, **kwargs) + args_list = _arg_swap(args, statement, parsed_arglist) + return func(*args_list, **kwargs) command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] cmd_wrapper.__doc__ = func.__doc__ @@ -159,17 +204,19 @@ def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *, def arg_decorator(func: Callable): @functools.wraps(func) - def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Optional[bool]: """ Command function wrapper which translates command line into argparse Namespace and calls actual command function - :param cmd2_app: CLI instance passed as self parameter to command function - :param statement: command line string or already generated Statement + :param args: All positional arguments to this function. We're expecting there to be: + cmd2_app, statement: Union[Statement, str] + contiguously somewhere in the list :param kwargs: any keyword arguments being passed to command function :return: return value of command function :raises: Cmd2ArgparseError if argparse has error parsing command line """ + cmd2_app, statement = _parse_positionals(args) statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) @@ -180,12 +227,19 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, namespace = ns_provider(cmd2_app) try: - args, unknown = parser.parse_known_args(parsed_arglist, namespace) + ns, unknown = parser.parse_known_args(parsed_arglist, namespace) except SystemExit: raise Cmd2ArgparseError else: - setattr(args, '__statement__', statement) - return func(cmd2_app, args, unknown, **kwargs) + setattr(ns, '__statement__', statement) + + def get_handler(self: argparse.Namespace) -> Optional[Callable]: + return getattr(self, constants.SUBCMD_HANDLER, None) + + setattr(ns, 'get_handler', types.MethodType(get_handler, ns)) + + args_list = _arg_swap(args, statement, ns, unknown) + return func(*args_list, **kwargs) # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] @@ -241,17 +295,19 @@ def with_argparser(parser: argparse.ArgumentParser, *, def arg_decorator(func: Callable): @functools.wraps(func) - def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]: """ Command function wrapper which translates command line into argparse Namespace and calls actual command function - :param cmd2_app: CLI instance passed as self parameter to command function - :param statement: command line string or already generated Statement + :param args: All positional arguments to this function. We're expecting there to be: + cmd2_app, statement: Union[Statement, str] + contiguously somewhere in the list :param kwargs: any keyword arguments being passed to command function :return: return value of command function :raises: Cmd2ArgparseError if argparse has error parsing command line """ + cmd2_app, statement = _parse_positionals(args) statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) @@ -262,12 +318,19 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, namespace = ns_provider(cmd2_app) try: - args = parser.parse_args(parsed_arglist, namespace) + ns = parser.parse_args(parsed_arglist, namespace) except SystemExit: raise Cmd2ArgparseError else: - setattr(args, '__statement__', statement) - return func(cmd2_app, args, **kwargs) + setattr(ns, '__statement__', statement) + + def get_handler(self: argparse.Namespace) -> Optional[Callable]: + return getattr(self, constants.SUBCMD_HANDLER, None) + + setattr(ns, 'get_handler', types.MethodType(get_handler, ns)) + + args_list = _arg_swap(args, statement, ns) + return func(*args_list, **kwargs) # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] @@ -288,3 +351,45 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, # noinspection PyTypeChecker return arg_decorator + + +def as_subcommand_to(command: str, + subcommand: str, + parser: argparse.ArgumentParser, + *, + help_text: Optional[str] = None, + aliases: Iterable[str] = None) -> Callable[[argparse.Namespace], Optional[bool]]: + """ + Tag this method as a subcommand to an existing argparse decorated command. + + :param command: Command Name. Space-delimited subcommands may optionally be specified + :param subcommand: Subcommand name + :param parser: argparse Parser for this subcommand + :param help_text: Help message for this subcommand + :param aliases: Alternative names for this subcommand + :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) + parser_args = {} + if help_text is not None: + parser_args['help'] = help_text + if aliases is not None: + parser_args['aliases'] = aliases[:] + setattr(func, constants.SUBCMD_ATTR_PARSER_ARGS, parser_args) + + return func + + # noinspection PyTypeChecker + return arg_decorator diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 8a7fd81f5..b928f2934 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -24,10 +24,14 @@ class Cmd2ArgparseError(SkipPostcommandHooks): pass +class CommandSetRegistrationError(Exception): + pass + ############################################################################################################ # The following exceptions are NOT part of the public API and are intended for internal use only. ############################################################################################################ + class Cmd2ShlexError(Exception): """Raised when shlex fails to parse a command line string in StatementParser""" pass diff --git a/docs/api/command_definition.rst b/docs/api/command_definition.rst new file mode 100644 index 000000000..cfb7082ed --- /dev/null +++ b/docs/api/command_definition.rst @@ -0,0 +1,5 @@ +cmd2.command_definition +======================= + +.. automodule:: cmd2.command_definition + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index cc899ba1c..17a259072 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -23,6 +23,7 @@ This documentation is for ``cmd2`` version |version|. argparse_completer argparse_custom constants + command_definition decorators exceptions history diff --git a/docs/features/commands.rst b/docs/features/commands.rst index 13a4ac1fc..8e61a4721 100644 --- a/docs/features/commands.rst +++ b/docs/features/commands.rst @@ -209,3 +209,15 @@ to: - remove commands included in ``cmd2`` - hide commands from the help menu - disable and re-enable commands at runtime + + +Modular Commands and Loading/Unloading Commands +----------------------------------------------- + +See :ref:`features/modular_commands:Modular Commands` for details of how +to: + +- Define commands in separate CommandSet modules +- Load or unload commands at runtime + + diff --git a/docs/features/index.rst b/docs/features/index.rst index efc0fe671..48590b6ad 100644 --- a/docs/features/index.rst +++ b/docs/features/index.rst @@ -17,6 +17,7 @@ Features hooks initialization misc + modular_commands multiline_commands os packaging diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst new file mode 100644 index 000000000..9823d3ac4 --- /dev/null +++ b/docs/features/modular_commands.rst @@ -0,0 +1,329 @@ +Modular Commands +================ + +Overview +-------- + +Cmd2 also enables developers to modularize their command definitions into Command Sets. Command sets represent +a logical grouping of commands within an cmd2 application. By default, all CommandSets will be discovered and loaded +automatically when the cmd2.Cmd class is instantiated with this mixin. This also enables the developer to +dynamically add/remove commands from the cmd2 application. This could be useful for loadable plugins that +add additional capabilities. + +Features +~~~~~~~~ + +* Modular Command Sets - Commands can be broken into separate modules rather than in one god class holding all + commands. +* Automatic Command Discovery - In your application, merely defining and importing a CommandSet is sufficient for + cmd2 to discover and load your command. No manual registration is necessary. +* 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. +* Subcommand Injection - Subcommands 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 + + +Defining Commands +----------------- + +Command Sets +~~~~~~~~~~~~~ + +CommandSets group multiple commands together. The plugin will inspect functions within a ``CommandSet`` +using the same rules as when they're defined in ``cmd2.Cmd``. Commands must be prefixed with ``do_``, help +functions with ``help_``, and completer functions with ``complete_``. + +A new decorator ``with_default_category`` is provided to categorize all commands within a CommandSet in the +same command category. Individual commands in a CommandSet may be override the default category by specifying a +specific category with ``cmd.with_category``. + +CommandSet methods will always expect ``self``, and ``cmd2.Cmd`` as the first two parameters. The parameters that +follow will depend on the specific command decorator being used. + +CommandSets will only be auto-loaded if the constructor takes no arguments. +If you need to provide constructor arguments, see :ref:`features/modular_commands:Manual CommandSet Construction` + +.. code-block:: python + + import cmd2 + from cmd2 import CommandSet, with_default_category + + @with_default_category('My Category') + class AutoLoadCommandSet(CommandSet): + def __init__(self): + super().__init__() + + def do_hello(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Hello') + + def do_world(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('World') + + class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def do_something(self, arg): + self.poutput('this is the something command') + + +Manual CommandSet Construction +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If a CommandSet class requires parameters to be provided to the constructor, you man manually construct +CommandSets and pass in the constructor to Cmd2. + +.. code-block:: python + + import cmd2 + from cmd2 import CommandSet, with_default_category + + @with_default_category('My Category') + class CustomInitCommandSet(CommandSet): + def __init__(self, arg1, arg2): + super().__init__() + + self._arg1 = arg1 + self._arg2 = arg2 + + def do_show_arg1(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arg1: ' + self._arg1) + + def do_show_arg2(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arg2: ' + self._arg2) + + class ExampleApp(cmd2.Cmd): + """ + CommandSets with constructor parameters are provided in the constructor + """ + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, **kwargs) + + def do_something(self, arg): + self.last_result = 5 + self.poutput('this is the something command') + + + def main(): + my_commands = CustomInitCommandSet(1, 2) + app = ExampleApp(command_sets=[my_commands]) + app.cmdloop() + + +Dynamic Commands +~~~~~~~~~~~~~~~~ + +You man also dynamically load and unload commands by installing and removing CommandSets at runtime. For example, +if you could support runtime loadable plugins or add/remove commands based on your state. + +You may need to disable command auto-loading if you need dynamically load commands at runtime. + +.. 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') + + def do_banana(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Banana') + + + @with_default_category('Vegetables') + class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + def do_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + + class ExampleApp(cmd2.Cmd): + """ + CommandSets are loaded via the `load` and `unload` commands + """ + + 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') + + + if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() + + +Injecting Subcommands +---------------------- + +Description +~~~~~~~~~~~ +Using the `with_argparse` decorator, it is possible to define subcommands 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 subcommands `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. + +Subcommand injection allows you to inject subcommands 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): + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(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_commands/__init__.py b/examples/modular_commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py new file mode 100644 index 000000000..105530e89 --- /dev/null +++ b/examples/modular_commands/commandset_basic.py @@ -0,0 +1,92 @@ +# coding=utf-8 +""" +A simple example demonstrating a loadable command set +""" +from typing import List + +from cmd2 import Cmd, CommandSet, Statement, with_category, with_default_category +from cmd2.utils import CompletionError + + +@with_default_category('Basic Completion') +class BasicCompletionCommandSet(CommandSet): + # List of strings used with completion functions + food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] + sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] + + # This data is used to demonstrate delimiter_complete + file_strs = \ + [ + '/home/user/file.db', + '/home/user/file space.db', + '/home/user/another.db', + '/home/other user/maps.db', + '/home/other user/tests.db' + ] + + def do_flag_based(self, cmd: Cmd, statement: Statement): + """Tab completes arguments based on a preceding flag using flag_based_complete + -f, --food [completes food items] + -s, --sport [completes sports] + -p, --path [completes local file system paths] + """ + cmd.poutput("Args: {}".format(statement.args)) + + def complete_flag_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completion function for do_flag_based""" + flag_dict = \ + { + # Tab complete food items after -f and --food flags in command line + '-f': self.food_item_strs, + '--food': self.food_item_strs, + + # Tab complete sport items after -s and --sport flags in command line + '-s': self.sport_item_strs, + '--sport': self.sport_item_strs, + + # Tab complete using path_complete function after -p and --path flags in command line + '-p': cmd.path_complete, + '--path': cmd.path_complete, + } + + return cmd.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict) + + def do_index_based(self, cmd: Cmd, statement: Statement): + """Tab completes first 3 arguments using index_based_complete""" + cmd.poutput("Args: {}".format(statement.args)) + + def complete_index_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completion function for do_index_based""" + index_dict = \ + { + 1: self.food_item_strs, # Tab complete food items at index 1 in command line + 2: self.sport_item_strs, # Tab complete sport items at index 2 in command line + 3: cmd.path_complete, # Tab complete using path_complete function at index 3 in command line + } + + return cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict) + + def do_delimiter_complete(self, cmd: Cmd, statement: Statement): + """Tab completes files from a list using delimiter_complete""" + cmd.poutput("Args: {}".format(statement.args)) + + def complete_delimiter_complete(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/') + + def do_raise_error(self, cmd: Cmd, statement: Statement): + """Demonstrates effect of raising CompletionError""" + cmd.poutput("Args: {}".format(statement.args)) + + def complete_raise_error(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + CompletionErrors can be raised if an error occurs while tab completing. + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + """ + raise CompletionError("This is how a CompletionError behaves") + + @with_category('Not Basic Completion') + def do_custom_category(self, cmd: Cmd, statement: Statement): + cmd.poutput('Demonstrates a command that bypasses the default category') diff --git a/examples/modular_commands/commandset_complex.py b/examples/modular_commands/commandset_complex.py new file mode 100644 index 000000000..5a031bd06 --- /dev/null +++ b/examples/modular_commands/commandset_complex.py @@ -0,0 +1,53 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test CommandSet +""" + +import argparse +from typing import List + +import cmd2 +from cmd2 import utils + + +@cmd2.with_default_category('Fruits') +class CommandSetA(cmd2.CommandSet): + def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Apple!') + + def do_banana(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Banana Command""" + cmd.poutput('Banana!!') + + cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry') + cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce']) + + @cmd2.with_argparser_and_unknown_args(cranberry_parser) + def do_cranberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace, unknown: List[str]): + cmd.poutput('Cranberry {}!!'.format(ns.arg1)) + if unknown and len(unknown): + cmd.poutput('Unknown: ' + ', '.join(['{}']*len(unknown)).format(*unknown)) + cmd.last_result = {'arg1': ns.arg1, + 'unknown': unknown} + + def help_cranberry(self, cmd: cmd2.Cmd): + cmd.stdout.write('This command does diddly squat...\n') + + @cmd2.with_argument_list + @cmd2.with_category('Also Alone') + def do_durian(self, cmd: cmd2.Cmd, args: List[str]): + """Durian Command""" + cmd.poutput('{} Arguments: '.format(len(args))) + cmd.poutput(', '.join(['{}']*len(args)).format(*args)) + + def complete_durian(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return utils.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting']) + + elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry') + elderberry_parser.add_argument('arg1') + + @cmd2.with_category('Alone') + @cmd2.with_argparser(elderberry_parser) + def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('Elderberry {}!!'.format(ns.arg1)) diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py new file mode 100644 index 000000000..5a574a594 --- /dev/null +++ b/examples/modular_commands/commandset_custominit.py @@ -0,0 +1,20 @@ +# coding=utf-8 +""" +A simple example demonstrating a loadable command set +""" +from cmd2 import Cmd, CommandSet, Statement, with_default_category + + +@with_default_category('Custom Init') +class CustomInitCommandSet(CommandSet): + def __init__(self, arg1, arg2): + super().__init__() + + self._arg1 = arg1 + self._arg2 = arg2 + + def do_show_arg1(self, cmd: Cmd, _: Statement): + cmd.poutput('Arg1: ' + self._arg1) + + def do_show_arg2(self, cmd: Cmd, _: Statement): + cmd.poutput('Arg2: ' + self._arg2) diff --git a/examples/modular_commands_basic.py b/examples/modular_commands_basic.py new file mode 100644 index 000000000..9f4a0bd2b --- /dev/null +++ b/examples/modular_commands_basic.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Simple example demonstrating basic CommandSet usage. +""" + +import cmd2 +from cmd2 import CommandSet, with_default_category + + +@with_default_category('My Category') +class AutoLoadCommandSet(CommandSet): + def __init__(self): + super().__init__() + + def do_hello(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Hello') + + def do_world(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('World') + + +class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + + def __init__(self): + super(ExampleApp, self).__init__() + + def do_something(self, arg): + self.poutput('this is the something command') + + +if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_commands_dynamic.py b/examples/modular_commands_dynamic.py new file mode 100644 index 000000000..81dbad824 --- /dev/null +++ b/examples/modular_commands_dynamic.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Simple example demonstrating dynamic CommandSet loading and unloading. + +There are 2 CommandSets defined. ExampleApp sets the `auto_load_commands` flag to false. + +The `load` and `unload` commands will load and unload the CommandSets. The available commands 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') + + def do_banana(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Banana') + + +@with_default_category('Vegetables') +class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + def do_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +class ExampleApp(cmd2.Cmd): + """ + CommandSets are loaded via the `load` and `unload` commands + """ + + 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') + + +if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py new file mode 100644 index 000000000..b698e00fb --- /dev/null +++ b/examples/modular_commands_main.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A complex example demonstrating a variety of methods to load CommandSets using a mix of command decorators +with examples of how to integrate tab completion with argparse-based commands. +""" +import argparse +from typing import Dict, Iterable, List, Optional + +from cmd2 import Cmd, Cmd2ArgumentParser, CommandSet, CompletionItem, with_argparser +from cmd2.utils import CompletionError, basic_complete +from modular_commands.commandset_basic import BasicCompletionCommandSet # noqa: F401 +from modular_commands.commandset_complex import CommandSetA # noqa: F401 +from modular_commands.commandset_custominit import CustomInitCommandSet # noqa: F401 + +# Data source for argparse.choices +food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] + + +def choices_function() -> List[str]: + """Choices functions are useful when the choice list is dynamically generated (e.g. from data in a database)""" + return ['a', 'dynamic', 'list', 'goes', 'here'] + + +def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + A tab completion function not dependent on instance data. Since custom tab completion operations commonly + need to modify cmd2's instance variables related to tab completion, it will be rare to need a completer + function. completer_method should be used in those cases. + """ + match_against = ['a', 'dynamic', 'list', 'goes', 'here'] + return basic_complete(text, line, begidx, endidx, match_against) + + +def choices_completion_item() -> List[CompletionItem]: + """Return CompletionItem instead of strings. These give more context to what's being tab completed.""" + items = \ + { + 1: "My item", + 2: "Another item", + 3: "Yet another item" + } + return [CompletionItem(item_id, description) for item_id, description in items.items()] + + +def choices_arg_tokens(arg_tokens: Dict[str, List[str]]) -> List[str]: + """ + If a choices or completer function/method takes a value called arg_tokens, then it will be + passed a dictionary that maps the command line tokens up through the one being completed + to their argparse argument name. All values of the arg_tokens dictionary are lists, even if + a particular argument expects only 1 token. + """ + # Check if choices_function flag has appeared + values = ['choices_function', 'flag'] + if 'choices_function' in arg_tokens: + values.append('is {}'.format(arg_tokens['choices_function'][0])) + else: + values.append('not supplied') + return values + + +class WithCommandSets(Cmd): + def __init__(self, command_sets: Optional[Iterable[CommandSet]] = None): + super().__init__(command_sets=command_sets) + self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] + + def choices_method(self) -> List[str]: + """Choices methods are useful when the choice list is based on instance data of your application""" + return self.sport_item_strs + + def choices_completion_error(self) -> List[str]: + """ + CompletionErrors can be raised if an error occurs while tab completing. + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + """ + if self.debug: + return self.sport_item_strs + raise CompletionError("debug must be true") + + # Parser for example command + example_parser = Cmd2ArgumentParser(description="Command demonstrating tab completion with argparse\n" + "Notice even the flags of this command tab complete") + + # Tab complete from a list using argparse choices. Set metavar if you don't + # want the entire choices list showing in the usage text for this command. + example_parser.add_argument('--choices', choices=food_item_strs, metavar="CHOICE", + help="tab complete using choices") + + # Tab complete from choices provided by a choices function and choices method + example_parser.add_argument('--choices_function', choices_function=choices_function, + help="tab complete using a choices_function") + example_parser.add_argument('--choices_method', choices_method=choices_method, + help="tab complete using a choices_method") + + # Tab complete using a completer function and completer method + example_parser.add_argument('--completer_function', completer_function=completer_function, + help="tab complete using a completer_function") + example_parser.add_argument('--completer_method', completer_method=Cmd.path_complete, + help="tab complete using a completer_method") + + # Demonstrate raising a CompletionError while tab completing + example_parser.add_argument('--completion_error', choices_method=choices_completion_error, + help="raise a CompletionError while tab completing if debug is False") + + # Demonstrate returning CompletionItems instead of strings + example_parser.add_argument('--completion_item', choices_function=choices_completion_item, metavar="ITEM_ID", + descriptive_header="Description", + help="demonstrate use of CompletionItems") + + # Demonstrate use of arg_tokens dictionary + example_parser.add_argument('--arg_tokens', choices_function=choices_arg_tokens, + help="demonstrate use of arg_tokens dictionary") + + @with_argparser(example_parser) + def do_example(self, _: argparse.Namespace) -> None: + """The example command""" + self.poutput("I do nothing") + + +if __name__ == '__main__': + import sys + + print("Starting") + my_sets = [CustomInitCommandSet('First argument', 'Second argument')] + app = WithCommandSets(command_sets=my_sets) + sys.exit(app.cmdloop()) diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py new file mode 100644 index 000000000..e1bc6b7b9 --- /dev/null +++ b/examples/modular_subcommands.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# coding=utf-8 +"""A simple example demonstracting modular subcommand loading through CommandSets + +In this example, there are loadable CommandSets defined. Each CommandSet has 1 subcommand 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 +subcommands 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): + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(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/plumbum_colors.py b/examples/plumbum_colors.py index ed65f245c..a30e4c706 100755 --- a/examples/plumbum_colors.py +++ b/examples/plumbum_colors.py @@ -27,10 +27,9 @@ """ import argparse -from plumbum.colors import bg, fg - import cmd2 from cmd2 import ansi +from plumbum.colors import bg, fg class FgColors(ansi.ColorBase): diff --git a/isolated_tests/__init__.py b/isolated_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/isolated_tests/test_commandset/__init__.py b/isolated_tests/test_commandset/__init__.py new file mode 100644 index 000000000..037f3866e --- /dev/null +++ b/isolated_tests/test_commandset/__init__.py @@ -0,0 +1,3 @@ +# +# -*- coding: utf-8 -*- +# diff --git a/isolated_tests/test_commandset/conftest.py b/isolated_tests/test_commandset/conftest.py new file mode 100644 index 000000000..5b1a6f05b --- /dev/null +++ b/isolated_tests/test_commandset/conftest.py @@ -0,0 +1,196 @@ +# coding=utf-8 +""" +Cmd2 unit/functional testing +""" +import sys +from contextlib import redirect_stderr, redirect_stdout +from typing import List, Optional, Union +from unittest import mock + +from pytest import fixture + +import cmd2 +from cmd2.utils import StdSim + +# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) +try: + import gnureadline as readline +except ImportError: + # Try to import readline, but allow failure for convenience in Windows unit testing + # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows + try: + # noinspection PyUnresolvedReferences + import readline + except ImportError: + pass + + +def verify_help_text(cmd2_app: cmd2.Cmd, + help_output: Union[str, List[str]], + verbose_strings: Optional[List[str]] = None) -> None: + """This function verifies that all expected commands are present in the help text. + + :param cmd2_app: instance of cmd2.Cmd + :param help_output: output of help, either as a string or list of strings + :param verbose_strings: optional list of verbose strings to search for + """ + if isinstance(help_output, str): + help_text = help_output + else: + help_text = ''.join(help_output) + commands = cmd2_app.get_visible_commands() + for command in commands: + assert command in help_text + + if verbose_strings: + for verbose_string in verbose_strings: + assert verbose_string in help_text + + +# Help text for the history command +HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x] + [-v] [-a] + [arg] + +View, run, edit, save, or clear previously entered commands + +positional arguments: + arg empty all history items + a one history item by number + a..b, a:b, a:, ..b items by indices (inclusive) + string items containing string + /regex/ items matching regular expression + +optional arguments: + -h, --help show this help message and exit + -r, --run run selected history items + -e, --edit edit and then run selected history items + -o, --output_file FILE + output commands to a script file, implies -s + -t, --transcript TRANSCRIPT_FILE + output commands and results to a transcript file, + implies -s + -c, --clear clear all history + +formatting: + -s, --script output commands in script format, i.e. without command + numbers + -x, --expanded output fully parsed commands with any aliases and + macros expanded, instead of typed commands + -v, --verbose display history and include expanded commands if they + differ from the typed command + -a, --all display all commands, including ones persisted from + previous sessions +""" + +# Output from the shortcuts command with default built-in shortcuts +SHORTCUTS_TXT = """Shortcuts for other commands: +!: shell +?: help +@: run_script +@@: _relative_run_script +""" + +# Output from the show command with default settings +SHOW_TXT = """allow_style: 'Terminal' +debug: False +echo: False +editor: 'vim' +feedback_to_output: False +max_completion_items: 50 +quiet: False +timing: False +""" + +SHOW_LONG = """ +allow_style: 'Terminal' # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never) +debug: False # Show full traceback on exception +echo: False # Echo command issued into output +editor: 'vim' # Program used by 'edit' +feedback_to_output: False # Include nonessentials in '|', '>' results +max_completion_items: 50 # Maximum number of CompletionItems to display during tab completion +quiet: False # Don't print nonessential feedback +timing: False # Report execution times +""" + + +def normalize(block): + """ Normalize a block of text to perform comparison. + + Strip newlines from the very beginning and very end Then split into separate lines and strip trailing whitespace + from each line. + """ + assert isinstance(block, str) + block = block.strip('\n') + return [line.rstrip() for line in block.splitlines()] + + +def run_cmd(app, cmd): + """ Clear out and err StdSim buffers, run the command, and return out and err """ + saved_sysout = sys.stdout + sys.stdout = app.stdout + + # This will be used to capture app.stdout and sys.stdout + copy_cmd_stdout = StdSim(app.stdout) + + # This will be used to capture sys.stderr + copy_stderr = StdSim(sys.stderr) + + try: + app.stdout = copy_cmd_stdout + with redirect_stdout(copy_cmd_stdout): + with redirect_stderr(copy_stderr): + app.onecmd_plus_hooks(cmd) + finally: + app.stdout = copy_cmd_stdout.inner_stream + sys.stdout = saved_sysout + + out = copy_cmd_stdout.getvalue() + err = copy_stderr.getvalue() + return normalize(out), normalize(err) + + +@fixture +def base_app(): + return cmd2.Cmd() + + +# These are odd file names for testing quoting of them +odd_file_names = [ + 'nothingweird', + 'has spaces', + '"is_double_quoted"', + "'is_single_quoted'" +] + + +def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: + """ + This is a convenience function to test cmd2.complete() since + in a unit test environment there is no actual console readline + is monitoring. Therefore we use mock to provide readline data + to complete(). + + :param text: the string prefix we are attempting to match + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :param app: the cmd2 app that will run completions + :return: The first matched string or None if there are no matches + Matches are stored in app.completion_matches + These matches also have been sorted by complete() + """ + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + # Run the readline tab completion function with readline mocks in place + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + return app.complete(text, 0) diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py new file mode 100644 index 000000000..90f0448cd --- /dev/null +++ b/isolated_tests/test_commandset/test_commandset.py @@ -0,0 +1,735 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test CommandSet +""" + +import argparse +from typing import List + +import pytest + +import cmd2 +from cmd2 import utils +from cmd2_ext_test import ExternalTestMixin +from .conftest import complete_tester, run_cmd +from cmd2.exceptions import CommandSetRegistrationError + + +@cmd2.with_default_category('Fruits') +class CommandSetA(cmd2.CommandSet): + def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Apple!') + + def do_banana(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Banana Command""" + cmd.poutput('Banana!!') + + cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry') + cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce']) + + @cmd2.with_argparser_and_unknown_args(cranberry_parser) + def do_cranberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace, unknown: List[str]): + cmd.poutput('Cranberry {}!!'.format(ns.arg1)) + if unknown and len(unknown): + cmd.poutput('Unknown: ' + ', '.join(['{}']*len(unknown)).format(*unknown)) + cmd.last_result = {'arg1': ns.arg1, + 'unknown': unknown} + + def help_cranberry(self, cmd: cmd2.Cmd): + cmd.stdout.write('This command does diddly squat...\n') + + @cmd2.with_argument_list + @cmd2.with_category('Also Alone') + def do_durian(self, cmd: cmd2.Cmd, args: List[str]): + """Durian Command""" + cmd.poutput('{} Arguments: '.format(len(args))) + cmd.poutput(', '.join(['{}']*len(args)).format(*args)) + cmd.last_result = {'args': args} + + def complete_durian(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return utils.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting']) + + elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry') + elderberry_parser.add_argument('arg1') + + @cmd2.with_category('Alone') + @cmd2.with_argparser(elderberry_parser) + def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('Elderberry {}!!'.format(ns.arg1)) + cmd.last_result = {'arg1': ns.arg1} + + +@cmd2.with_default_category('Command Set B') +class CommandSetB(cmd2.CommandSet): + def __init__(self, arg1): + super().__init__() + self._arg1 = arg1 + + def do_aardvark(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Aardvark!') + + def do_bat(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Banana Command""" + cmd.poutput('Bat!!') + + def do_crocodile(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Crocodile!!') + + +class WithCommandSets(ExternalTestMixin, cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super(WithCommandSets, self).__init__(*args, **kwargs) + + +@pytest.fixture +def command_sets_app(): + app = WithCommandSets() + return app + + +@pytest.fixture() +def command_sets_manual(): + app = WithCommandSets(auto_load_commands=False) + return app + + +def test_autoload_commands(command_sets_app): + # verifies that, when autoload is enabled, CommandSets and registered functions all show up + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_app._build_command_info() + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Also Alone' in cmds_cats + assert 'durian' in cmds_cats['Also Alone'] + + assert 'Fruits' in cmds_cats + assert 'cranberry' in cmds_cats['Fruits'] + + assert 'Command Set B' not in cmds_cats + + +def test_custom_construct_commandsets(): + # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor + command_set = CommandSetB('foo') + app = WithCommandSets(command_sets=[command_set]) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = app._build_command_info() + assert 'Command Set B' in cmds_cats + + # Verifies that the same CommandSet can not be loaded twice + command_set_2 = CommandSetB('bar') + with pytest.raises(CommandSetRegistrationError): + assert app.install_command_set(command_set_2) + + # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded. + app2 = WithCommandSets(command_sets=[CommandSetA()]) + assert hasattr(app2, 'do_apple') + + +def test_load_commands(command_sets_manual): + + # now install a command set and verify the commands are now present + cmd_set = CommandSetA() + command_sets_manual.install_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Fruits' in cmds_cats + assert 'cranberry' in cmds_cats['Fruits'] + + # uninstall the command set and verify it is now also no longer accessible + command_sets_manual.uninstall_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'Alone' not in cmds_cats + assert 'Fruits' not in cmds_cats + + # uninstall a second time and verify no errors happen + command_sets_manual.uninstall_command_set(cmd_set) + + # reinstall the command set and verify it is accessible + command_sets_manual.install_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Fruits' in cmds_cats + assert 'cranberry' in cmds_cats['Fruits'] + + +def test_partial_with_passthru(): + + def test_func(arg1, arg2): + """Documentation Comment""" + print('Do stuff {} - {}'.format(arg1, arg2)) + + my_partial = cmd2.command_definition._partial_passthru(test_func, 1) + + setattr(test_func, 'Foo', 5) + + assert hasattr(my_partial, 'Foo') + + assert getattr(my_partial, 'Foo', None) == 5 + + a = dir(test_func) + b = dir(my_partial) + assert a == b + + assert not hasattr(test_func, 'Bar') + setattr(my_partial, 'Bar', 6) + assert hasattr(test_func, 'Bar') + + assert getattr(test_func, 'Bar', None) == 6 + + +def test_commandset_decorators(command_sets_app): + result = command_sets_app.app_cmd('cranberry juice extra1 extra2') + assert len(result.data['unknown']) == 2 + assert 'extra1' in result.data['unknown'] + assert 'extra2' in result.data['unknown'] + assert result.data['arg1'] == 'juice' + assert result.stderr is None + + result = command_sets_app.app_cmd('durian juice extra1 extra2') + assert len(result.data['args']) == 3 + assert 'juice' in result.data['args'] + assert 'extra1' in result.data['args'] + assert 'extra2' in result.data['args'] + assert result.stderr is None + + result = command_sets_app.app_cmd('durian') + assert len(result.data['args']) == 0 + assert result.stderr is None + + result = command_sets_app.app_cmd('elderberry') + assert result.stderr is not None + assert len(result.stderr) > 0 + assert 'arguments are required' in result.stderr + assert result.data is None + + result = command_sets_app.app_cmd('elderberry a b') + assert result.stderr is not None + assert len(result.stderr) > 0 + assert 'unrecognized arguments' in result.stderr + assert result.data is None + + +def test_load_commandset_errors(command_sets_manual, capsys): + cmd_set = CommandSetA() + + # create a conflicting command before installing CommandSet to verify rollback behavior + command_sets_manual._install_command_function('durian', cmd_set.do_durian) + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(cmd_set) + + # verify that the commands weren't installed + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'Alone' not in cmds_cats + assert 'Fruits' not in cmds_cats + assert not command_sets_manual._installed_command_sets + + delattr(command_sets_manual, 'do_durian') + + # pre-create intentionally conflicting macro and alias names + command_sets_manual.app_cmd('macro create apple run_pyscript') + command_sets_manual.app_cmd('alias create banana run_pyscript') + + # now install a command set and verify the commands are now present + command_sets_manual.install_command_set(cmd_set) + out, err = capsys.readouterr() + + # verify aliases and macros are deleted with warning if they conflict with a command + assert "Deleting alias 'banana'" in err + assert "Deleting macro 'apple'" in err + + # verify duplicate commands are detected + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._install_command_function('banana', cmd_set.do_banana) + + # verify bad command names are detected + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._install_command_function('bad command', cmd_set.do_banana) + + # verify error conflict with existing completer function + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._install_completer_function('durian', cmd_set.complete_durian) + + # verify error conflict with existing help function + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry) + + +class LoadableBase(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableBase, self).__init__() + self._dummy = dummy # prevents autoload + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + + @cmd2.with_argparser(cut_parser) + def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut something""" + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(ns) + else: + # No subcommand was provided, so call help + cmd.pwarning('This command does nothing without sub-parsers registered') + cmd.do_help('cut') + + + stir_parser = cmd2.Cmd2ArgumentParser('stir') + stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir') + + @cmd2.with_argparser(stir_parser) + def do_stir(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Stir something""" + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(ns) + else: + # No subcommand was provided, so call help + cmd.pwarning('This command does nothing without sub-parsers registered') + cmd.do_help('stir') + + stir_pasta_parser = cmd2.Cmd2ArgumentParser('pasta', add_help=False) + stir_pasta_parser.add_argument('--option', '-o') + stir_pasta_parser.add_subparsers(title='style', help='Stir style') + + @cmd2.as_subcommand_to('stir', 'pasta', stir_pasta_parser) + def stir_pasta(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(ns) + else: + cmd.poutput('Stir pasta haphazardly') + + +class LoadableBadBase(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableBadBase, self).__init__() + self._dummy = dummy # prevents autoload + + def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut something""" + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(ns) + else: + # No subcommand was provided, so call help + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('cut') + + +@cmd2.with_default_category('Fruits') +class LoadableFruits(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableFruits, self).__init__() + self._dummy = dummy # prevents autoload + + 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, help_text='Cut banana', aliases=['bananer']) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + +class LoadablePastaStir(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadablePastaStir, self).__init__() + self._dummy = dummy # prevents autoload + + stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False) + stir_pasta_vigor_parser.add_argument('frequency') + + @cmd2.as_subcommand_to('stir pasta', 'vigorously', stir_pasta_vigor_parser) + def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('stir the pasta vigorously') + + +@cmd2.with_default_category('Vegetables') +class LoadableVegetables(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableVegetables, self).__init__() + self._dummy = dummy # prevents autoload + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + def complete_style_arg(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return ['quartered', 'diced'] + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', completer_method=complete_style_arg) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +def test_subcommands(command_sets_manual): + + base_cmds = LoadableBase(1) + badbase_cmds = LoadableBadBase(1) + fruit_cmds = LoadableFruits(1) + veg_cmds = LoadableVegetables(1) + + # installing subcommands without base command present raises exception + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(fruit_cmds) + + # if the base command is present but isn't an argparse command, expect exception + command_sets_manual.install_command_set(badbase_cmds) + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(fruit_cmds) + + # verify that the commands weren't installed + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'cut' in cmds_doc + assert 'Fruits' not in cmds_cats + + # Now install the good base commands + command_sets_manual.uninstall_command_set(badbase_cmds) + command_sets_manual.install_command_set(base_cmds) + + # verify that we catch an attempt to register subcommands when the commandset isn't installed + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._register_subcommands(fruit_cmds) + + cmd_result = command_sets_manual.app_cmd('cut') + assert 'This command does nothing without sub-parsers registered' in cmd_result.stderr + + # verify that command set install without problems + command_sets_manual.install_command_set(fruit_cmds) + command_sets_manual.install_command_set(veg_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' in cmds_cats + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + + cmd_result = command_sets_manual.app_cmd('cut banana discs') + assert 'cutting banana: discs' in cmd_result.stdout + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == command_sets_manual.completion_matches + + # verify that command set uninstalls without problems + command_sets_manual.uninstall_command_set(fruit_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' not in cmds_cats + + # verify a double-unregister raises exception + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._unregister_subcommands(fruit_cmds) + command_sets_manual.uninstall_command_set(veg_cmds) + + # Disable command and verify subcommands still load and unload + command_sets_manual.disable_command('cut', 'disabled for test') + + # verify that command set install without problems + command_sets_manual.install_command_set(fruit_cmds) + command_sets_manual.install_command_set(veg_cmds) + + command_sets_manual.enable_command('cut') + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' in cmds_cats + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == command_sets_manual.completion_matches + + # disable again and verify can still uninstnall + command_sets_manual.disable_command('cut', 'disabled for test') + + # verify that command set uninstalls without problems + command_sets_manual.uninstall_command_set(fruit_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' not in cmds_cats + + # verify a double-unregister raises exception + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._unregister_subcommands(fruit_cmds) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.uninstall_command_set(base_cmds) + + command_sets_manual.uninstall_command_set(veg_cmds) + command_sets_manual.uninstall_command_set(base_cmds) + +def test_nested_subcommands(command_sets_manual): + base_cmds = LoadableBase(1) + # fruit_cmds = LoadableFruits(1) + # veg_cmds = LoadableVegetables(1) + pasta_cmds = LoadablePastaStir(1) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(pasta_cmds) + + command_sets_manual.install_command_set(base_cmds) + + command_sets_manual.install_command_set(pasta_cmds) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.uninstall_command_set(base_cmds) + + class BadNestedSubcommands(cmd2.CommandSet): + def __init__(self, dummy): + super(BadNestedSubcommands, self).__init__() + self._dummy = dummy # prevents autoload + + stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False) + stir_pasta_vigor_parser.add_argument('frequency') + + @cmd2.as_subcommand_to('stir sauce', 'vigorously', stir_pasta_vigor_parser) + def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('stir the pasta vigorously') + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(BadNestedSubcommands(1)) + + +class AppWithSubCommands(cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super(AppWithSubCommands, self).__init__(*args, **kwargs) + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + + @cmd2.with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + """Cut something""" + handler = ns.get_handler() + if handler is not None: + # Call whatever subcommand function was selected + handler(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer']) + def cut_banana(self, ns: argparse.Namespace): + """Cut banana""" + self.poutput('cutting banana: ' + ns.direction) + + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return ['quartered', 'diced'] + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', completer_method=complete_style_arg) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, _: cmd2.Statement): + self.poutput('Bok Choy') + + +@pytest.fixture +def static_subcommands_app(): + app = AppWithSubCommands() + return app + + +def test_static_subcommands(static_subcommands_app): + cmds_cats, cmds_doc, cmds_undoc, help_topics = static_subcommands_app._build_command_info() + assert 'Fruits' in cmds_cats + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == static_subcommands_app.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == static_subcommands_app.completion_matches + + +# reproduces test_argparse.py except with SubCommands +class SubcommandSet(cmd2.CommandSet): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self, dummy): + super(SubcommandSet, self).__init__() + + # subcommand functions for the base command + def base_foo(self, cmd: cmd2.Cmd, args): + """foo subcommand of base command""" + cmd.poutput(args.x * args.y) + + def base_bar(self, cmd: cmd2.Cmd, args): + """bar subcommand of base command""" + cmd.poutput('((%s))' % args.z) + + def base_helpless(self, cmd: cmd2.Cmd, args): + """helpless subcommand of base command""" + cmd.poutput('((%s))' % args.z) + + # create the top-level parser for the base command + base_parser = argparse.ArgumentParser() + base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') + base_subparsers.required = True + + # create the parser for the "foo" subcommand + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.set_defaults(func=base_foo) + + # create the parser for the "bar" subcommand + parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2']) + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + # create the parser for the "helpless" subcommand + # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # use an approach which relies on action._choices_actions list. See comment in that function for more + # details. + parser_bar = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + @cmd2.with_argparser(base_parser) + def do_base(self, cmd: cmd2.Cmd, args): + """Base command help""" + # Call whatever subcommand function was selected + func = getattr(args, 'func') + func(self, cmd, args) + + +@pytest.fixture +def subcommand_app(): + app = WithCommandSets(auto_load_commands=False, + command_sets=[SubcommandSet(1)]) + return app + + +def test_subcommand_foo(subcommand_app): + out, err = run_cmd(subcommand_app, 'base foo -x2 5.0') + assert out == ['10.0'] + + +def test_subcommand_bar(subcommand_app): + out, err = run_cmd(subcommand_app, 'base bar baz') + assert out == ['((baz))'] + +def test_subcommand_invalid(subcommand_app): + out, err = run_cmd(subcommand_app, 'base baz') + assert err[0].startswith('usage: base') + assert err[1].startswith("base: error: argument SUBCOMMAND: invalid choice: 'baz'") + +def test_subcommand_base_help(subcommand_app): + out, err = run_cmd(subcommand_app, 'help base') + assert out[0].startswith('usage: base') + assert out[1] == '' + assert out[2] == 'Base command help' + +def test_subcommand_help(subcommand_app): + # foo has no aliases + out, err = run_cmd(subcommand_app, 'help base foo') + assert out[0].startswith('usage: base foo') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + # bar has aliases (usage should never show alias name) + out, err = run_cmd(subcommand_app, 'help base bar') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base bar_1') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base bar_2') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + # helpless has aliases and no help text (usage should never show alias name) + out, err = run_cmd(subcommand_app, 'help base helpless') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base helpless_1') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base helpless_2') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + +def test_subcommand_invalid_help(subcommand_app): + out, err = run_cmd(subcommand_app, 'help base baz') + assert out[0].startswith('usage: base') + diff --git a/noxfile.py b/noxfile.py index df4e97aa3..ec8a16e27 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,7 +22,9 @@ def docs(session): def tests(session, plugin): if plugin is None: session.install('invoke', './[test]') - session.run('invoke', 'pytest', '--junit', '--no-pty') + session.run('invoke', 'pytest', '--junit', '--no-pty', '--base') + session.install('./plugins/ext_test/') + session.run('invoke', 'pytest', '--junit', '--no-pty', '--isolated') elif plugin == 'coverage': session.install('invoke', 'codecov', 'coverage') session.run('codecov') diff --git a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py index df54e1129..b1827f025 100644 --- a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py +++ b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py @@ -2,7 +2,7 @@ # coding=utf-8 """External test interface plugin""" -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import cmd2 diff --git a/plugins/template/cmd2_myplugin/__init__.py b/plugins/template/cmd2_myplugin/__init__.py index e66b62cdb..838d828a3 100644 --- a/plugins/template/cmd2_myplugin/__init__.py +++ b/plugins/template/cmd2_myplugin/__init__.py @@ -5,7 +5,7 @@ An overview of what myplugin does. """ -from .myplugin import empty_decorator, MyPluginMixin # noqa: F401 +from .myplugin import MyPluginMixin, empty_decorator # noqa: F401 try: # For python 3.8 and later diff --git a/plugins/template/cmd2_myplugin/myplugin.py b/plugins/template/cmd2_myplugin/myplugin.py index 4f1ff0e99..816198b0b 100644 --- a/plugins/template/cmd2_myplugin/myplugin.py +++ b/plugins/template/cmd2_myplugin/myplugin.py @@ -3,7 +3,7 @@ """An example cmd2 plugin""" import functools -from typing import Callable, TYPE_CHECKING +from typing import TYPE_CHECKING, Callable import cmd2 diff --git a/plugins/template/setup.py b/plugins/template/setup.py index 17d06fa89..cb1dfd8ef 100644 --- a/plugins/template/setup.py +++ b/plugins/template/setup.py @@ -2,6 +2,7 @@ # coding=utf-8 import os + import setuptools # diff --git a/plugins/template/tasks.py b/plugins/template/tasks.py index 3fcb4cbfb..dcde18047 100644 --- a/plugins/template/tasks.py +++ b/plugins/template/tasks.py @@ -8,7 +8,6 @@ import invoke - TASK_ROOT = pathlib.Path(__file__).resolve().parent TASK_ROOT_STR = str(TASK_ROOT) diff --git a/plugins/template/tests/test_myplugin.py b/plugins/template/tests/test_myplugin.py index 4149f7df3..d61181a6e 100644 --- a/plugins/template/tests/test_myplugin.py +++ b/plugins/template/tests/test_myplugin.py @@ -1,8 +1,8 @@ # # coding=utf-8 -from cmd2 import cmd2 import cmd2_myplugin +from cmd2 import cmd2 ###### # diff --git a/setup.cfg b/setup.cfg index c1de2b05a..a2b178bd4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,7 @@ +[tool:pytest] +testpaths = + tests + [flake8] exclude = .git,.idea,.pytest_cache,.tox,.nox,.venv,.vscode,build,cmd2.egg-info,dist,htmlcov,__pycache__,*.egg max-line-length = 127 @@ -12,6 +16,6 @@ force_grid_wrap = 0 use_parentheses = true [doc8] -ignore-path=docs/_build,.git,.idea,.pytest_cache,.tox,.nox,.venv,.vscode,build,cmd2,examples,tests,cmd2.egg-info,dist,htmlcov,__pycache__,*.egg +ignore-path=docs/_build,.git,.idea,.pytest_cache,.tox,.nox,.venv,.vscode,build,cmd2,examples,tests,cmd2.egg-info,dist,htmlcov,__pycache__,*.egg,plugins max-line-length=117 verbose=0 diff --git a/tasks.py b/tasks.py index e5f6e7cba..eef310eca 100644 --- a/tasks.py +++ b/tasks.py @@ -52,14 +52,28 @@ def rmrf(items, verbose=True): @invoke.task() -def pytest(context, junit=False, pty=True): +def pytest(context, junit=False, pty=True, base=False, isolated=False): """Run tests and code coverage using pytest""" with context.cd(TASK_ROOT_STR): - command_str = 'pytest --cov=cmd2 --cov-report=term --cov-report=html ' + command_str = 'pytest ' + command_str += ' --cov=cmd2 ' + command_str += ' --cov-append --cov-report=term --cov-report=html ' + + if not base and not isolated: + base = True + isolated = True + if junit: command_str += ' --junitxml=junit/test-results.xml ' - command_str += ' tests' - context.run(command_str, pty=pty) + + if base: + tests_cmd = command_str + ' tests' + context.run(tests_cmd, pty=pty) + if isolated: + for root, dirnames, _ in os.walk(str(TASK_ROOT/'isolated_tests')): + for dir in dirnames: + if dir.startswith('test_'): + context.run(command_str + ' isolated_tests/' + dir) namespace.add_task(pytest) diff --git a/tests/conftest.py b/tests/conftest.py index 60074f5cb..5b1a6f05b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,11 +25,14 @@ pass -def verify_help_text(cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]]) -> None: +def verify_help_text(cmd2_app: cmd2.Cmd, + help_output: Union[str, List[str]], + verbose_strings: Optional[List[str]] = None) -> None: """This function verifies that all expected commands are present in the help text. :param cmd2_app: instance of cmd2.Cmd :param help_output: output of help, either as a string or list of strings + :param verbose_strings: optional list of verbose strings to search for """ if isinstance(help_output, str): help_text = help_output @@ -39,7 +42,9 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]]) -> for command in commands: assert command in help_text - # TODO: Consider adding checks for categories and for verbose history + if verbose_strings: + for verbose_string in verbose_strings: + assert verbose_string in help_text # Help text for the history command diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ec8ccbc72..000000000 --- a/tox.ini +++ /dev/null @@ -1,21 +0,0 @@ -[tox] -envlist = docs,py35,py36,py37,py38,py39 - -[pytest] -testpaths = tests - -[testenv] -passenv = CI TRAVIS TRAVIS_* APPVEYOR* -setenv = PYTHONPATH={toxinidir} -extras = test -commands = - py.test {posargs} --cov --junitxml=junit/test-results.xml - codecov - -[testenv:docs] -basepython = python3.7 -deps = - sphinx - sphinx-rtd-theme -changedir = docs -commands = sphinx-build -a -W -T -b html -d {envtmpdir}/doctrees . {envtmpdir}/html