From bdcdb3b38c00426f7d50606b5da7426f2787a99c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 25 Oct 2024 21:31:44 -0400 Subject: [PATCH] All built-in commands now use a function to create their argument parser. This simplifies the process for overriding cmd2's default parser class. --- CHANGELOG.md | 6 +- cmd2/__init__.py | 50 ++-- cmd2/argparse_custom.py | 17 +- cmd2/cmd2.py | 496 +++++++++++++++++++------------- cmd2/decorators.py | 69 ++--- docs/conf.py | 1 + examples/custom_parser.py | 26 +- examples/help_categories.py | 11 +- examples/override_parser.py | 28 -- plugins/ext_test/pyproject.toml | 9 - pyproject.toml | 9 - tests/test_argparse_custom.py | 23 -- tests/test_history.py | 12 +- 13 files changed, 386 insertions(+), 371 deletions(-) delete mode 100755 examples/override_parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 291397a4..737ab407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## 3.0.0 (TBD) -* Breaking Change +* Breaking Changes * Removed macros +* Enhancements + * Simplified the process to set a custom parser for `cmd2's` built-in commands. + See [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/master/examples/custom_parser.py) + example for more details. ## 2.5.0 (October 23, 2024) * Breaking Change diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 8f1f030e..abd6d59d 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -3,8 +3,6 @@ # flake8: noqa F401 """This simply imports certain things for backwards compatibility.""" -import sys - import importlib.metadata as importlib_metadata try: @@ -15,17 +13,19 @@ from typing import List +from . import plugin from .ansi import ( - Cursor, Bg, - Fg, + Cursor, EightBitBg, EightBitFg, + Fg, RgbBg, RgbFg, TextStyle, style, ) +from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, Cmd2AttributeWrapper, @@ -33,23 +33,21 @@ register_argparse_argument_parameter, set_default_argument_parser_type, ) - -# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER. -# Do this before loading cmd2.Cmd class so its commands use the custom parser. -import argparse - -cmd2_parser_module = getattr(argparse, 'cmd2_parser_module', None) -if cmd2_parser_module is not None: - import importlib - - importlib.import_module(cmd2_parser_module) - -from .argparse_completer import set_default_ap_completer_type - 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_category, as_subcommand_to +from .command_definition import ( + CommandSet, + with_default_category, +) +from .constants import ( + COMMAND_NAME, + DEFAULT_SHORTCUTS, +) +from .decorators import ( + as_subcommand_to, + with_argparser, + with_argument_list, + with_category, +) from .exceptions import ( Cmd2ArgparseError, CommandSetRegistrationError, @@ -57,11 +55,14 @@ PassThroughException, SkipPostcommandHooks, ) -from . import plugin from .parsing import Statement from .py_bridge import CommandResult -from .utils import categorize, CompletionMode, CustomCompletionSettings, Settable - +from .utils import ( + CompletionMode, + CustomCompletionSettings, + Settable, + categorize, +) __all__: List[str] = [ 'COMMAND_NAME', @@ -81,8 +82,8 @@ 'Cmd2AttributeWrapper', 'CompletionItem', 'register_argparse_argument_parameter', - 'set_default_argument_parser_type', 'set_default_ap_completer_type', + 'set_default_argument_parser_type', # Cmd2 'Cmd', 'CommandResult', @@ -98,6 +99,7 @@ 'Cmd2ArgparseError', 'CommandSetRegistrationError', 'CompletionError', + 'PassThroughException', 'SkipPostcommandHooks', # modules 'plugin', diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 8201d54b..bed8bb47 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1402,14 +1402,21 @@ def set(self, new_val: Any) -> None: self.__attribute = new_val -# The default ArgumentParser class for a cmd2 app -DEFAULT_ARGUMENT_PARSER: Type[argparse.ArgumentParser] = Cmd2ArgumentParser +# Parser type used by cmd2's built-in commands. +# Set it using cmd2.set_default_argument_parser_type(). +DEFAULT_ARGUMENT_PARSER: Type[Cmd2ArgumentParser] = Cmd2ArgumentParser -def set_default_argument_parser_type(parser_type: Type[argparse.ArgumentParser]) -> None: +def set_default_argument_parser_type(parser_type: Type[Cmd2ArgumentParser]) -> None: """ - Set the default ArgumentParser class for a cmd2 app. This must be called prior to loading cmd2.py if - you want to override the parser for cmd2's built-in commands. See examples/override_parser.py. + Set the default ArgumentParser class for cmd2's built-in commands. + + Since built-in commands rely on customizations made in Cmd2ArgumentParser, + your custom parser class should inherit from Cmd2ArgumentParser. + + This should be called prior to instantiating your CLI object. + + See examples/custom_parser.py. """ global DEFAULT_ARGUMENT_PARSER DEFAULT_ARGUMENT_PARSER = parser_type diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b53ae7a2..25fcc735 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -83,6 +83,7 @@ ) from .argparse_custom import ( ChoicesProviderFunc, + Cmd2ArgumentParser, CompleterFunc, CompletionItem, ) @@ -3270,13 +3271,19 @@ def _cmdloop(self) -> None: ############################################################# # Top-level parser for alias - alias_description = "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string." - alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) - alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') - alias_subparsers.required = True + @staticmethod + def _build_alias_parser() -> Cmd2ArgumentParser: + alias_description = ( + "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string." + ) + alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) + alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') + alias_subparsers.required = True + + return alias_parser # Preserve quotes since we are passing strings to other commands - @with_argparser(alias_parser, preserve_quotes=True) + @with_argparser(_build_alias_parser, preserve_quotes=True) def do_alias(self, args: argparse.Namespace) -> None: """Manage aliases""" # Call handler for whatever subcommand was selected @@ -3284,34 +3291,44 @@ def do_alias(self, args: argparse.Namespace) -> None: handler(args) # alias -> create - alias_create_description = "Create or overwrite an alias" + @staticmethod + def _build_alias_create_parser() -> Cmd2ArgumentParser: + alias_create_description = "Create or overwrite an alias" + + alias_create_epilog = ( + "Notes:\n" + " If you want to use redirection, pipes, or terminators in the value of the\n" + " alias, then quote them.\n" + "\n" + " Since aliases are resolved during parsing, tab completion will function as\n" + " it would for the actual command the alias resolves to.\n" + "\n" + "Examples:\n" + " alias create ls !ls -lF\n" + " alias create show_log !cat \"log file.txt\"\n" + " alias create save_results print_results \">\" out.txt\n" + ) - alias_create_epilog = ( - "Notes:\n" - " If you want to use redirection, pipes, or terminators in the value of the\n" - " alias, then quote them.\n" - "\n" - " Since aliases are resolved during parsing, tab completion will function as\n" - " it would for the actual command the alias resolves to.\n" - "\n" - "Examples:\n" - " alias create ls !ls -lF\n" - " alias create show_log !cat \"log file.txt\"\n" - " alias create save_results print_results \">\" out.txt\n" - ) + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=alias_create_description, + epilog=alias_create_epilog, + ) + alias_create_parser.add_argument('name', help='name of this alias') + alias_create_parser.add_argument( + 'command', + help='what the alias resolves to', + choices_provider=Cmd._get_commands_and_aliases_for_completion, + ) + alias_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=Cmd.path_complete, + ) - alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=alias_create_description, epilog=alias_create_epilog - ) - alias_create_parser.add_argument('name', help='name of this alias') - alias_create_parser.add_argument( - 'command', help='what the alias resolves to', choices_provider=_get_commands_and_aliases_for_completion - ) - alias_create_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete - ) + return alias_create_parser - @as_subcommand_to('alias', 'create', alias_create_parser, help=alias_create_description.lower()) + @as_subcommand_to('alias', 'create', _build_alias_create_parser, help="create or overwrite an alias") def _alias_create(self, args: argparse.Namespace) -> None: """Create or overwrite an alias""" self.last_result = False @@ -3344,20 +3361,23 @@ def _alias_create(self, args: argparse.Namespace) -> None: self.last_result = True # alias -> delete - alias_delete_help = "delete aliases" - alias_delete_description = "Delete specified aliases or all aliases if --all is used" - - alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) - alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") - alias_delete_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to delete', - choices_provider=_get_alias_completion_items, - descriptive_header=_alias_completion_table.generate_header(), - ) + @staticmethod + def _build_alias_delete_parser() -> Cmd2ArgumentParser: + alias_delete_description = "Delete specified aliases or all aliases if --all is used" + + alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) + alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") + alias_delete_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='alias(es) to delete', + choices_provider=Cmd._get_alias_completion_items, + descriptive_header=Cmd._alias_completion_table.generate_header(), + ) + + return alias_delete_parser - @as_subcommand_to('alias', 'delete', alias_delete_parser, help=alias_delete_help) + @as_subcommand_to('alias', 'delete', _build_alias_delete_parser, help="delete aliases") def _alias_delete(self, args: argparse.Namespace) -> None: """Delete aliases""" self.last_result = True @@ -3377,24 +3397,27 @@ def _alias_delete(self, args: argparse.Namespace) -> None: self.perror(f"Alias '{cur_name}' does not exist") # alias -> list - alias_list_help = "list aliases" - alias_list_description = ( - "List specified aliases in a reusable form that can be saved to a startup\n" - "script to preserve aliases across sessions\n" - "\n" - "Without arguments, all aliases will be listed." - ) + @staticmethod + def _build_alias_list_parser() -> Cmd2ArgumentParser: + alias_list_description = ( + "List specified aliases in a reusable form that can be saved to a startup\n" + "script to preserve aliases across sessions\n" + "\n" + "Without arguments, all aliases will be listed." + ) - alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) - alias_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to list', - choices_provider=_get_alias_completion_items, - descriptive_header=_alias_completion_table.generate_header(), - ) + alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) + alias_list_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='alias(es) to list', + choices_provider=Cmd._get_alias_completion_items, + descriptive_header=Cmd._alias_completion_table.generate_header(), + ) - @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_list_help) + return alias_list_parser + + @as_subcommand_to('alias', 'list', _build_alias_list_parser, help="list aliases") def _alias_list(self, args: argparse.Namespace) -> None: """List some or all aliases as 'alias create' commands""" self.last_result = {} # Dict[alias_name, alias_value] @@ -3457,24 +3480,36 @@ def complete_help_subcommands( completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) - help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="List available commands or provide " "detailed help for a specific command" - ) - help_parser.add_argument( - '-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each" - ) - help_parser.add_argument( - 'command', nargs=argparse.OPTIONAL, help="command to retrieve help for", completer=complete_help_command - ) - help_parser.add_argument( - 'subcommands', nargs=argparse.REMAINDER, help="subcommand(s) to retrieve help for", completer=complete_help_subcommands - ) + @staticmethod + def _build_help_parser() -> Cmd2ArgumentParser: + help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="List available commands or provide detailed help for a specific command" + ) + help_parser.add_argument( + '-v', + '--verbose', + action='store_true', + help="print a list of all commands with descriptions of each", + ) + help_parser.add_argument( + 'command', + nargs=argparse.OPTIONAL, + help="command to retrieve help for", + completer=Cmd.complete_help_command, + ) + help_parser.add_argument( + 'subcommands', + nargs=argparse.REMAINDER, + help="subcommand(s) to retrieve help for", + completer=Cmd.complete_help_subcommands, + ) + return help_parser # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command if getattr(cmd.Cmd, 'complete_help', None) is not None: delattr(cmd.Cmd, 'complete_help') - @with_argparser(help_parser) + @with_argparser(_build_help_parser) def do_help(self, args: argparse.Namespace) -> None: """List available commands or provide detailed help for a specific command""" self.last_result = True @@ -3697,9 +3732,11 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: self.poutput(table_str_buf.getvalue()) - shortcuts_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") + @staticmethod + def _build_shortcuts_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") - @with_argparser(shortcuts_parser) + @with_argparser(_build_shortcuts_parser) def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts""" # Sort the shortcut tuples by name @@ -3708,11 +3745,14 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: self.poutput(f"Shortcuts for other commands:\n{result}") self.last_result = True - eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG - ) + @staticmethod + def _build_eof_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Called when Ctrl-D is pressed", + epilog=Cmd.INTERNAL_COMMAND_EPILOG, + ) - @with_argparser(eof_parser) + @with_argparser(_build_eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: """ Called when Ctrl-D is pressed and calls quit with no arguments. @@ -3723,9 +3763,11 @@ def do_eof(self, _: argparse.Namespace) -> Optional[bool]: # self.last_result will be set by do_quit() return self.do_quit('') - quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application") + @staticmethod + def _build_quit_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application") - @with_argparser(quit_parser) + @with_argparser(_build_quit_parser) def do_quit(self, _: argparse.Namespace) -> Optional[bool]: """Exit this application""" # Return True to stop the command loop @@ -3792,7 +3834,7 @@ def complete_set_value( raise CompletionError(param + " is not a settable parameter") # Create a parser with a value field based on this settable - settable_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent]) + settable_parser = Cmd._build_base_set_parser() # Settables with choices list the values of those choices instead of the arg name # in help text and this shows in tab completion hints. Set metavar to avoid this. @@ -3812,30 +3854,42 @@ def complete_set_value( _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) return completer.complete(text, line, begidx, endidx, raw_tokens[1:]) - # When tab completing value, we recreate the set command parser with a value argument specific to - # the settable being edited. To make this easier, define a parent parser with all the common elements. - set_description = ( - "Set a settable parameter or show current settings of parameters\n" - "Call without arguments for a list of all settable parameters with their values.\n" - "Call with just param to view that parameter's value." - ) - set_parser_parent = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False) - set_parser_parent.add_argument( - 'param', - nargs=argparse.OPTIONAL, - help='parameter to set or view', - choices_provider=_get_settable_completion_items, - descriptive_header=_settable_completion_table.generate_header(), - ) + @staticmethod + def _build_base_set_parser() -> Cmd2ArgumentParser: + # When tab completing value, we recreate the set command parser with a value argument specific to + # the settable being edited. To make this easier, define a base parser with all the common elements. + set_description = ( + "Set a settable parameter or show current settings of parameters\n\n" + "Call without arguments for a list of all settable parameters with their values.\n" + "Call with just param to view that parameter's value." + ) + base_set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description) + base_set_parser.add_argument( + 'param', + nargs=argparse.OPTIONAL, + help='parameter to set or view', + choices_provider=Cmd._get_settable_completion_items, + descriptive_header=Cmd._settable_completion_table.generate_header(), + ) - # Create the parser for the set command - set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) - set_parser.add_argument( - 'value', nargs=argparse.OPTIONAL, help='new value for settable', completer=complete_set_value, suppress_tab_hint=True - ) + return base_set_parser + + @staticmethod + def _build_set_parser() -> Cmd2ArgumentParser: + # Create the parser for the set command + set_parser = Cmd._build_base_set_parser() + set_parser.add_argument( + 'value', + nargs=argparse.OPTIONAL, + help='new value for settable', + completer=Cmd.complete_set_value, + suppress_tab_hint=True, + ) + + return set_parser # Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value - @with_argparser(set_parser, preserve_quotes=True) + @with_argparser(_build_set_parser, preserve_quotes=True) def do_set(self, args: argparse.Namespace) -> None: """Set a settable parameter or show current settings of parameters""" self.last_result = False @@ -3892,14 +3946,18 @@ def do_set(self, args: argparse.Namespace) -> None: self.poutput(table.generate_data_row(row_data)) self.last_result[param] = settable.get_value() - shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") - shell_parser.add_argument('command', help='the command to run', completer=shell_cmd_complete) - shell_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete - ) + @staticmethod + def _build_shell_parser() -> Cmd2ArgumentParser: + shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") + shell_parser.add_argument('command', help='the command to run', completer=Cmd.shell_cmd_complete) + shell_parser.add_argument( + 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=Cmd.path_complete + ) + + return shell_parser # Preserve quotes since we are passing these strings to the shell - @with_argparser(shell_parser, preserve_quotes=True) + @with_argparser(_build_shell_parser, preserve_quotes=True) def do_shell(self, args: argparse.Namespace) -> None: """Execute a command as if at the OS prompt""" import signal @@ -4197,9 +4255,11 @@ def py_quit() -> None: return py_bridge.stop - py_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") + @staticmethod + def _build_py_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") - @with_argparser(py_parser) + @with_argparser(_build_py_parser) def do_py(self, _: argparse.Namespace) -> Optional[bool]: """ Run an interactive Python shell @@ -4208,13 +4268,19 @@ def do_py(self, _: argparse.Namespace) -> Optional[bool]: # self.last_resort will be set by _run_python() return self._run_python() - run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console") - run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=path_complete) - run_pyscript_parser.add_argument( - 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=path_complete - ) + @staticmethod + def _build_run_pyscript_parser() -> Cmd2ArgumentParser: + run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Run a Python script file inside the console" + ) + run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=Cmd.path_complete) + run_pyscript_parser.add_argument( + 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=Cmd.path_complete + ) + + return run_pyscript_parser - @with_argparser(run_pyscript_parser) + @with_argparser(_build_run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: """ Run a Python script file inside the console @@ -4249,9 +4315,11 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: return py_return - ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") + @staticmethod + def _build_ipython_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") - @with_argparser(ipython_parser) + @with_argparser(_build_ipython_parser) def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover """ Enter an interactive IPython shell @@ -4320,54 +4388,68 @@ def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover finally: self._in_py = False - history_description = "View, run, edit, save, or clear previously entered commands" - - history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) - history_action_group = history_parser.add_mutually_exclusive_group() - history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') - history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_action_group.add_argument( - '-o', '--output_file', metavar='FILE', help='output commands to a script file, implies -s', completer=path_complete - ) - history_action_group.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='output commands and results to a transcript file,\nimplies -s', - completer=path_complete, - ) - history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + @staticmethod + def _build_history_parser() -> Cmd2ArgumentParser: + history_description = "View, run, edit, save, or clear previously entered commands" + + history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) + history_action_group = history_parser.add_mutually_exclusive_group() + history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') + history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') + history_action_group.add_argument( + '-o', + '--output_file', + metavar='FILE', + help='output commands to a script file, implies -s', + completer=Cmd.path_complete, + ) + history_action_group.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='output commands and results to a transcript file,\nimplies -s', + completer=Cmd.path_complete, + ) + history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + + history_format_group = history_parser.add_argument_group(title='formatting') + history_format_group.add_argument( + '-s', + '--script', + action='store_true', + help='output commands in script format, i.e. without command\nnumbers', + ) + history_format_group.add_argument( + '-x', + '--expanded', + action='store_true', + help='output fully parsed commands with aliases and shortcuts expanded', + ) + history_format_group.add_argument( + '-v', + '--verbose', + action='store_true', + help='display history and include expanded commands if they\ndiffer from the typed command', + ) + history_format_group.add_argument( + '-a', + '--all', + action='store_true', + help='display all commands, including ones persisted from\nprevious sessions', + ) - history_format_group = history_parser.add_argument_group(title='formatting') - history_format_group.add_argument( - '-s', '--script', action='store_true', help='output commands in script format, i.e. without command\n' 'numbers' - ) - history_format_group.add_argument( - '-x', - '--expanded', - action='store_true', - help='output fully parsed commands with aliases and shortcuts expanded', - ) - history_format_group.add_argument( - '-v', - '--verbose', - action='store_true', - help='display history and include expanded commands if they\n' 'differ from the typed command', - ) - history_format_group.add_argument( - '-a', '--all', action='store_true', help='display all commands, including ones persisted from\n' 'previous sessions' - ) + history_arg_help = ( + "empty all history items\n" + "a one history item by number\n" + "a..b, a:b, a:, ..b items by indices (inclusive)\n" + "string items containing string\n" + "/regex/ items matching regular expression" + ) + history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) - history_arg_help = ( - "empty all history items\n" - "a one history item by number\n" - "a..b, a:b, a:, ..b items by indices (inclusive)\n" - "string items containing string\n" - "/regex/ items matching regular expression" - ) - history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) + return history_parser - @with_argparser(history_parser) + @with_argparser(_build_history_parser) def do_history(self, args: argparse.Namespace) -> Optional[bool]: """ View, run, edit, save, or clear previously entered commands @@ -4380,13 +4462,11 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: if args.verbose: if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: self.poutput("-v cannot be used with any other options") - self.poutput(self.history_parser.format_usage()) return None # -s and -x can only be used if none of these options are present: [-c -r -e -o -t] if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript): self.poutput("-s and -x cannot be used with -c, -r, -e, -o, or -t") - self.poutput(self.history_parser.format_usage()) return None if args.clear: @@ -4696,20 +4776,26 @@ def _generate_transcript( self.pfeedback(f"{commands_run} {plural} saved to transcript file '{transcript_path}'") self.last_result = True - edit_description = ( - "Run a text editor and optionally open a file with it\n" - "\n" - "The editor used is determined by a settable parameter. To set it:\n" - "\n" - " set editor (program-name)" - ) + @staticmethod + def _build_edit_parser() -> Cmd2ArgumentParser: + edit_description = ( + "Run a text editor and optionally open a file with it\n" + "\n" + "The editor used is determined by a settable parameter. To set it:\n" + "\n" + " set editor (program-name)" + ) - edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) - edit_parser.add_argument( - 'file_path', nargs=argparse.OPTIONAL, help="optional path to a file to open in editor", completer=path_complete - ) + edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) + edit_parser.add_argument( + 'file_path', + nargs=argparse.OPTIONAL, + help="optional path to a file to open in editor", + completer=Cmd.path_complete, + ) + return edit_parser - @with_argparser(edit_parser) + @with_argparser(_build_edit_parser) def do_edit(self, args: argparse.Namespace) -> None: """Run a text editor and optionally open a file with it""" @@ -4750,17 +4836,25 @@ def _current_script_dir(self) -> Optional[str]: "the output of the script commands to a transcript for testing purposes.\n" ) - run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) - run_script_parser.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='record the output of the script as a transcript file', - completer=path_complete, - ) - run_script_parser.add_argument('script_path', help="path to the script file", completer=path_complete) + @staticmethod + def _build_run_script_parser() -> Cmd2ArgumentParser: + run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=Cmd.run_script_description) + run_script_parser.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='record the output of the script as a transcript file', + completer=Cmd.path_complete, + ) + run_script_parser.add_argument( + 'script_path', + help="path to the script file", + completer=Cmd.path_complete, + ) + + return run_script_parser - @with_argparser(run_script_parser) + @with_argparser(_build_run_script_parser) def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: """Run commands in script file that is encoded as either ASCII or UTF-8 text. @@ -4823,21 +4917,25 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: self._script_dir.pop() return None - relative_run_script_description = run_script_description - relative_run_script_description += ( - "\n\n" - "If this is called from within an already-running script, the filename will be\n" - "interpreted relative to the already-running script's directory." - ) + @staticmethod + def _build_relative_run_script_parser() -> Cmd2ArgumentParser: + relative_run_script_description = Cmd.run_script_description + relative_run_script_description += ( + "\n\n" + "If this is called from within an already-running script, the filename will be\n" + "interpreted relative to the already-running script's directory." + ) - relative_run_script_epilog = "Notes:\n" " This command is intended to only be used within text file scripts." + relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts." - relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=relative_run_script_description, epilog=relative_run_script_epilog - ) - relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script') + relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=relative_run_script_description, epilog=relative_run_script_epilog + ) + relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script') + + return relative_run_script_parser - @with_argparser(relative_run_script_parser) + @with_argparser(_build_relative_run_script_parser) def do__relative_run_script(self, args: argparse.Namespace) -> Optional[bool]: """ Run commands in script file that is encoded as either ASCII or UTF-8 text diff --git a/cmd2/decorators.py b/cmd2/decorators.py index cd9fd358..d3ff8026 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -11,9 +11,9 @@ Optional, Sequence, Tuple, + Type, TypeVar, Union, - overload, ) from . import ( @@ -65,19 +65,18 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: return cat_decorator -########################## -# The _parse_positionals and _arg_swap functions 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 -########################## - - CommandParent = TypeVar('CommandParent', bound=Union['cmd2.Cmd', CommandSet]) +CommandParentType = TypeVar('CommandParentType', bound=Union[Type['cmd2.Cmd'], Type[CommandSet]]) RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Union[Statement, str]], Optional[bool]] +########################## +# The _parse_positionals and _arg_swap functions 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[Any, ...]) -> Tuple['cmd2.Cmd', Union[Statement, str]]: """ Helper function for cmd2 decorators to inspect the positional arguments until the cmd2.Cmd argument is found @@ -265,28 +264,12 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: ] -@overload -def with_argparser( - parser: argparse.ArgumentParser, - *, - ns_provider: Optional[Callable[..., argparse.Namespace]] = None, - preserve_quotes: bool = False, - with_unknown_args: bool = False, -) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]: ... # pragma: no cover - - -@overload -def with_argparser( - parser: Callable[[], argparse.ArgumentParser], - *, - ns_provider: Optional[Callable[..., argparse.Namespace]] = None, - preserve_quotes: bool = False, - with_unknown_args: bool = False, -) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]: ... # pragma: no cover - - def with_argparser( - parser: Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser]], + parser: Union[ + argparse.ArgumentParser, # existing parser + Callable[[], argparse.ArgumentParser], # function or staticmethod + Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod + ], *, ns_provider: Optional[Callable[..., argparse.Namespace]] = None, preserve_quotes: bool = False, @@ -413,32 +396,14 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]: return arg_decorator -@overload -def as_subcommand_to( - command: str, - subcommand: str, - parser: argparse.ArgumentParser, - *, - help: Optional[str] = None, - aliases: Optional[List[str]] = None, -) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: ... # pragma: no cover - - -@overload -def as_subcommand_to( - command: str, - subcommand: str, - parser: Callable[[], argparse.ArgumentParser], - *, - help: Optional[str] = None, - aliases: Optional[List[str]] = None, -) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: ... # pragma: no cover - - def as_subcommand_to( command: str, subcommand: str, - parser: Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser]], + parser: Union[ + argparse.ArgumentParser, # existing parser + Callable[[], argparse.ArgumentParser], # function or staticmethod + Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod + ], *, help: Optional[str] = None, aliases: Optional[List[str]] = None, diff --git a/docs/conf.py b/docs/conf.py index f4f3451b..499aa274 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -181,6 +181,7 @@ nitpick_ignore = [ ('py:class', 'cmd2.decorators.CommandParent'), ('py:obj', 'cmd2.decorators.CommandParent'), + ('py:class', 'cmd2.decorators.CommandParentType'), ('py:class', 'argparse._SubParsersAction'), ('py:class', 'cmd2.utils._T'), ('py:class', 'types.FrameType'), diff --git a/examples/custom_parser.py b/examples/custom_parser.py index 94df3b05..419c0a54 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -1,25 +1,29 @@ # coding=utf-8 """ -Defines the CustomParser used with override_parser.py example +The standard parser used by cmd2 built-in commands is Cmd2ArgumentParser. +The following code shows how to override it with your own parser class. """ import sys +from typing import NoReturn from cmd2 import ( Cmd2ArgumentParser, ansi, + cmd2, set_default_argument_parser_type, ) -# First define the parser +# Since built-in commands rely on customizations made in Cmd2ArgumentParser, +# your custom parser class should inherit from Cmd2ArgumentParser. class CustomParser(Cmd2ArgumentParser): - """Overrides error class""" + """Overrides error method""" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] super().__init__(*args, **kwargs) - def error(self, message: str) -> None: + def error(self, message: str) -> NoReturn: """Custom override that applies custom formatting to the error message""" lines = message.split('\n') linum = 0 @@ -38,5 +42,13 @@ def error(self, message: str) -> None: self.exit(2, '{}\n\n'.format(formatted_message)) -# Now set the default parser for a cmd2 app -set_default_argument_parser_type(CustomParser) +if __name__ == '__main__': + import sys + + # Set the default parser type before instantiating app. + set_default_argument_parser_type(CustomParser) + + app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat') + app.self_in_py = True # Enable access to "self" within the py command + app.debug = True # Show traceback if/when an exception occurs + sys.exit(app.cmdloop()) diff --git a/examples/help_categories.py b/examples/help_categories.py index 8059ca90..bb5a1d43 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -11,7 +11,6 @@ import cmd2 from cmd2 import ( COMMAND_NAME, - argparse_custom, ) @@ -60,8 +59,9 @@ def do_deploy(self, _): """Deploy command""" self.poutput('Deploy') - start_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description='Start', epilog='my_decorator runs even with argparse errors' + start_parser = cmd2.Cmd2ArgumentParser( + description='Start', + epilog='my_decorator runs even with argparse errors', ) start_parser.add_argument('when', choices=START_TIMES, help='Specify when to start') @@ -79,8 +79,9 @@ def do_redeploy(self, _): """Redeploy command""" self.poutput('Redeploy') - restart_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description='Restart', epilog='my_decorator does not run when argparse errors' + restart_parser = cmd2.Cmd2ArgumentParser( + description='Restart', + epilog='my_decorator does not run when argparse errors', ) restart_parser.add_argument('when', choices=START_TIMES, help='Specify when to restart') diff --git a/examples/override_parser.py b/examples/override_parser.py deleted file mode 100755 index 36f25c75..00000000 --- a/examples/override_parser.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -# flake8: noqa F402 -""" -The standard parser used by cmd2 built-in commands is Cmd2ArgumentParser. -The following code shows how to override it with your own parser class. -""" - -# First set a value called argparse.cmd2_parser_module with the module that defines the custom parser. -# See the code for custom_parser.py. It simply defines a parser and calls cmd2.set_default_argument_parser_type() -# with the custom parser's type. -import argparse - -argparse.cmd2_parser_module = 'examples.custom_parser' - -# Next import from cmd2. It will import your module just before the cmd2.Cmd class file is imported -# and therefore override the parser class it uses on its commands. -from cmd2 import ( - cmd2, -) - -if __name__ == '__main__': - import sys - - app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat') - app.self_in_py = True # Enable access to "self" within the py command - app.debug = True # Show traceback if/when an exception occurs - sys.exit(app.cmdloop()) diff --git a/plugins/ext_test/pyproject.toml b/plugins/ext_test/pyproject.toml index 715301a9..5dbbd826 100644 --- a/plugins/ext_test/pyproject.toml +++ b/plugins/ext_test/pyproject.toml @@ -145,19 +145,10 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 -per-file-ignores."cmd2/__init__.py" = [ - "E402", # Module level import not at top of file - "F401", # Unused import -] - per-file-ignores."docs/conf.py" = [ "F401", # Unused import ] -per-file-ignores."examples/override_parser.py" = [ - "E402", # Module level import not at top of file -] - per-file-ignores."examples/scripts/*.py" = [ "F821", # Undefined name `app` ] diff --git a/pyproject.toml b/pyproject.toml index 6dae9cea..c497b867 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,19 +186,10 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 -per-file-ignores."cmd2/__init__.py" = [ - "E402", # Module level import not at top of file - "F401", # Unused import -] - per-file-ignores."docs/conf.py" = [ "F401", # Unused import ] -per-file-ignores."examples/override_parser.py" = [ - "E402", # Module level import not at top of file -] - per-file-ignores."examples/scripts/*.py" = [ "F821", # Undefined name `app` ] diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index a3f85558..f9eeec5d 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -243,29 +243,6 @@ def test_apcustom_required_options(): assert 'required arguments' in parser.format_help() -def test_override_parser(): - """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER""" - import importlib - - from cmd2 import ( - argparse_custom, - ) - - # The standard parser is Cmd2ArgumentParser - assert argparse_custom.DEFAULT_ARGUMENT_PARSER == Cmd2ArgumentParser - - # Set our parser module and force a reload of cmd2 so it loads the module - argparse.cmd2_parser_module = 'examples.custom_parser' - importlib.reload(cmd2) - - # Verify DEFAULT_ARGUMENT_PARSER is now our CustomParser - from examples.custom_parser import ( - CustomParser, - ) - - assert argparse_custom.DEFAULT_ARGUMENT_PARSER == CustomParser - - def test_apcustom_metavar_tuple(): # Test the case when a tuple metavar is used with nargs an integer > 1 parser = Cmd2ArgumentParser() diff --git a/tests/test_history.py b/tests/test_history.py index 1a3bd744..f2e12277 100755 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -772,9 +772,7 @@ def test_history_verbose_with_other_options(base_app): options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -v ' + opt) - assert len(out) == 4 - assert out[0] == '-v cannot be used with any other options' - assert out[1].startswith('Usage:') + assert '-v cannot be used with any other options' in out assert base_app.last_result is False @@ -800,9 +798,7 @@ def test_history_script_with_invalid_options(base_app): options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -s ' + opt) - assert len(out) == 4 - assert out[0] == '-s and -x cannot be used with -c, -r, -e, -o, or -t' - assert out[1].startswith('Usage:') + assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out assert base_app.last_result is False @@ -820,9 +816,7 @@ def test_history_expanded_with_invalid_options(base_app): options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -x ' + opt) - assert len(out) == 4 - assert out[0] == '-s and -x cannot be used with -c, -r, -e, -o, or -t' - assert out[1].startswith('Usage:') + assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out assert base_app.last_result is False