Skip to content

Commit

Permalink
Refactored custom ArgparseCompleter functionality so they will now be…
Browse files Browse the repository at this point in the history
… set using methods on ArgumentParser objects.

This fixes issue where subcommands did not use the correct custom ArgparseCompleter type.
  • Loading branch information
kmvanbrunt committed Sep 1, 2021
1 parent 30b30cd commit bf558c5
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 61 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* Bug Fixes
* Fixed extra space appended to each alias by "alias list" command
* Enhancements
* New function `set_default_command_completer_type()` allows developer to extend and modify the
behavior of `ArgparseCompleter`.
* New function `set_default_ap_completer_type()` allows developer to extend and modify the
behavior of `ArgparseCompleter`.
* Added `ArgumentParser.get_ap_completer_type()` and `ArgumentParser.set_ap_completer_type()`. These
methods allow developers to enable custom tab completion behavior for a given parser by using a custom
`ArgparseCompleter`-based class.
* 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.
Expand Down
8 changes: 4 additions & 4 deletions cmd2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
Cmd2AttributeWrapper,
CompletionItem,
register_argparse_argument_parameter,
set_default_argument_parser,
set_default_argument_parser_type,
)

# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER.
Expand All @@ -38,7 +38,7 @@

importlib.import_module(cmd2_parser_module)

from .argparse_completer import set_default_command_completer_type
from .argparse_completer import set_default_ap_completer_type

from .cmd2 import Cmd
from .command_definition import CommandSet, with_default_category
Expand All @@ -63,8 +63,8 @@
'Cmd2AttributeWrapper',
'CompletionItem',
'register_argparse_argument_parameter',
'set_default_argument_parser',
'set_default_command_completer_type',
'set_default_argument_parser_type',
'set_default_ap_completer_type',
# Cmd2
'Cmd',
'CommandResult',
Expand Down
41 changes: 31 additions & 10 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,9 +407,15 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
if action.dest != argparse.SUPPRESS:
parent_tokens[action.dest] = [token]

completer = ArgparseCompleter(
self._subcommand_action.choices[token], self._cmd2_app, parent_tokens=parent_tokens
)
parser: argparse.ArgumentParser = self._subcommand_action.choices[token]
completer_type: Optional[
Type[ArgparseCompleter]
] = parser.get_ap_completer_type() # type: ignore[attr-defined]
if completer_type is None:
completer_type = DEFAULT_AP_COMPLETER

completer = completer_type(parser, self._cmd2_app, parent_tokens=parent_tokens)

return completer.complete(
text, line, begidx, endidx, tokens[token_index + 1 :], cmd_set=cmd_set
)
Expand Down Expand Up @@ -609,7 +615,14 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in
if self._subcommand_action is not None:
for token_index, token in enumerate(tokens):
if token in self._subcommand_action.choices:
completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app)
parser: argparse.ArgumentParser = self._subcommand_action.choices[token]
completer_type: Optional[
Type[ArgparseCompleter]
] = parser.get_ap_completer_type() # type: ignore[attr-defined]
if completer_type is None:
completer_type = DEFAULT_AP_COMPLETER

completer = completer_type(parser, self._cmd2_app)
return completer.complete_subcommand_help(text, line, begidx, endidx, tokens[token_index + 1 :])
elif token_index == len(tokens) - 1:
# Since this is the last token, we will attempt to complete it
Expand All @@ -629,7 +642,14 @@ def format_help(self, tokens: List[str]) -> str:
if self._subcommand_action is not None:
for token_index, token in enumerate(tokens):
if token in self._subcommand_action.choices:
completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app)
parser: argparse.ArgumentParser = self._subcommand_action.choices[token]
completer_type: Optional[
Type[ArgparseCompleter]
] = parser.get_ap_completer_type() # type: ignore[attr-defined]
if completer_type is None:
completer_type = DEFAULT_AP_COMPLETER

completer = completer_type(parser, self._cmd2_app)
return completer.format_help(tokens[token_index + 1 :])
else:
break
Expand Down Expand Up @@ -740,14 +760,15 @@ def _complete_arg(
return self._format_completions(arg_state, results)


DEFAULT_COMMAND_COMPLETER: Type[ArgparseCompleter] = ArgparseCompleter
# The default ArgparseCompleter class for a cmd2 app
DEFAULT_AP_COMPLETER: Type[ArgparseCompleter] = ArgparseCompleter


def set_default_command_completer_type(completer_type: Type[ArgparseCompleter]) -> None:
def set_default_ap_completer_type(completer_type: Type[ArgparseCompleter]) -> None:
"""
Set the default command completer type. It must be a sub-class of the ArgparseCompleter.
Set the default ArgparseCompleter class for a cmd2 app.
:param completer_type: Type that is a subclass of ArgparseCompleter.
"""
global DEFAULT_COMMAND_COMPLETER
DEFAULT_COMMAND_COMPLETER = completer_type
global DEFAULT_AP_COMPLETER
DEFAULT_AP_COMPLETER = completer_type
68 changes: 65 additions & 3 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
- ``argparse.Action.set_suppress_tab_hint()`` - See
:func:`_action_set_suppress_tab_hint` for more details.
cmd2 has patched ``argparse.ArgumentParser`` to include the following accessor methods
- ``argparse.ArgumentParser.get_ap_completer_type()`` - See
:func:`_ArgumentParser_get_ap_completer_type` for more details.
- ``argparse.Action.set_ap_completer_type()`` - See
:func:`_ArgumentParser_set_ap_completer_type` for more details.
**Subcommand removal**
cmd2 has patched ``argparse._SubParsersAction`` to include a ``remove_parser()``
Expand All @@ -232,6 +239,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
)
from typing import (
IO,
TYPE_CHECKING,
Any,
Callable,
Dict,
Expand Down Expand Up @@ -264,6 +272,12 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
)


if TYPE_CHECKING: # pragma: no cover
from .argparse_completer import (
ArgparseCompleter,
)


def generate_range_error(range_min: int, range_max: Union[int, float]) -> str:
"""Generate an error message when the the number of arguments provided is not within the expected range"""
err_str = "expected "
Expand Down Expand Up @@ -659,6 +673,7 @@ def register_argparse_argument_parameter(param_name: str, param_type: Optional[T
and ``set_{param_name}(value)``.
:param param_name: Name of the parameter to add.
:param param_type: Type 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):
Expand Down Expand Up @@ -715,6 +730,7 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None:
orig_actions_container_add_argument = argparse._ActionsContainer.add_argument


# noinspection PyProtectedMember
def _add_argument_wrapper(
self: argparse._ActionsContainer,
*args: Any,
Expand Down Expand Up @@ -916,10 +932,54 @@ def _match_argument_wrapper(self: argparse.ArgumentParser, action: argparse.Acti


############################################################################################################
# Patch argparse._SubParsersAction to add remove_parser function
# Patch argparse.ArgumentParser with accessors for ap_completer_type attribute
############################################################################################################

# An ArgumentParser attribute which specifies a subclass of ArgparseCompleter for custom tab completion behavior on a
# given parser. If this is None or not present, then cmd2 will use argparse_completer.DEFAULT_AP_COMPLETER when tab
# completing a parser's arguments
ATTR_AP_COMPLETER_TYPE = 'ap_completer_type'


# noinspection PyPep8Naming
def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> Optional[Type['ArgparseCompleter']]:
"""
Get the ap_completer_type attribute of an argparse ArgumentParser.
This function is added by cmd2 as a method called ``get_ap_completer_type()`` to ``argparse.ArgumentParser`` class.
To call: ``parser.get_ap_completer_type()``
:param self: ArgumentParser being queried
:return: An ArgparseCompleter-based class or None if attribute does not exist
"""
return cast(Optional[Type['ArgparseCompleter']], getattr(self, ATTR_AP_COMPLETER_TYPE, None))


setattr(argparse.ArgumentParser, 'get_ap_completer_type', _ArgumentParser_get_ap_completer_type)


# noinspection PyPep8Naming
def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_completer_type: Type['ArgparseCompleter']) -> None:
"""
Set the ap_completer_type attribute of an argparse ArgumentParser.
This function is added by cmd2 as a method called ``set_ap_completer_type()`` to ``argparse.ArgumentParser`` class.
:param self: ArgumentParser being edited
:param ap_completer_type: the custom ArgparseCompleter-based class to use when tab completing arguments for this parser
"""
setattr(self, ATTR_AP_COMPLETER_TYPE, ap_completer_type)


setattr(argparse.ArgumentParser, 'set_ap_completer_type', _ArgumentParser_set_ap_completer_type)


############################################################################################################
# Patch argparse._SubParsersAction to add remove_parser function
############################################################################################################

# noinspection PyPep8Naming,PyProtectedMember
def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) -> None:
"""
Removes a sub-parser from a sub-parsers group. Used to remove subcommands from a parser.
Expand Down Expand Up @@ -964,6 +1024,7 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str)
class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
"""Custom help formatter to configure ordering of help text"""

# noinspection PyProtectedMember
def _format_usage(
self,
usage: Optional[str],
Expand Down Expand Up @@ -1207,6 +1268,7 @@ def __init__(
allow_abbrev=allow_abbrev,
)

# noinspection PyProtectedMember
def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction:
"""
Custom override. Sets a default title if one was not given.
Expand Down Expand Up @@ -1321,10 +1383,10 @@ def set(self, new_val: Any) -> None:
DEFAULT_ARGUMENT_PARSER: Type[argparse.ArgumentParser] = Cmd2ArgumentParser


def set_default_argument_parser(parser: Type[argparse.ArgumentParser]) -> None:
def set_default_argument_parser_type(parser_type: Type[argparse.ArgumentParser]) -> 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.
"""
global DEFAULT_ARGUMENT_PARSER
DEFAULT_ARGUMENT_PARSER = parser
DEFAULT_ARGUMENT_PARSER = parser_type
50 changes: 21 additions & 29 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,9 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) ->
defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method}
attached_parser.set_defaults(**defaults)

# Copy value for custom ArgparseCompleter type, which will be None if not present on subcmd_parser
attached_parser.set_ap_completer_type(subcmd_parser.get_ap_completer_type()) # type: ignore[attr-defined]

# Set what instance the handler is bound to
setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset)
break
Expand Down Expand Up @@ -1850,10 +1853,6 @@ def _perform_completion(
:param endidx: the ending index of the prefix text
:param custom_settings: optional prepopulated completion settings
"""
from .argparse_completer import (
ArgparseCompleter,
)

# If custom_settings is None, then we are completing a command's argument.
# Parse the command line to get the command token.
command = ''
Expand Down Expand Up @@ -1903,18 +1902,18 @@ def _perform_completion(
else:
# 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
argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)

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
if completer_type is not None:
completer = completer_type(argparser, self)
else:
completer = ArgparseCompleter(argparser, self)
# Get arguments for complete()
preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)
cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None

# Create the argparse completer
completer_type = argparser.get_ap_completer_type() # type: ignore[attr-defined]
if completer_type is None:
completer_type = argparse_completer.DEFAULT_AP_COMPLETER
completer = completer_type(argparser, self)

completer_func = functools.partial(
completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set
Expand All @@ -1932,7 +1931,12 @@ def _perform_completion(

# Otherwise we are completing the command token or performing custom completion
else:
completer = ArgparseCompleter(custom_settings.parser, self)
# Create the argparse completer
completer_type = custom_settings.parser.get_ap_completer_type() # type: ignore[attr-defined]
if completer_type is None:
completer_type = argparse_completer.DEFAULT_AP_COMPLETER
completer = completer_type(custom_settings.parser, self)

completer_func = functools.partial(
completer.complete, tokens=raw_tokens if custom_settings.preserve_quotes else tokens, cmd_set=None
)
Expand Down Expand Up @@ -3542,11 +3546,7 @@ def complete_help_subcommands(
if func is None or argparser is None:
return []

from .argparse_completer import (
ArgparseCompleter,
)

completer = ArgparseCompleter(argparser, self)
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(
Expand Down Expand Up @@ -3582,11 +3582,7 @@ def do_help(self, args: argparse.Namespace) -> None:

# If the command function uses argparse, then use argparse's help
if func is not None and argparser is not None:
from .argparse_completer import (
ArgparseCompleter,
)

completer = ArgparseCompleter(argparser, self)
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)

# Set end to blank so the help output matches how it looks when "command -h" is used
self.poutput(completer.format_help(args.subcommands), end='')
Expand Down Expand Up @@ -3918,11 +3914,7 @@ def complete_set_value(
completer=settable.completer,
)

from .argparse_completer import (
ArgparseCompleter,
)

completer = ArgparseCompleter(settable_parser, self)
completer = argparse_completer.DEFAULT_AP_COMPLETER(settable_parser, self)

# Use raw_tokens since quotes have been preserved
_, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
Expand Down
1 change: 0 additions & 1 deletion cmd2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@

# 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'
Expand Down
Loading

0 comments on commit bf558c5

Please sign in to comment.