diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2021-08-23 14:20:09 -0400 |
---|---|---|
committer | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2021-08-23 14:20:09 -0400 |
commit | 16e145a27ea7b4c2dc348699c8f4cc11be4dc0b6 (patch) | |
tree | eb0520a93dfd388360d5d816a53cf5f8a3b35d01 | |
parent | 7f07f5ef66a4a3d986d265b8f7fc9d014f6d5541 (diff) | |
parent | 9d818100f3b6dfa647e58b8a0df182ea6729a197 (diff) | |
download | cmd2-git-16e145a27ea7b4c2dc348699c8f4cc11be4dc0b6.tar.gz |
Merge branch 'master' into topic_width
-rw-r--r-- | CHANGELOG.md | 21 | ||||
-rw-r--r-- | cmd2/__init__.py | 20 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 45 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 84 | ||||
-rw-r--r-- | cmd2/cmd2.py | 69 | ||||
-rw-r--r-- | cmd2/constants.py | 1 | ||||
-rw-r--r-- | cmd2/decorators.py | 7 | ||||
-rw-r--r-- | examples/custom_parser.py | 2 | ||||
-rwxr-xr-x | examples/override_parser.py | 9 | ||||
-rwxr-xr-x | setup.py | 1 | ||||
-rw-r--r-- | tests/test_argparse_completer.py | 53 | ||||
-rw-r--r-- | tests/test_argparse_custom.py | 10 |
12 files changed, 257 insertions, 65 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e117b17..3bb53bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,19 @@ ## 2.2.0 (TBD, 2021) * Enhancements - * Using `SimpleTable` in the output for the following commands to improve appearance. - * help - * set (command and tab completion of Settables) - * alias tab completion - * macro tab completion - * Tab completion of `CompletionItems` now includes divider row comprised of `Cmd.ruler` character. - * Removed `--verbose` flag from set command since descriptions always show now. + * New function `set_default_command_completer_type()` allows developer to extend and modify the + behavior of `ArgparseCompleter`. + * New function `register_argparse_argument_parameter()` allows developers to specify custom + parameters to be passed to the argparse parser's `add_argument()` method. These parameters will + become accessible in the resulting argparse Action object when modifying `ArgparseCompleter` behavior. + * Using `SimpleTable` in the output for the following commands to improve appearance. + * help + * set (command and tab completion of Settables) + * alias tab completion + * macro tab completion + * Tab completion of `CompletionItems` now includes divider row comprised of `Cmd.ruler` character. + * Removed `--verbose` flag from set command since descriptions always show now. * Deletions (potentially breaking changes) - * Deleted ``set_choices_provider()`` and ``set_completer()`` which were deprecated in 2.1.2 + * Deleted ``set_choices_provider()`` and ``set_completer()`` which were deprecated in 2.1.2 ## 2.1.2 (July 5, 2021) * Enhancements diff --git a/cmd2/__init__.py b/cmd2/__init__.py index e545f394..a23b7a36 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -5,7 +5,7 @@ import sys -# For python 3.8 and late +# For python 3.8 and later if sys.version_info >= (3, 8): import importlib.metadata as importlib_metadata else: @@ -20,9 +20,16 @@ except importlib_metadata.PackageNotFoundError: # pragma: no cover from typing import List from .ansi import style, fg, bg -from .argparse_custom import Cmd2ArgumentParser, Cmd2AttributeWrapper, CompletionItem, set_default_argument_parser +from .argparse_custom import ( + Cmd2ArgumentParser, + Cmd2AttributeWrapper, + CompletionItem, + register_argparse_argument_parameter, + set_default_argument_parser, +) -# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER +# 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) @@ -31,8 +38,8 @@ if cmd2_parser_module is not None: importlib.import_module(cmd2_parser_module) -# Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER -from .argparse_custom import DEFAULT_ARGUMENT_PARSER +from .argparse_completer import set_default_command_completer_type + from .cmd2 import Cmd from .command_definition import CommandSet, with_default_category from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS @@ -46,7 +53,6 @@ from .utils import categorize, CompletionMode, CustomCompletionSettings, Settabl __all__: List[str] = [ 'COMMAND_NAME', - 'DEFAULT_ARGUMENT_PARSER', 'DEFAULT_SHORTCUTS', # ANSI Style exports 'bg', @@ -56,7 +62,9 @@ __all__: List[str] = [ 'Cmd2ArgumentParser', 'Cmd2AttributeWrapper', 'CompletionItem', + 'register_argparse_argument_parameter', 'set_default_argument_parser', + 'set_default_command_completer_type', # Cmd2 'Cmd', 'CommandResult', diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 967e3f1c..ebc49a8c 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -13,18 +13,28 @@ from collections import ( deque, ) from typing import ( + TYPE_CHECKING, Dict, List, Optional, + Type, Union, cast, ) -from . import ( - ansi, - cmd2, - constants, +from .ansi import ( + style_aware_wcswidth, + widest_line, ) +from .constants import ( + INFINITY, +) + +if TYPE_CHECKING: + from .cmd2 import ( + Cmd, + ) + from .argparse_custom import ( ChoicesCallable, ChoicesProviderFuncWithTokens, @@ -124,10 +134,10 @@ class _ArgumentState: self.max = 1 elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: self.min = 0 - self.max = constants.INFINITY + self.max = INFINITY elif self.action.nargs == argparse.ONE_OR_MORE: self.min = 1 - self.max = constants.INFINITY + self.max = INFINITY else: self.min = self.action.nargs self.max = self.action.nargs @@ -165,7 +175,7 @@ class ArgparseCompleter: """Automatic command line tab completion based on argparse parameters""" def __init__( - self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, parent_tokens: Optional[Dict[str, List[str]]] = None + self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: Optional[Dict[str, List[str]]] = None ) -> None: """ Create an ArgparseCompleter @@ -564,15 +574,15 @@ class ArgparseCompleter: desc_header = desc_header.replace('\t', four_spaces) # Calculate needed widths for the token and description columns of the table - token_width = ansi.style_aware_wcswidth(destination) - desc_width = ansi.widest_line(desc_header) + token_width = style_aware_wcswidth(destination) + desc_width = widest_line(desc_header) for item in completion_items: - token_width = max(ansi.style_aware_wcswidth(item), token_width) + token_width = max(style_aware_wcswidth(item), token_width) # Replace tabs with 4 spaces so we can calculate width item.description = item.description.replace('\t', four_spaces) - desc_width = max(ansi.widest_line(item.description), desc_width) + desc_width = max(widest_line(item.description), desc_width) cols = list() cols.append(Column(destination.upper(), width=token_width)) @@ -728,3 +738,16 @@ class ArgparseCompleter: return [] return self._format_completions(arg_state, results) + + +DEFAULT_COMMAND_COMPLETER: Type[ArgparseCompleter] = ArgparseCompleter + + +def set_default_command_completer_type(completer_type: Type[ArgparseCompleter]) -> None: + """ + Set the default command completer type. It must be a sub-class of the ArgparseCompleter. + + :param completer_type: Type that is a subclass of ArgparseCompleter. + """ + global DEFAULT_COMMAND_COMPLETER + DEFAULT_COMMAND_COMPLETER = completer_type diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 461b4bba..44e7a90b 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -240,6 +240,7 @@ from typing import ( NoReturn, Optional, Sequence, + Set, Tuple, Type, Union, @@ -641,9 +642,74 @@ setattr(argparse.Action, 'set_suppress_tab_hint', _action_set_suppress_tab_hint) ############################################################################################################ +# Allow developers to add custom action attributes +############################################################################################################ + +CUSTOM_ACTION_ATTRIBS: Set[str] = set() +_CUSTOM_ATTRIB_PFX = '_attr_' + + +def register_argparse_argument_parameter(param_name: str, param_type: Optional[Type[Any]]) -> None: + """ + Registers a custom argparse argument parameter. + + The registered name will then be a recognized keyword parameter to the parser's `add_argument()` function. + + An accessor functions will be added to the parameter's Action object in the form of: ``get_{param_name}()`` + and ``set_{param_name}(value)``. + + :param param_name: Name of the parameter to add. + """ + attr_name = f'{_CUSTOM_ATTRIB_PFX}{param_name}' + if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): + raise KeyError(f'Custom parameter {param_name} already exists') + if not re.search('^[A-Za-z_][A-Za-z0-9_]*$', param_name): + raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier') + + getter_name = f'get_{param_name}' + + def _action_get_custom_parameter(self: argparse.Action) -> Any: + f""" + Get the custom {param_name} attribute of an argparse Action. + + This function is added by cmd2 as a method called ``{getter_name}()`` to ``argparse.Action`` class. + + To call: ``action.{getter_name}()`` + + :param self: argparse Action being queried + :return: The value of {param_name} or None if attribute does not exist + """ + return getattr(self, attr_name, None) + + setattr(argparse.Action, getter_name, _action_get_custom_parameter) + + setter_name = f'set_{param_name}' + + def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: + f""" + Set the custom {param_name} attribute of an argparse Action. + + This function is added by cmd2 as a method called ``{setter_name}()`` to ``argparse.Action`` class. + + To call: ``action.{setter_name}({param_name})`` + + :param self: argparse Action being updated + :param value: value being assigned + """ + if param_type and not isinstance(value, param_type): + raise TypeError(f'{param_name} must be of type {param_type}, got: {value} ({type(value)})') + setattr(self, attr_name, value) + + setattr(argparse.Action, setter_name, _action_set_custom_parameter) + + CUSTOM_ACTION_ATTRIBS.add(param_name) + + +############################################################################################################ # Patch _ActionsContainer.add_argument with our wrapper to support more arguments ############################################################################################################ + # Save original _ActionsContainer.add_argument so we can call it in our wrapper # noinspection PyProtectedMember orig_actions_container_add_argument = argparse._ActionsContainer.add_argument @@ -753,6 +819,14 @@ def _add_argument_wrapper( # Add the argparse-recognized version of nargs to kwargs kwargs['nargs'] = nargs_adjusted + # Extract registered custom keyword arguments + custom_attribs: Dict[str, Any] = {} + for keyword, value in kwargs.items(): + if keyword in CUSTOM_ACTION_ATTRIBS: + custom_attribs[keyword] = value + for keyword in custom_attribs: + del kwargs[keyword] + # Create the argument using the original add_argument function new_arg = orig_actions_container_add_argument(self, *args, **kwargs) @@ -767,6 +841,11 @@ def _add_argument_wrapper( new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined] new_arg.set_descriptive_header(descriptive_header) # type: ignore[attr-defined] + for keyword, value in custom_attribs.items(): + attr_setter = getattr(new_arg, f'set_{keyword}', None) + if attr_setter is not None: + attr_setter(value) + return new_arg @@ -1243,6 +1322,9 @@ DEFAULT_ARGUMENT_PARSER: Type[argparse.ArgumentParser] = Cmd2ArgumentParser def set_default_argument_parser(parser: Type[argparse.ArgumentParser]) -> None: - """Set the default ArgumentParser class for a cmd2 app""" + """ + 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. + """ global DEFAULT_ARGUMENT_PARSER DEFAULT_ARGUMENT_PARSER = parser diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 35398088..32fbc731 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -71,12 +71,13 @@ from typing import ( from . import ( ansi, + argparse_completer, + argparse_custom, constants, plugin, utils, ) from .argparse_custom import ( - DEFAULT_ARGUMENT_PARSER, ChoicesProviderFunc, CompleterFunc, CompletionItem, @@ -409,7 +410,7 @@ class Cmd(cmd.Cmd): # Check for command line args if allow_cli_args: - parser = DEFAULT_ARGUMENT_PARSER() + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER() parser.add_argument('-t', '--test', action="store_true", help='Test against transcript(s) in FILE (wildcards OK)') callopts, callargs = parser.parse_known_args() @@ -1904,10 +1905,16 @@ class Cmd(cmd.Cmd): # There's no completer function, next see if the command uses argparse func = self.cmd_func(command) argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None) + completer_type = getattr(func, constants.CMD_ATTR_COMPLETER, argparse_completer.DEFAULT_COMMAND_COMPLETER) + if completer_type is None: + completer_type = argparse_completer.DEFAULT_COMMAND_COMPLETER if func is not None and argparser is not None: cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None - completer = ArgparseCompleter(argparser, self) + if completer_type is not None: + completer = completer_type(argparser, self) + else: + completer = ArgparseCompleter(argparser, self) preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES) completer_func = functools.partial( @@ -2076,7 +2083,7 @@ class Cmd(cmd.Cmd): break else: # No shortcut was found. Complete the command token. - parser = DEFAULT_ARGUMENT_PARSER(add_help=False) + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) parser.add_argument( 'command', metavar="COMMAND", @@ -2910,7 +2917,7 @@ class Cmd(cmd.Cmd): # Set custom completion settings else: if parser is None: - parser = DEFAULT_ARGUMENT_PARSER(add_help=False) + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) parser.add_argument( 'arg', suppress_tab_hint=True, @@ -3112,7 +3119,7 @@ class Cmd(cmd.Cmd): # 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_epilog = "See also:\n" " macro" - alias_parser = DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog) + alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog) alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') alias_subparsers.required = True @@ -3141,7 +3148,9 @@ class Cmd(cmd.Cmd): " alias create save_results print_results \">\" out.txt\n" ) - alias_create_parser = DEFAULT_ARGUMENT_PARSER(description=alias_create_description, epilog=alias_create_epilog) + 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_aliases_and_macros_for_completion @@ -3187,7 +3196,7 @@ class Cmd(cmd.Cmd): alias_delete_help = "delete aliases" alias_delete_description = "Delete specified aliases or all aliases if --all is used" - alias_delete_parser = DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) + 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', @@ -3222,7 +3231,7 @@ class Cmd(cmd.Cmd): "Without arguments, all aliases will be listed." ) - alias_list_parser = DEFAULT_ARGUMENT_PARSER(description=alias_list_description) + alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) alias_list_parser.add_argument( 'names', nargs=argparse.ZERO_OR_MORE, @@ -3270,7 +3279,7 @@ class Cmd(cmd.Cmd): # Top-level parser for macro macro_description = "Manage macros\n" "\n" "A macro is similar to an alias, but it can contain argument placeholders." macro_epilog = "See also:\n" " alias" - macro_parser = DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog) + macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog) macro_subparsers = macro_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') macro_subparsers.required = True @@ -3323,7 +3332,9 @@ class Cmd(cmd.Cmd): " will only complete paths while typing a macro." ) - macro_create_parser = DEFAULT_ARGUMENT_PARSER(description=macro_create_description, epilog=macro_create_epilog) + macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=macro_create_description, epilog=macro_create_epilog + ) macro_create_parser.add_argument('name', help='name of this macro') macro_create_parser.add_argument( 'command', help='what the macro resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion @@ -3413,7 +3424,7 @@ class Cmd(cmd.Cmd): # macro -> delete macro_delete_help = "delete macros" macro_delete_description = "Delete specified macros or all macros if --all is used" - macro_delete_parser = DEFAULT_ARGUMENT_PARSER(description=macro_delete_description) + macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description) macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") macro_delete_parser.add_argument( 'names', @@ -3448,7 +3459,7 @@ class Cmd(cmd.Cmd): "Without arguments, all macros will be listed." ) - macro_list_parser = DEFAULT_ARGUMENT_PARSER(description=macro_list_description) + macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description) macro_list_parser.add_argument( 'names', nargs=argparse.ZERO_OR_MORE, @@ -3521,7 +3532,7 @@ class Cmd(cmd.Cmd): completer = ArgparseCompleter(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) - help_parser = DEFAULT_ARGUMENT_PARSER( + help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( description="List available commands or provide " "detailed help for a specific command" ) help_parser.add_argument( @@ -3777,7 +3788,7 @@ class Cmd(cmd.Cmd): self.poutput(table_str_buf.getvalue()) - shortcuts_parser = DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") + shortcuts_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") @with_argparser(shortcuts_parser) def do_shortcuts(self, _: argparse.Namespace) -> None: @@ -3787,7 +3798,9 @@ class Cmd(cmd.Cmd): result = "\n".join('{}: {}'.format(sc[0], sc[1]) for sc in sorted_shortcuts) self.poutput(f"Shortcuts for other commands:\n{result}") - eof_parser = DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG) + eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG + ) @with_argparser(eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: @@ -3800,7 +3813,7 @@ class Cmd(cmd.Cmd): # noinspection PyTypeChecker return self.do_quit('') - quit_parser = DEFAULT_ARGUMENT_PARSER(description="Exit this application") + quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application") @with_argparser(quit_parser) def do_quit(self, _: argparse.Namespace) -> Optional[bool]: @@ -3868,7 +3881,7 @@ class Cmd(cmd.Cmd): raise CompletionError(param + " is not a settable parameter") # Create a parser with a value field based on this settable - settable_parser = DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent]) + settable_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent]) # 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. @@ -3899,7 +3912,7 @@ class Cmd(cmd.Cmd): "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 = DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False) + set_parser_parent = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False) set_parser_parent.add_argument( 'param', nargs=argparse.OPTIONAL, @@ -3909,7 +3922,7 @@ class Cmd(cmd.Cmd): ) # Create the parser for the set command - set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) + 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 ) @@ -3967,7 +3980,7 @@ class Cmd(cmd.Cmd): row_data = [param, settable.get_value(), settable.description] self.poutput(table.generate_data_row(row_data)) - shell_parser = DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") + 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 @@ -4274,7 +4287,7 @@ class Cmd(cmd.Cmd): return py_bridge.stop - py_parser = DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") + py_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") @with_argparser(py_parser) def do_py(self, _: argparse.Namespace) -> Optional[bool]: @@ -4284,7 +4297,7 @@ class Cmd(cmd.Cmd): """ return self._run_python() - run_pyscript_parser = DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console") + 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 @@ -4321,7 +4334,7 @@ class Cmd(cmd.Cmd): return py_return - ipython_parser = DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") + ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") # noinspection PyPackageRequirements @with_argparser(ipython_parser) @@ -4391,7 +4404,7 @@ class Cmd(cmd.Cmd): history_description = "View, run, edit, save, or clear previously entered commands" - history_parser = DEFAULT_ARGUMENT_PARSER(description=history_description) + 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') @@ -4728,7 +4741,7 @@ class Cmd(cmd.Cmd): " set editor (program-name)" ) - edit_parser = DEFAULT_ARGUMENT_PARSER(description=edit_description) + 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 ) @@ -4773,7 +4786,7 @@ class Cmd(cmd.Cmd): "the output of the script commands to a transcript for testing purposes.\n" ) - run_script_parser = DEFAULT_ARGUMENT_PARSER(description=run_script_description) + run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) run_script_parser.add_argument( '-t', '--transcript', @@ -4842,7 +4855,7 @@ class Cmd(cmd.Cmd): relative_run_script_epilog = "Notes:\n" " This command is intended to only be used within text file scripts." - relative_run_script_parser = DEFAULT_ARGUMENT_PARSER( + 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') diff --git a/cmd2/constants.py b/cmd2/constants.py index 9f29be86..7656ae58 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -43,6 +43,7 @@ CLASS_ATTR_DEFAULT_HELP_CATEGORY = 'cmd2_default_help_category' # The argparse parser for the command CMD_ATTR_ARGPARSER = 'argparser' +CMD_ATTR_COMPLETER = 'command_completer' # Whether or not tokens are unquoted before sending to argparse CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 1ff0bdbe..644a8add 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -10,12 +10,16 @@ from typing import ( Optional, Sequence, Tuple, + Type, Union, ) from . import ( constants, ) +from .argparse_completer import ( + ArgparseCompleter, +) from .argparse_custom import ( Cmd2AttributeWrapper, ) @@ -271,6 +275,7 @@ def with_argparser( ns_provider: Optional[Callable[..., argparse.Namespace]] = None, preserve_quotes: bool = False, with_unknown_args: bool = False, + completer: Optional[Type[ArgparseCompleter]] = None, ) -> Callable[[ArgparseCommandFunc], RawCommandFuncOptionalBoolReturn]: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser. @@ -281,6 +286,7 @@ def with_argparser( state data that affects parsing. :param preserve_quotes: if ``True``, then arguments passed to argparse maintain their quotes :param with_unknown_args: if true, then capture unknown args + :param completer: CommandCompleter type. Defaults to ArgparseCompleter if unspecified. :return: function that gets passed argparse-parsed args in a ``Namespace`` A :class:`cmd2.argparse_custom.Cmd2AttributeWrapper` called ``cmd2_statement`` is included in the ``Namespace`` to provide access to the :class:`cmd2.Statement` object that was created when @@ -391,6 +397,7 @@ def with_argparser( # Set some custom attributes for this command setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser) + setattr(cmd_wrapper, constants.CMD_ATTR_COMPLETER, completer) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) return cmd_wrapper diff --git a/examples/custom_parser.py b/examples/custom_parser.py index 6df68bfa..ea66e7e1 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -31,6 +31,8 @@ class CustomParser(Cmd2ArgumentParser): linum += 1 self.print_usage(sys.stderr) + + # Format errors with style_warning() formatted_message = ansi.style_warning(formatted_message) self.exit(2, '{}\n\n'.format(formatted_message)) diff --git a/examples/override_parser.py b/examples/override_parser.py index 00161ef6..2e778c07 100755 --- a/examples/override_parser.py +++ b/examples/override_parser.py @@ -6,20 +6,19 @@ 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 +# 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() # with the custom parser's type. import argparse -# Next import stuff from cmd2. It will import your module just before the cmd2.Cmd class file is imported +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, ) -argparse.cmd2_parser_module = 'examples.custom_parser' - - if __name__ == '__main__': import sys @@ -83,6 +83,7 @@ EXTRAS_REQUIRE = { 'validate': [ 'flake8', 'mypy==0.902', + 'types-pkg-resources', ], } diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 25a13157..9c816e5e 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -16,6 +16,8 @@ from cmd2 import ( Cmd2ArgumentParser, CompletionError, CompletionItem, + argparse_completer, + argparse_custom, with_argparser, ) from cmd2.utils import ( @@ -1152,3 +1154,54 @@ def test_complete_standalone(ac_app, flag, completions): first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + + +class CustomCompleter(argparse_completer.ArgparseCompleter): + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: List[str]) -> List[str]: + """Override so arguments with 'always_complete' set to True will always be completed""" + for flag in matched_flags: + action = self._flag_to_action[flag] + if action.get_always_complete() is True: + matched_flags.remove(flag) + return super(CustomCompleter, self)._complete_flags(text, line, begidx, endidx, matched_flags) + + +argparse_custom.register_argparse_argument_parameter('always_complete', bool) + + +class CustomCompleterApp(cmd2.Cmd): + _parser = Cmd2ArgumentParser(description="Testing manually wrapping") + _parser.add_argument('--myflag', always_complete=True, nargs=1) + + @with_argparser(_parser) + def do_mycommand(self, cmd: 'CustomCompleterApp', args: argparse.Namespace) -> None: + """Test command that will be manually wrapped to use argparse""" + print(args) + + +@pytest.fixture +def custom_completer_app(): + + argparse_completer.set_default_command_completer_type(CustomCompleter) + app = CustomCompleterApp() + app.stdout = StdSim(app.stdout) + yield app + argparse_completer.set_default_command_completer_type(argparse_completer.ArgparseCompleter) + + +@pytest.mark.parametrize( + 'command_and_args, text, output_contains, first_match', + [ + ('mycommand', '--my', '', '--myflag '), + ('mycommand --myflag 5', '--my', '', '--myflag '), + ], +) +def test_custom_completer_type(custom_completer_app, command_and_args, text, output_contains, first_match, capsys): + line = '{} {}'.format(command_and_args, text) + endidx = len(line) + begidx = endidx - len(text) + + assert first_match == complete_tester(text, line, begidx, endidx, custom_completer_app) + + out, err = capsys.readouterr() + assert output_contains in out diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index dcfb7d9f..fccccd43 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -247,28 +247,26 @@ def test_apcustom_required_options(): def test_override_parser(): + """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER""" import importlib from cmd2 import ( - DEFAULT_ARGUMENT_PARSER, + argparse_custom, ) # The standard parser is Cmd2ArgumentParser - assert DEFAULT_ARGUMENT_PARSER == 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) - from cmd2 import ( - DEFAULT_ARGUMENT_PARSER, - ) # Verify DEFAULT_ARGUMENT_PARSER is now our CustomParser from examples.custom_parser import ( CustomParser, ) - assert DEFAULT_ARGUMENT_PARSER == CustomParser + assert argparse_custom.DEFAULT_ARGUMENT_PARSER == CustomParser def test_apcustom_metavar_tuple(): |