diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2ac5356..ea28343f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.1" + rev: "v0.12.2" hooks: - id: ruff-format args: [--config=pyproject.toml] diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ec8717..3f957308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ ## 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/main/examples/custom_parser.py) + example for more details. + ## 2.7.0 (June 30, 2025) - Enhancements diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 09962e79..b6b56682 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -1,13 +1,12 @@ """Import certain things for backwards compatibility.""" -import argparse import contextlib import importlib.metadata as importlib_metadata -import sys with contextlib.suppress(importlib_metadata.PackageNotFoundError): __version__ = importlib_metadata.version(__name__) +from . import plugin from .ansi import ( Bg, Cursor, @@ -19,6 +18,7 @@ TextStyle, style, ) +from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, Cmd2AttributeWrapper, @@ -26,21 +26,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. -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 . import plugin -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 as_subcommand_to, with_argparser, with_argument_list, with_category +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, @@ -50,7 +50,12 @@ ) from .parsing import Statement from .py_bridge import CommandResult -from .utils import CompletionMode, CustomCompletionSettings, Settable, categorize +from .utils import ( + CompletionMode, + CustomCompletionSettings, + Settable, + categorize, +) __all__: list[str] = [ # noqa: RUF022 'COMMAND_NAME', @@ -70,8 +75,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', @@ -87,6 +92,7 @@ 'Cmd2ArgparseError', 'CommandSetRegistrationError', 'CompletionError', + 'PassThroughException', 'SkipPostcommandHooks', # modules 'plugin', diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index d67ccbe8..79d17a8c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -6,7 +6,7 @@ parser that inherits from it. This will give a consistent look-and-feel between the help/error output of built-in cmd2 commands and the app-specific commands. If you wish to override the parser used by cmd2's built-in commands, see -override_parser.py example. +custom_parser.py example. Since the new capabilities are added by patching at the argparse API level, they are available whether or not Cmd2ArgumentParser is used. However, the help @@ -1378,15 +1378,20 @@ 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: - """Set the default ArgumentParser class for a cmd2 app. +def set_default_argument_parser_type(parser_type: type[Cmd2ArgumentParser]) -> None: + """Set the default ArgumentParser class for cmd2's built-in commands. - 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. + 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 # noqa: PLW0603 DEFAULT_ARGUMENT_PARSER = parser_type diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d0de8782..bccfb888 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -77,6 +77,7 @@ ) from .argparse_custom import ( ChoicesProviderFunc, + Cmd2ArgumentParser, CompleterFunc, CompletionItem, ) @@ -3216,12 +3217,16 @@ def _cmdloop(self) -> None: ############################################################# # Top-level parser for alias - alias_description = "Manage aliases\n\nAn alias is a command that enables replacement of a word by another string." - alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) - alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + @staticmethod + def _build_alias_parser() -> Cmd2ArgumentParser: + alias_description = "Manage aliases\n\nAn alias is a command that enables replacement of a word by another string." + alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) + alias_parser.add_subparsers(metavar='SUBCOMMAND', 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 @@ -3231,32 +3236,42 @@ def do_alias(self, args: argparse.Namespace) -> None: # alias -> create 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" - ) + @classmethod + def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: + 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=_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 - ) + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=cls.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=cls._get_commands_and_aliases_for_completion, + ) + alias_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=cls.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=alias_create_description.lower()) def _alias_create(self, args: argparse.Namespace) -> None: """Create or overwrite an alias.""" self.last_result = False @@ -3289,20 +3304,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(), - ) + @classmethod + def _build_alias_delete_parser(cls) -> 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=cls._get_alias_completion_items, + descriptive_header=cls._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 @@ -3322,24 +3340,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." - ) + @classmethod + def _build_alias_list_parser(cls) -> 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=cls._get_alias_completion_items, + descriptive_header=cls._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] @@ -3395,24 +3416,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 - ) + @classmethod + def _build_help_parser(cls) -> 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=cls.complete_help_command, + ) + help_parser.add_argument( + 'subcommands', + nargs=argparse.REMAINDER, + help="subcommand(s) to retrieve help for", + completer=cls.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 @@ -3640,9 +3673,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 @@ -3651,11 +3686,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 - ) + @classmethod + def _build_eof_parser(cls) -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Called when Ctrl-D is pressed", + epilog=cls.INTERNAL_COMMAND_EPILOG, + ) - @with_argparser(eof_parser) + @with_argparser(_build_eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: """Quit with no arguments, called when Ctrl-D is pressed. @@ -3666,9 +3704,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 @@ -3726,6 +3766,26 @@ def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], p except (ValueError, IndexError): self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:") + @classmethod + def _build_base_set_parser(cls) -> 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=cls._get_settable_completion_items, + descriptive_header=cls._settable_completion_table.generate_header(), + ) + + return base_set_parser + def complete_set_value( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] ) -> list[str]: @@ -3737,7 +3797,7 @@ def complete_set_value( raise CompletionError(param + " is not a settable parameter") from exc # 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 = self._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. @@ -3757,30 +3817,22 @@ 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(), - ) + @classmethod + def _build_set_parser(cls) -> Cmd2ArgumentParser: + # Create the parser for the set command + set_parser = cls._build_base_set_parser() + set_parser.add_argument( + 'value', + nargs=argparse.OPTIONAL, + help='new value for settable', + completer=cls.complete_set_value, + suppress_tab_hint=True, + ) - # 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 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 @@ -3837,14 +3889,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 - ) + @classmethod + def _build_shell_parser(cls) -> 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=cls.shell_cmd_complete) + shell_parser.add_argument( + 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=cls.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 @@ -4141,9 +4197,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. @@ -4152,13 +4210,19 @@ def do_py(self, _: argparse.Namespace) -> Optional[bool]: # self.last_result 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 - ) + @classmethod + def _build_run_pyscript_parser(cls) -> 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=cls.path_complete) + run_pyscript_parser.add_argument( + 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=cls.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. @@ -4192,9 +4256,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. @@ -4266,54 +4332,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='create a transcript file by re-running the commands,\nimplies both -r and -s', - completer=path_complete, - ) - history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + @classmethod + def _build_history_parser(cls) -> 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=cls.path_complete, + ) + history_action_group.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='create a transcript file by re-running the commands,\nimplies both -r and -s', + completer=cls.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\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_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. @@ -4325,13 +4405,11 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: if args.verbose: # noqa: SIM102 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: @@ -4638,20 +4716,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)" - ) + @classmethod + def _build_edit_parser(cls) -> 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=cls.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.""" # self.last_result will be set by do_shell() which is called by run_editor() @@ -4689,17 +4773,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) + @classmethod + def _build_run_script_parser(cls) -> Cmd2ArgumentParser: + run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=cls.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=cls.path_complete, + ) + run_script_parser.add_argument( + 'script_path', + help="path to the script file", + completer=cls.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. @@ -4762,21 +4854,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." - ) + @classmethod + def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser: + relative_run_script_description = cls.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/examples/README.md b/examples/README.md index e8d5cf51..67bd4278 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,8 +35,7 @@ each: - [colors.py](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) - Show various ways of using colorized output within a cmd2 application - [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) - - Demonstrates how to create your own customer `Cmd2ArgumentParser`; used by the - `override_parser.py` example + - Demonstrates how to create your own custom `Cmd2ArgumentParser` - [decorator_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) - Shows how to use cmd2's various argparse decorators to processes command-line arguments - [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) @@ -78,8 +77,6 @@ each: command decorators - [modular_subcommands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_subcommands.py) - Shows how to dynamically add and remove subcommands at runtime using `CommandSets` -- [override-parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/override_parser.py) - - Shows how to override cmd2's default `Cmd2ArgumentParser` with your own customer parser class - [paged_output.py](https://github.com/python-cmd2/cmd2/blob/main/examples/paged_output.py) - Shows how to use output pagination within `cmd2` apps via the `ppaged` method - [persistent_history.py](https://github.com/python-cmd2/cmd2/blob/main/examples/persistent_history.py) diff --git a/examples/custom_parser.py b/examples/custom_parser.py index a79a65b8..d4c33116 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -1,22 +1,28 @@ -"""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') formatted_message = '' @@ -33,5 +39,13 @@ def error(self, message: str) -> None: self.exit(2, f'{formatted_message}\n\n') -# 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 8b213c74..7a187250 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -7,10 +7,7 @@ import functools import cmd2 -from cmd2 import ( - COMMAND_NAME, - argparse_custom, -) +from cmd2 import COMMAND_NAME def my_decorator(f): @@ -58,8 +55,9 @@ def do_deploy(self, _) -> None: """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') @@ -77,8 +75,9 @@ def do_redeploy(self, _) -> None: """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 2d4a0f9c..00000000 --- a/examples/override_parser.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -"""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 = '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 # noqa: E402 - -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..f46d152b 100644 --- a/plugins/ext_test/pyproject.toml +++ b/plugins/ext_test/pyproject.toml @@ -6,11 +6,11 @@ disallow_incomplete_defs = true disallow_untyped_calls = true disallow_untyped_defs = true exclude = [ - "^examples/", # examples directory + "^examples/", # examples directory "^noxfile\\.py$", # nox config file - "setup\\.py$", # any files named setup.py - "^tasks\\.py$", # tasks.py invoke config file - "^tests/", # tests directory + "setup\\.py$", # any files named setup.py + "^tasks\\.py$", # tasks.py invoke config file + "^tests/", # tests directory ] show_column_numbers = true show_error_codes = true @@ -82,7 +82,7 @@ select = [ # "EM", # flake8-errmsg # "ERA", # eradicate # "EXE", # flake8-executable - "F", # Pyflakes + "F", # Pyflakes "FA", # flake8-future-annotations # "FBT", # flake8-boolean-trap "G", # flake8-logging-format @@ -93,7 +93,7 @@ select = [ # "ISC", # flake8-implicit-str-concat # "N", # pep8-naming "NPY", # NumPy-specific rules - "PD", # pandas-vet + "PD", # pandas-vet # "PGH", # pygrep-hooks # "PIE", # flake8-pie # "PL", # Pylint @@ -119,21 +119,21 @@ select = [ ] ignore = [ # `ruff rule S101` for a description of that rule - "B904", # Within an `except` clause, raise exceptions with `raise ... from err` -- FIX ME - "B905", # `zip()` without an explicit `strict=` parameter -- FIX ME - "E501", # Line too long - "EM101", # Exception must not use a string literal, assign to variable first - "EXE001", # Shebang is present but file is not executable -- DO NOT FIX - "G004", # Logging statement uses f-string + "B904", # Within an `except` clause, raise exceptions with `raise ... from err` -- FIX ME + "B905", # `zip()` without an explicit `strict=` parameter -- FIX ME + "E501", # Line too long + "EM101", # Exception must not use a string literal, assign to variable first + "EXE001", # Shebang is present but file is not executable -- DO NOT FIX + "G004", # Logging statement uses f-string "PLC1901", # `{}` can be simplified to `{}` as an empty string is falsey - "PLW060", # Using global for `{name}` but no assignment is done -- DO NOT FIX + "PLW060", # Using global for `{name}` but no assignment is done -- DO NOT FIX "PLW2901", # PLW2901: Redefined loop variable -- FIX ME - "PT011", # `pytest.raises(Exception)` is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "S101", # Use of `assert` detected -- DO NOT FIX - "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes -- FIX ME - "SLF001", # Private member accessed: `_Iterator` -- FIX ME - "UP038", # Use `X | Y` in `{}` call instead of `(X, Y)` -- DO NOT FIX + "PT011", # `pytest.raises(Exception)` is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "S101", # Use of `assert` detected -- DO NOT FIX + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes -- FIX ME + "SLF001", # Private member accessed: `_Iterator` -- FIX ME + "UP038", # Use `X | Y` in `{}` call instead of `(X, Y)` -- DO NOT FIX ] # Allow fix for all enabled rules (when `--fix`) is provided. @@ -145,19 +145,6 @@ 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 4794983f..9f3990d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,9 +259,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 [tool.ruff.lint.per-file-ignores] -# Module level import not at top of file and unused import -"cmd2/__init__.py" = ["E402", "F401"] - # Do not call setattr with constant attribute value "cmd2/argparse_custom.py" = ["B010"] diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index bd79910e..eedd0d3e 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -240,29 +240,6 @@ def test_apcustom_required_options() -> None: assert 'required arguments' in parser.format_help() -def test_override_parser() -> None: - """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER""" - import importlib - - from cmd2 import ( - argparse_custom, - ) - - # The standard parser is Cmd2ArgumentParser - assert Cmd2ArgumentParser == argparse_custom.DEFAULT_ARGUMENT_PARSER - - # 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 CustomParser == argparse_custom.DEFAULT_ARGUMENT_PARSER - - def test_apcustom_metavar_tuple() -> None: # 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 7b2a3a7c..703966c2 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -770,9 +770,7 @@ def test_history_verbose_with_other_options(base_app) -> None: 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 4 <= len(out) <= 5 - 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 @@ -798,9 +796,7 @@ def test_history_script_with_invalid_options(base_app) -> None: 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 4 <= len(out) <= 5 - 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 @@ -818,9 +814,7 @@ def test_history_expanded_with_invalid_options(base_app) -> None: 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 4 <= len(out) <= 5 - 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