diff options
Diffstat (limited to 'cmd2')
-rw-r--r-- | cmd2/__init__.py | 6 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 257 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 38 | ||||
-rw-r--r-- | cmd2/cmd2.py | 154 | ||||
-rw-r--r-- | cmd2/decorators.py | 2 | ||||
-rw-r--r-- | cmd2/exceptions.py | 12 | ||||
-rw-r--r-- | cmd2/utils.py | 44 |
7 files changed, 260 insertions, 253 deletions
diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 27a0e96e..eb5c275d 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,7 +11,7 @@ except DistributionNotFound: pass from .ansi import style, fg, bg -from .argparse_custom import Cmd2ArgumentParser, CompletionError, CompletionItem, set_default_argument_parser +from .argparse_custom import Cmd2ArgumentParser, CompletionItem, set_default_argument_parser # Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER import argparse @@ -22,9 +22,9 @@ if cmd2_parser_module is not None: # Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER from .argparse_custom import DEFAULT_ARGUMENT_PARSER -from .cmd2 import Cmd, EmptyStatement +from .cmd2 import Cmd from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category from .parsing import Statement from .py_bridge import CommandResult -from .utils import Settable, categorize +from .utils import categorize, CompletionError, Settable diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 185e01a2..707b36ba 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -2,7 +2,7 @@ # flake8: noqa C901 # NOTE: Ignoring flake8 cyclomatic complexity in this file """ -This module defines the AutoCompleter class which provides argparse-based tab completion to cmd2 apps. +This module defines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. See the header of argparse_custom.py for instructions on how to use these features. """ @@ -10,17 +10,15 @@ import argparse import inspect import numbers import shutil -import textwrap from collections import deque from typing import Dict, List, Optional, Union from . import ansi from . import cmd2 -from . import utils from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE -from .argparse_custom import ChoicesCallable, CompletionError, CompletionItem -from .rl_utils import rl_force_redisplay +from .argparse_custom import ChoicesCallable, CompletionItem +from .utils import basic_complete, CompletionError # If no descriptive header is supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADER = 'Description' @@ -63,52 +61,89 @@ def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: return True +class _ArgumentState: + """Keeps state of an argument being parsed""" + def __init__(self, arg_action: argparse.Action) -> None: + self.action = arg_action + self.min = None + self.max = None + self.count = 0 + self.is_remainder = (self.action.nargs == argparse.REMAINDER) + + # Check if nargs is a range + nargs_range = getattr(self.action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + self.min = nargs_range[0] + self.max = nargs_range[1] + + # Otherwise check against argparse types + elif self.action.nargs is None: + self.min = 1 + self.max = 1 + elif self.action.nargs == argparse.OPTIONAL: + self.min = 0 + self.max = 1 + elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: + self.min = 0 + self.max = INFINITY + elif self.action.nargs == argparse.ONE_OR_MORE: + self.min = 1 + self.max = INFINITY + else: + self.min = self.action.nargs + self.max = self.action.nargs + + # noinspection PyProtectedMember -class AutoCompleter: - """Automatic command line tab completion based on argparse parameters""" +class _UnfinishedFlagError(CompletionError): + def __init__(self, flag_arg_state: _ArgumentState) -> None: + """ + CompletionError which occurs when the user has not finished the current flag + :param flag_arg_state: information about the unfinished flag action + """ + error = "Error: argument {}: {} ({} entered)".\ + format(argparse._get_action_name(flag_arg_state.action), + generate_range_error(flag_arg_state.min, flag_arg_state.max), + flag_arg_state.count) + super().__init__(error) - class _ArgumentState: - """Keeps state of an argument being parsed""" - - def __init__(self, arg_action: argparse.Action) -> None: - self.action = arg_action - self.min = None - self.max = None - self.count = 0 - self.is_remainder = (self.action.nargs == argparse.REMAINDER) - - # Check if nargs is a range - nargs_range = getattr(self.action, ATTR_NARGS_RANGE, None) - if nargs_range is not None: - self.min = nargs_range[0] - self.max = nargs_range[1] - - # Otherwise check against argparse types - elif self.action.nargs is None: - self.min = 1 - self.max = 1 - elif self.action.nargs == argparse.OPTIONAL: - self.min = 0 - self.max = 1 - elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: - self.min = 0 - self.max = INFINITY - elif self.action.nargs == argparse.ONE_OR_MORE: - self.min = 1 - self.max = INFINITY - else: - self.min = self.action.nargs - self.max = self.action.nargs +# noinspection PyProtectedMember +class _NoResultsError(CompletionError): + def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: + """ + CompletionError which occurs when there are no results. If hinting is allowed, then its message will + be a hint about the argument being tab completed. + :param parser: ArgumentParser instance which owns the action being tab completed + :param arg_action: action being tab completed + """ + # Check if hinting is disabled + suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) + if suppress_hint or arg_action.help == argparse.SUPPRESS: + hint_str = '' + else: + # Use the parser's help formatter to print just this action's help text + formatter = parser._get_formatter() + formatter.start_section("Hint") + formatter.add_argument(arg_action) + formatter.end_section() + hint_str = formatter.format_help() + # Set apply_style to False because we don't want hints to look like errors + super().__init__(hint_str, apply_style=False) + + +# noinspection PyProtectedMember +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) -> None: """ - Create an AutoCompleter + Create an ArgparseCompleter :param parser: ArgumentParser instance - :param cmd2_app: reference to the Cmd2 application that owns this AutoCompleter + :param cmd2_app: reference to the Cmd2 application that owns this ArgparseCompleter :param parent_tokens: optional dictionary mapping parent parsers' arg names to their tokens - this is only used by AutoCompleter when recursing on subcommand parsers + This is only used by ArgparseCompleter when recursing on subcommand parsers Defaults to None """ self._parser = parser @@ -141,7 +176,10 @@ class AutoCompleter: self._subcommand_action = action def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Complete the command using the argparse metadata and provided argument dictionary""" + """ + Complete the command using the argparse metadata and provided argument dictionary + :raises: CompletionError for various types of tab completion errors + """ if not tokens: return [] @@ -167,18 +205,18 @@ class AutoCompleter: # Completed mutually exclusive groups completed_mutex_groups = dict() # dict(argparse._MutuallyExclusiveGroup -> Action which completed group) - def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: + def consume_argument(arg_state: _ArgumentState) -> None: """Consuming token as an argument""" arg_state.count += 1 consumed_arg_values.setdefault(arg_state.action.dest, []) consumed_arg_values[arg_state.action.dest].append(token) - def update_mutex_groups(arg_action: argparse.Action) -> bool: + def update_mutex_groups(arg_action: argparse.Action) -> None: """ Check if an argument belongs to a mutually exclusive group and either mark that group as complete or print an error if the group has already been completed :param arg_action: the action of the argument - :return: False if the group has already been completed and there is a conflict, otherwise True + :raises: CompletionError if the group is already completed """ # Check if this action is in a mutually exclusive group for group in self._parser._mutually_exclusive_groups: @@ -191,13 +229,12 @@ class AutoCompleter: # since it's allowed to appear on the command line more than once. completer_action = completed_mutex_groups[group] if arg_action == completer_action: - return True + return - error = ansi.style_error("\nError: argument {}: not allowed with argument {}\n". - format(argparse._get_action_name(arg_action), - argparse._get_action_name(completer_action))) - self._print_message(error) - return False + error = ("Error: argument {}: not allowed with argument {}". + format(argparse._get_action_name(arg_action), + argparse._get_action_name(completer_action))) + raise CompletionError(error) # Mark that this action completed the group completed_mutex_groups[group] = arg_action @@ -214,8 +251,6 @@ class AutoCompleter: # Arg can only be in one group, so we are done break - return True - ############################################################################################# # Parse all but the last token ############################################################################################# @@ -238,8 +273,7 @@ class AutoCompleter: elif token == '--' and not skip_remaining_flags: # Check if there is an unfinished flag if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] + raise _UnfinishedFlagError(flag_arg_state) # Otherwise end the current flag else: @@ -252,8 +286,7 @@ class AutoCompleter: # Check if there is an unfinished flag if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] + raise _UnfinishedFlagError(flag_arg_state) # Reset flag arg state but not positional tracking because flags can be # interspersed anywhere between positionals @@ -269,9 +302,7 @@ class AutoCompleter: action = self._flag_to_action[candidates_flags[0]] if action is not None: - if not update_mutex_groups(action): - return [] - + update_mutex_groups(action) if isinstance(action, (argparse._AppendAction, argparse._AppendConstAction, argparse._CountAction)): @@ -286,7 +317,7 @@ class AutoCompleter: # earlier in the command line. Reset them now for this use of it. consumed_arg_values[action.dest] = [] - new_arg_state = AutoCompleter._ArgumentState(action) + new_arg_state = _ArgumentState(action) # Keep track of this flag if it can receive arguments if new_arg_state.max > 0: @@ -319,8 +350,8 @@ class AutoCompleter: if action.dest != argparse.SUPPRESS: parent_tokens[action.dest] = [token] - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app, - parent_tokens=parent_tokens) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, + parent_tokens=parent_tokens) return completer.complete_command(tokens[token_index:], text, line, begidx, endidx) else: # Invalid subcommand entered, so no way to complete remaining tokens @@ -328,14 +359,11 @@ class AutoCompleter: # Otherwise keep track of the argument else: - pos_arg_state = AutoCompleter._ArgumentState(action) + pos_arg_state = _ArgumentState(action) # Check if we have a positional to consume this token if pos_arg_state is not None: - # No need to check for an error since we remove a completed group's positional from - # remaining_positionals which means this action can't belong to a completed mutex group update_mutex_groups(pos_arg_state.action) - consume_argument(pos_arg_state) # No more flags are allowed if this is a REMAINDER argument @@ -361,21 +389,15 @@ class AutoCompleter: # character (-f) at the end. if _looks_like_flag(text, self._parser) and not skip_remaining_flags: if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] - + raise _UnfinishedFlagError(flag_arg_state) return self._complete_flags(text, line, begidx, endidx, matched_flags) completion_results = [] # Check if we are completing a flag's argument if flag_arg_state is not None: - try: - completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) - except CompletionError as ex: - self._print_completion_error(flag_arg_state.action, ex) - return [] + completion_results = self._complete_for_arg(flag_arg_state.action, text, line, + begidx, endidx, consumed_arg_values) # If we have results, then return them if completion_results: @@ -384,8 +406,7 @@ class AutoCompleter: # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag elif flag_arg_state.count < flag_arg_state.min or \ not _single_prefix_char(text, self._parser) or skip_remaining_flags: - self._print_arg_hint(flag_arg_state.action) - return [] + raise _NoResultsError(self._parser, flag_arg_state.action) # Otherwise check if we have a positional to complete elif pos_arg_state is not None or remaining_positionals: @@ -393,14 +414,10 @@ class AutoCompleter: # If we aren't current tracking a positional, then get the next positional arg to handle this token if pos_arg_state is None: action = remaining_positionals.popleft() - pos_arg_state = AutoCompleter._ArgumentState(action) + pos_arg_state = _ArgumentState(action) - try: - completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) - except CompletionError as ex: - self._print_completion_error(pos_arg_state.action, ex) - return [] + completion_results = self._complete_for_arg(pos_arg_state.action, text, line, + begidx, endidx, consumed_arg_values) # If we have results, then return them if completion_results: @@ -408,8 +425,7 @@ class AutoCompleter: # Otherwise, print a hint if text isn't possibly the start of a flag elif not _single_prefix_char(text, self._parser) or skip_remaining_flags: - self._print_arg_hint(pos_arg_state.action) - return [] + raise _NoResultsError(self._parser, pos_arg_state.action) # Handle case in which text is a single flag prefix character that # didn't complete against any argument values. @@ -432,7 +448,7 @@ class AutoCompleter: if action.help != argparse.SUPPRESS: match_against.append(flag) - return utils.basic_complete(text, line, begidx, endidx, match_against) + return basic_complete(text, line, begidx, endidx, match_against) def _format_completions(self, action, completions: List[Union[str, CompletionItem]]) -> List[str]: # Check if the results are CompletionItems and that there aren't too many to display @@ -483,15 +499,15 @@ class AutoCompleter: :return: List of subcommand completions """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands - # If so, we will let the subcommand's parser handle the rest of the tokens via another AutoCompleter. + # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: for token_index, token in enumerate(tokens[1:], start=1): if token in self._subcommand_action.choices: - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) return completer.complete_subcommand_help(tokens[token_index:], text, line, begidx, endidx) elif token_index == len(tokens) - 1: # Since this is the last token, we will attempt to complete it - return utils.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) + return basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) else: break return [] @@ -503,11 +519,11 @@ class AutoCompleter: :return: help text of the command being queried """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands - # If so, we will let the subcommand's parser handle the rest of the tokens via another AutoCompleter. + # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: for token_index, token in enumerate(tokens[1:], start=1): if token in self._subcommand_action.choices: - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) return completer.format_help(tokens[token_index:]) else: break @@ -519,7 +535,7 @@ class AutoCompleter: """ Tab completion routine for an argparse argument :return: list of completions - :raises CompletionError if the completer or choices function this calls raises one + :raises: CompletionError if the completer or choices function this calls raises one """ # Check if the arg provides choices to the user if arg_action.choices is not None: @@ -579,55 +595,6 @@ class AutoCompleter: arg_choices = [choice for choice in arg_choices if choice not in used_values] # Do tab completion on the choices - results = utils.basic_complete(text, line, begidx, endidx, arg_choices) + results = basic_complete(text, line, begidx, endidx, arg_choices) return self._format_completions(arg_action, results) - - @staticmethod - def _print_message(msg: str) -> None: - """Print a message instead of tab completions and redraw the prompt and input line""" - import sys - ansi.style_aware_write(sys.stdout, msg + '\n') - rl_force_redisplay() - - def _print_arg_hint(self, arg_action: argparse.Action) -> None: - """ - Print argument hint to the terminal when tab completion results in no results - :param arg_action: action being tab completed - """ - # Check if hinting is disabled - suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) - if suppress_hint or arg_action.help == argparse.SUPPRESS: - return - - # Use the parser's help formatter to print just this action's help text - formatter = self._parser._get_formatter() - formatter.start_section("Hint") - formatter.add_argument(arg_action) - formatter.end_section() - out_str = formatter.format_help() - self._print_message('\n' + out_str) - - def _print_unfinished_flag_error(self, flag_arg_state: _ArgumentState) -> None: - """ - Print an error during tab completion when the user has not finished the current flag - :param flag_arg_state: information about the unfinished flag action - """ - error = "\nError: argument {}: {} ({} entered)\n".\ - format(argparse._get_action_name(flag_arg_state.action), - generate_range_error(flag_arg_state.min, flag_arg_state.max), - flag_arg_state.count) - self._print_message(ansi.style_error('{}'.format(error))) - - def _print_completion_error(self, arg_action: argparse.Action, completion_error: CompletionError) -> None: - """ - Print a CompletionError to the user - :param arg_action: action being tab completed - :param completion_error: error that occurred - """ - # Indent all lines of completion_error - indented_error = textwrap.indent(str(completion_error), ' ') - - error = ("\nError tab completing {}:\n" - "{}\n".format(argparse._get_action_name(arg_action), indented_error)) - self._print_message(ansi.style_error('{}'.format(error))) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index a59270c3..81fec013 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -26,7 +26,7 @@ value with no upper bound, use a 1-item tuple (min,) parser.add_argument('-f', nargs=(3, 5)) Tab Completion: - cmd2 uses its AutoCompleter class to enable argparse-based tab completion on all commands that use the + cmd2 uses its ArgparseCompleter class to enable argparse-based tab completion on all commands that use the @with_argparse wrappers. Out of the box you get tab completion of commands, subcommands, and flag names, as well as instructive hints about the current argument that print when tab is pressed. In addition, you can add tab completion for each argument's values using parameters passed to add_argument(). @@ -53,7 +53,7 @@ Tab Completion: choices_method This is exactly like choices_function, but the function needs to be an instance method of a cmd2-based class. - When AutoCompleter calls the method, it will pass the app instance as the self argument. This is good in + When ArgparseCompleter calls the method, it will pass the app instance as the self argument. This is good in cases where the list of choices being generated relies on state data of the cmd2-based app Example: @@ -74,7 +74,7 @@ Tab Completion: completer_method This is exactly like completer_function, but the function needs to be an instance method of a cmd2-based class. - When AutoCompleter calls the method, it will pass the app instance as the self argument. cmd2 provides + When ArgparseCompleter calls the method, it will pass the app instance as the self argument. cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) Example: @@ -113,21 +113,14 @@ Tab Completion: def my_completer_method(self, text, line, begidx, endidx, arg_tokens) All values of the arg_tokens dictionary are lists, even if a particular argument expects only 1 token. Since - AutoCompleter is for tab completion, it does not convert the tokens to their actual argument types or validate + ArgparseCompleter is for tab completion, it does not convert the tokens to their actual argument types or validate their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to the developer to determine if the user entered the correct argument type (e.g. int) and validate their values. -CompletionError Class: - Raised during tab completion operations to report any sort of error you want printed by the AutoCompleter - - Example use cases - - Reading a database to retrieve a tab completion data set failed - - A previous command line argument that determines the data set being completed is invalid - CompletionItem Class: This class was added to help in cases where uninformative data is being tab completed. For instance, tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems - instead of a regular string for completion results will signal the AutoCompleter to output the completion + instead of a regular string for completion results will signal the ArgparseCompleter to output the completion results in a table of completion tokens with descriptions instead of just a table of tokens. Instead of this: @@ -229,17 +222,6 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str: return err_str -class CompletionError(Exception): - """ - Raised during tab completion operations to report any sort of error you want printed by the AutoCompleter - - Example use cases - - Reading a database to retrieve a tab completion data set failed - - A previous command line argument that determines the data set being completed is invalid - """ - pass - - class CompletionItem(str): """ Completion item with descriptive text attached @@ -353,15 +335,15 @@ def _add_argument_wrapper(self, *args, :param nargs: extends argparse nargs functionality by allowing tuples which specify a range (min, max) to specify a max value with no upper bound, use a 1-item tuple (min,) - # Added args used by AutoCompleter + # Added args used by ArgparseCompleter :param choices_function: function that provides choices for this argument :param choices_method: cmd2-app method that provides choices for this argument :param completer_function: tab completion function that provides choices for this argument :param completer_method: cmd2-app tab completion method that provides choices for this argument - :param suppress_tab_hint: when AutoCompleter has no results to show during tab completion, it displays the current - argument's help text as a hint. Set this to True to suppress the hint. If this argument's - help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the - value passed for suppress_tab_hint. Defaults to False. + :param suppress_tab_hint: when ArgparseCompleter has no results to show during tab completion, it displays the + current argument's help text as a hint. Set this to True to suppress the hint. If this + argument's help text is set to argparse.SUPPRESS, then tab hints will not display + regardless of the value passed for suppress_tab_hint. Defaults to False. :param descriptive_header: if the provided choices are CompletionItems, then this header will display during tab completion. Defaults to None. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8f2cdca3..b314a683 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -47,13 +47,14 @@ from . import ansi from . import constants from . import plugin from . import utils -from .argparse_custom import CompletionError, CompletionItem, DEFAULT_ARGUMENT_PARSER +from .argparse_custom import CompletionItem, DEFAULT_ARGUMENT_PARSER from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .decorators import with_argparser +from .exceptions import EmbeddedConsoleExit, EmptyStatement from .history import History, HistoryItem from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt, rl_warning -from .utils import Settable +from .utils import CompletionError, Settable # Set up readline if rl_type == RlType.NONE: # pragma: no cover @@ -106,16 +107,6 @@ class _SavedCmd2Env: self.sys_stdin = None -class EmbeddedConsoleExit(SystemExit): - """Custom exception class for use with the py command.""" - pass - - -class EmptyStatement(Exception): - """Custom exception class for handling behavior when the user just presses <Enter>.""" - pass - - # Contains data about a disabled command which is used to restore its original functions when the command is enabled DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function']) @@ -1050,8 +1041,8 @@ class Cmd(cmd.Cmd): in_pipe = False in_file_redir = True - # Not a redirection token - else: + # Only tab complete after redirection tokens if redirection is allowed + elif self.allow_redirection: do_shell_completion = False do_path_completion = False @@ -1263,7 +1254,7 @@ class Cmd(cmd.Cmd): if func is not None and argparser is not None: import functools - compfunc = functools.partial(self._autocomplete_default, + compfunc = functools.partial(self._complete_argparse_command, argparser=argparser, preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)) else: @@ -1416,17 +1407,27 @@ class Cmd(cmd.Cmd): except IndexError: return None + except CompletionError as ex: + # Don't print error and redraw the prompt unless the error has length + err_str = str(ex) + if err_str: + if ex.apply_style: + err_str = ansi.style_error(err_str) + ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n') + rl_force_redisplay() + return None except Exception as e: # Insert a newline so the exception doesn't print in the middle of the command line being tab completed self.perror() self.pexcept(e) + rl_force_redisplay() return None - def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, *, - argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: - """Default completion function for argparse commands""" - from .argparse_completer import AutoCompleter - completer = AutoCompleter(argparser, self) + def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: int, *, + argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: + """Completion function for argparse commands""" + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(argparser, self) tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) # To have tab-completion parsing match command line parsing behavior, @@ -2560,11 +2561,11 @@ class Cmd(cmd.Cmd): if func is None or argparser is None: return [] - # Combine the command and its subcommand tokens for the AutoCompleter + # Combine the command and its subcommand tokens for the ArgparseCompleter tokens = [command] + arg_tokens['subcommands'] - from .argparse_completer import AutoCompleter - completer = AutoCompleter(argparser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(argparser, self) return completer.complete_subcommand_help(tokens, text, line, begidx, endidx) help_parser = DEFAULT_ARGUMENT_PARSER(description="List available commands or provide " @@ -2576,7 +2577,7 @@ class Cmd(cmd.Cmd): help_parser.add_argument('-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each") - # Get rid of cmd's complete_help() functions so AutoCompleter will complete the help command + # 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') @@ -2594,8 +2595,8 @@ class Cmd(cmd.Cmd): # 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 AutoCompleter - completer = AutoCompleter(argparser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(argparser, self) tokens = [args.command] + args.subcommands # Set end to blank so the help output matches how it looks when "command -h" is used @@ -2838,8 +2839,8 @@ class Cmd(cmd.Cmd): completer_function=settable.completer_function, completer_method=settable.completer_method) - from .argparse_completer import AutoCompleter - completer = AutoCompleter(settable_parser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(settable_parser, self) # Use raw_tokens since quotes have been preserved _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) @@ -2860,7 +2861,7 @@ class Cmd(cmd.Cmd): set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) # Suppress tab-completion hints for this field. The completer method is going to create an - # AutoCompleter based on the actual parameter being completed and we only want that hint printing. + # ArgparseCompleter based on the actual parameter being completed and we only want that hint printing. set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='new value for settable', completer_method=complete_set_value, suppress_tab_hint=True) @@ -3093,8 +3094,7 @@ class Cmd(cmd.Cmd): # This is a hidden flag for telling do_py to run a pyscript. It is intended only to be used by run_pyscript # after it sets up sys.argv for the script being run. When this flag is present, it takes precedence over all - # other arguments. run_pyscript uses this method instead of "py run('file')" because file names with - # 2 or more consecutive spaces cause issues with our parser, which isn't meant to parse Python statements. + # other arguments. py_parser.add_argument('--pyscript', help=argparse.SUPPRESS) # Preserve quotes since we are passing these strings to Python @@ -3104,65 +3104,69 @@ class Cmd(cmd.Cmd): Enter an interactive Python shell :return: True if running of commands should stop """ + def py_quit(): + """Function callable from the interactive Python console to exit that environment""" + raise EmbeddedConsoleExit + from .py_bridge import PyBridge + py_bridge = PyBridge(self) + saved_sys_path = None + if self.in_pyscript(): err = "Recursively entering interactive Python consoles is not allowed." self.perror(err) return - py_bridge = PyBridge(self) - py_code_to_run = '' - - # Handle case where we were called by run_pyscript - if args.pyscript: - args.pyscript = utils.strip_quotes(args.pyscript) - - # Run the script - use repr formatting to escape things which - # need to be escaped to prevent issues on Windows - py_code_to_run = 'run({!r})'.format(args.pyscript) - - elif args.command: - py_code_to_run = args.command - if args.remainder: - py_code_to_run += ' ' + ' '.join(args.remainder) - - # Set cmd_echo to True so PyBridge statements like: py app('help') - # run at the command line will print their output. - py_bridge.cmd_echo = True - try: self._in_py = True + py_code_to_run = '' - def py_run(filename: str): - """Run a Python script file in the interactive console. - :param filename: filename of script file to run - """ - expanded_filename = os.path.expanduser(filename) + # Make a copy of self.py_locals for the locals dictionary in the Python environment we are creating. + # This is to prevent pyscripts from editing it. (e.g. locals().clear()). It also ensures a pyscript's + # environment won't be filled with data from a previously run pyscript. Only make a shallow copy since + # it's OK for py_locals to contain objects which are editable in a pyscript. + localvars = dict(self.py_locals) + localvars[self.py_bridge_name] = py_bridge + localvars['quit'] = py_quit + localvars['exit'] = py_quit + + if self.self_in_py: + localvars['self'] = self + + # Handle case where we were called by run_pyscript + if args.pyscript: + # Read the script file + expanded_filename = os.path.expanduser(utils.strip_quotes(args.pyscript)) try: with open(expanded_filename) as f: - interp.runcode(f.read()) + py_code_to_run = f.read() except OSError as ex: self.pexcept("Error reading script file '{}': {}".format(expanded_filename, ex)) + return - def py_quit(): - """Function callable from the interactive Python console to exit that environment""" - raise EmbeddedConsoleExit + localvars['__name__'] = '__main__' + localvars['__file__'] = expanded_filename - # Set up Python environment - self.py_locals[self.py_bridge_name] = py_bridge - self.py_locals['run'] = py_run - self.py_locals['quit'] = py_quit - self.py_locals['exit'] = py_quit + # Place the script's directory at sys.path[0] just as Python does when executing a script + saved_sys_path = list(sys.path) + sys.path.insert(0, os.path.dirname(os.path.abspath(expanded_filename))) - if self.self_in_py: - self.py_locals['self'] = self - elif 'self' in self.py_locals: - del self.py_locals['self'] + else: + # This is the default name chosen by InteractiveConsole when no locals are passed in + localvars['__name__'] = '__console__' - localvars = self.py_locals + if args.command: + py_code_to_run = args.command + if args.remainder: + py_code_to_run += ' ' + ' '.join(args.remainder) + + # Set cmd_echo to True so PyBridge statements like: py app('help') + # run at the command line will print their output. + py_bridge.cmd_echo = True + + # Create the Python interpreter interp = InteractiveConsole(locals=localvars) - interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())') # Check if we are running Python code if py_code_to_run: @@ -3177,8 +3181,7 @@ class Cmd(cmd.Cmd): else: cprt = 'Type "help", "copyright", "credits" or "license" for more information.' instructions = ('End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.\n' - 'Non-Python commands can be issued with: {}("your command")\n' - 'Run Python code from external script files with: run("script.py")' + 'Non-Python commands can be issued with: {}("your command")' .format(self.py_bridge_name)) saved_cmd2_env = None @@ -3205,7 +3208,10 @@ class Cmd(cmd.Cmd): pass finally: - self._in_py = False + with self.sigint_protection: + if saved_sys_path is not None: + sys.path = saved_sys_path + self._in_py = False return py_bridge.stop diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 621f67df..a8babcac 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -5,7 +5,6 @@ from typing import Callable, List, Optional, Union from . import constants from .parsing import Statement -from .utils import categorize def with_category(category: str) -> Callable: @@ -22,6 +21,7 @@ def with_category(category: str) -> Callable: >>> self.poutput(args) """ def cat_decorator(func): + from .utils import categorize categorize(func, category) return func return cat_decorator diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py new file mode 100644 index 00000000..747e2368 --- /dev/null +++ b/cmd2/exceptions.py @@ -0,0 +1,12 @@ +# coding=utf-8 +"""Custom exceptions for cmd2. These are NOT part of the public API and are intended for internal use only.""" + + +class EmbeddedConsoleExit(SystemExit): + """Custom exception class for use with the py command.""" + pass + + +class EmptyStatement(Exception): + """Custom exception class for handling behavior when the user just presses <Enter>.""" + pass diff --git a/cmd2/utils.py b/cmd2/utils.py index 74df7444..d02d8157 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -72,6 +72,31 @@ def str_to_bool(val: str) -> bool: raise ValueError("must be True or False (case-insensitive)") +class CompletionError(Exception): + """ + Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter + This can also be used just to display a message, even if it's not an error. ArgparseCompleter raises + CompletionErrors to display tab completion hints and sets apply_style to False so hints aren't colored + like error text. + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + - Tab completion hints + """ + def __init__(self, *args, apply_style: bool = True, **kwargs): + """ + Initializer for CompletionError + :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. + Set to False in cases where the message text already has the desired style. + Defaults to True. + """ + self.apply_style = apply_style + + # noinspection PyArgumentList + super().__init__(*args, **kwargs) + + class Settable: """Used to configure a cmd2 instance member to be settable via the set command in the CLI""" def __init__(self, name: str, val_type: Callable, description: str, *, @@ -109,8 +134,8 @@ class Settable: for this argument (See note below) Note: - For choices_method and completer_method, do not set them to a bound method. This is because AutoCompleter - passes the self argument explicitly to these functions. + For choices_method and completer_method, do not set them to a bound method. This is because + ArgparseCompleter passes the self argument explicitly to these functions. Therefore instead of passing something like self.path_complete, pass cmd2.Cmd.path_complete. """ @@ -954,3 +979,18 @@ def get_styles_in_text(text: str) -> Dict[int, str]: start += len(match.group()) return styles + + +def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None: + """Categorize a function. + + The help command output will group this function under the specified category heading + + :param func: function or list of functions to categorize + :param category: category to put it in + """ + if isinstance(func, Iterable): + for item in func: + setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) + else: + setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) |