diff options
author | kotfu <kotfu@kotfu.net> | 2018-05-23 20:59:26 -0600 |
---|---|---|
committer | kotfu <kotfu@kotfu.net> | 2018-05-23 20:59:26 -0600 |
commit | 1a70b90f375997751bc7df16b5e3f58c6194c71b (patch) | |
tree | 1abe43d088060e24bb889e3db19fc5a1a4a82562 /cmd2 | |
parent | b1516f4b09518bb6d33abfeb14e1459ed03f34d8 (diff) | |
parent | 5d64ebee348aeffb02fc385f903c9af431e3721b (diff) | |
download | cmd2-git-1a70b90f375997751bc7df16b5e3f58c6194c71b.tar.gz |
Merge branch 'master' into speedup_import
# Conflicts:
# cmd2/cmd2.py
# tests/test_completion.py
# tests/test_submenu.py
Diffstat (limited to 'cmd2')
-rw-r--r-- | cmd2/argcomplete_bridge.py | 36 | ||||
-rwxr-xr-x | cmd2/argparse_completer.py | 68 | ||||
-rwxr-xr-x | cmd2/cmd2.py | 122 | ||||
-rw-r--r-- | cmd2/constants.py | 9 | ||||
-rw-r--r-- | cmd2/parsing.py | 148 | ||||
-rw-r--r-- | cmd2/pyscript_bridge.py | 80 | ||||
-rw-r--r-- | cmd2/rl_utils.py | 10 |
7 files changed, 337 insertions, 136 deletions
diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index a036af1e..824710b0 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -6,11 +6,17 @@ try: import argcomplete except ImportError: # pragma: no cover # not installed, skip the rest of the file - pass - + DEFAULT_COMPLETER = None else: # argcomplete is installed + # Newer versions of argcomplete have FilesCompleter at top level, older versions only have it under completers + try: + DEFAULT_COMPLETER = argcomplete.FilesCompleter() + except AttributeError: + DEFAULT_COMPLETER = argcomplete.completers.FilesCompleter() + + from cmd2.argparse_completer import ACTION_ARG_CHOICES, ACTION_SUPPRESS_HINT from contextlib import redirect_stdout import copy from io import StringIO @@ -102,7 +108,7 @@ else: def __call__(self, argument_parser, completer=None, always_complete_options=True, exit_method=os._exit, output_stream=None, exclude=None, validator=None, print_suppressed=False, append_space=None, - default_completer=argcomplete.FilesCompleter()): + default_completer=DEFAULT_COMPLETER): """ :param argument_parser: The argument parser to autocomplete on :type argument_parser: :class:`argparse.ArgumentParser` @@ -140,9 +146,14 @@ else: added to argcomplete.safe_actions, if their values are wanted in the ``parsed_args`` completer argument, or their execution is otherwise desirable. """ - self.__init__(argument_parser, always_complete_options=always_complete_options, exclude=exclude, - validator=validator, print_suppressed=print_suppressed, append_space=append_space, - default_completer=default_completer) + # Older versions of argcomplete have fewer keyword arguments + if sys.version_info >= (3, 5): + self.__init__(argument_parser, always_complete_options=always_complete_options, exclude=exclude, + validator=validator, print_suppressed=print_suppressed, append_space=append_space, + default_completer=default_completer) + else: + self.__init__(argument_parser, always_complete_options=always_complete_options, exclude=exclude, + validator=validator, print_suppressed=print_suppressed) if "_ARGCOMPLETE" not in os.environ: # not an argument completion invocation @@ -235,7 +246,10 @@ else: if comp_type == 63: # type is 63 for second tab press print(outstr.rstrip(), file=argcomplete.debug_stream, end='') - output_stream.write(ifs.join([ifs, ' ']).encode(argcomplete.sys_encoding)) + if completions is not None: + output_stream.write(ifs.join([ifs, ' ']).encode(argcomplete.sys_encoding)) + else: + output_stream.write(ifs.join([]).encode(argcomplete.sys_encoding)) else: # if completions is None we assume we don't know how to handle it so let bash # go forward with normal filesystem completion @@ -243,3 +257,11 @@ else: output_stream.flush() argcomplete.debug_stream.flush() exit_method(0) + + + def bash_complete(action, show_hint: bool=True): + """Helper function to configure an argparse action to fall back to bash completion""" + def complete_none(*args, **kwargs): + return None + setattr(action, ACTION_SUPPRESS_HINT, not show_hint) + setattr(action, ACTION_ARG_CHOICES, (complete_none,)) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index a8a0f24a..d98a6eac 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -75,6 +75,7 @@ from .rl_utils import rl_force_redisplay # attribute that can optionally added to an argparse argument (called an Action) to # define the completion choices for the argument. You may provide a Collection or a Function. ACTION_ARG_CHOICES = 'arg_choices' +ACTION_SUPPRESS_HINT = 'suppress_hint' class _RangeAction(object): @@ -261,6 +262,7 @@ class AutoCompleter(object): sub_completers[subcmd] = AutoCompleter(action.choices[subcmd], subcmd_start, arg_choices=subcmd_args, subcmd_args_lookup=subcmd_lookup, + tab_for_arg_help=tab_for_arg_help, cmd2_app=cmd2_app) sub_commands.append(subcmd) self._positional_completers[action.dest] = sub_completers @@ -472,8 +474,23 @@ class AutoCompleter(object): if action.dest in self._arg_choices: arg_choices = self._arg_choices[action.dest] - if isinstance(arg_choices, tuple) and len(arg_choices) > 0 and callable(arg_choices[0]): - completer = arg_choices[0] + # if arg_choices is a tuple + # Let's see if it's a custom completion function. If it is, return what it provides + # To do this, we make sure the first element is either a callable + # or it's the name of a callable in the application + if isinstance(arg_choices, tuple) and len(arg_choices) > 0 and \ + (callable(arg_choices[0]) or + (isinstance(arg_choices[0], str) and hasattr(self._cmd2_app, arg_choices[0]) and + callable(getattr(self._cmd2_app, arg_choices[0])) + ) + ): + + if callable(arg_choices[0]): + completer = arg_choices[0] + elif isinstance(arg_choices[0], str) and callable(getattr(self._cmd2_app, arg_choices[0])): + completer = getattr(self._cmd2_app, arg_choices[0]) + + # extract the positional and keyword arguments from the tuple list_args = None kw_args = None for index in range(1, len(arg_choices)): @@ -481,14 +498,19 @@ class AutoCompleter(object): list_args = arg_choices[index] elif isinstance(arg_choices[index], dict): kw_args = arg_choices[index] - if list_args is not None and kw_args is not None: - return completer(text, line, begidx, endidx, *list_args, **kw_args) - elif list_args is not None: - return completer(text, line, begidx, endidx, *list_args) - elif kw_args is not None: - return completer(text, line, begidx, endidx, **kw_args) - else: - return completer(text, line, begidx, endidx) + try: + # call the provided function differently depending on the provided positional and keyword arguments + if list_args is not None and kw_args is not None: + return completer(text, line, begidx, endidx, *list_args, **kw_args) + elif list_args is not None: + return completer(text, line, begidx, endidx, *list_args) + elif kw_args is not None: + return completer(text, line, begidx, endidx, **kw_args) + else: + return completer(text, line, begidx, endidx) + except TypeError: + # assume this is due to an incorrect function signature, return nothing. + return [] else: return AutoCompleter.basic_complete(text, line, begidx, endidx, self._resolve_choices_for_arg(action, used_values)) @@ -499,6 +521,16 @@ class AutoCompleter(object): if action.dest in self._arg_choices: args = self._arg_choices[action.dest] + # is the argument a string? If so, see if we can find an attribute in the + # application matching the string. + if isinstance(args, str): + try: + args = getattr(self._cmd2_app, args) + except AttributeError: + # Couldn't find anything matching the name + return [] + + # is the provided argument a callable. If so, call it if callable(args): try: if self._cmd2_app is not None: @@ -525,8 +557,19 @@ class AutoCompleter(object): return [] def _print_action_help(self, action: argparse.Action) -> None: + # is parameter hinting disabled globally? if not self._tab_for_arg_help: return + + # is parameter hinting disabled for this parameter? + try: + suppress_hint = getattr(action, ACTION_SUPPRESS_HINT) + except AttributeError: + pass + else: + if suppress_hint: + return + if action.option_strings: flags = ', '.join(action.option_strings) param = '' @@ -535,7 +578,10 @@ class AutoCompleter(object): prefix = '{}{}'.format(flags, param) else: - prefix = '{}'.format(str(action.dest).upper()) + if action.dest != SUPPRESS: + prefix = '{}'.format(str(action.dest).upper()) + else: + prefix = '' prefix = ' {0: <{width}} '.format(prefix, width=20) pref_len = len(prefix) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7547c012..6a2f0e02 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -26,6 +26,7 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 import argparse import cmd import collections +from colorama import Fore import glob import os import platform @@ -39,26 +40,34 @@ import pyperclip from . import constants from . import utils +from cmd2.parsing import StatementParser, Statement + # Set up readline -from .rl_utils import rl_force_redisplay, readline, rl_type, RlType -from .argparse_completer import AutoCompleter, ACArgumentParser +from .rl_utils import rl_type, RlType +if rl_type == RlType.NONE: + rl_warning = "Readline features including tab completion have been disabled since no \n" \ + "supported version of readline was found. To resolve this, install \n" \ + "pyreadline on Windows or gnureadline on Mac.\n\n" + sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_warning + Fore.RESET) +else: + from .rl_utils import rl_force_redisplay, readline -from cmd2.parsing import StatementParser, Statement + if rl_type == RlType.PYREADLINE: -if rl_type == RlType.PYREADLINE: + # Save the original pyreadline display completion function since we need to override it and restore it + # noinspection PyProtectedMember + orig_pyreadline_display = readline.rl.mode._display_completions - # Save the original pyreadline display completion function since we need to override it and restore it - # noinspection PyProtectedMember - orig_pyreadline_display = readline.rl.mode._display_completions + elif rl_type == RlType.GNU: -elif rl_type == RlType.GNU: + # We need wcswidth to calculate display width of tab completions + from wcwidth import wcswidth - # We need wcswidth to calculate display width of tab completions - from wcwidth import wcswidth + # Get the readline lib so we can make changes to it + import ctypes + from .rl_utils import readline_lib - # Get the readline lib so we can make changes to it - import ctypes - from .rl_utils import readline_lib +from .argparse_completer import AutoCompleter, ACArgumentParser # Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure try: @@ -312,7 +321,6 @@ class Cmd(cmd.Cmd): # Attributes used to configure the StatementParser, best not to change these at runtime blankLinesAllowed = False multiline_commands = [] - redirector = '>' # for sending output to file shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'} aliases = dict() terminators = [';'] @@ -377,7 +385,7 @@ class Cmd(cmd.Cmd): pass # If persistent readline history is enabled, then read history from file and register to write to file at exit - if persistent_history_file: + if persistent_history_file and rl_type != RlType.NONE: persistent_history_file = os.path.expanduser(persistent_history_file) try: readline.read_history_file(persistent_history_file) @@ -1127,29 +1135,26 @@ class Cmd(cmd.Cmd): if len(raw_tokens) > 1: - # Build a list of all redirection tokens - all_redirects = constants.REDIRECTION_CHARS + ['>>'] - # Check if there are redirection strings prior to the token being completed seen_pipe = False has_redirection = False for cur_token in raw_tokens[:-1]: - if cur_token in all_redirects: + if cur_token in constants.REDIRECTION_TOKENS: has_redirection = True - if cur_token == '|': + if cur_token == constants.REDIRECTION_PIPE: seen_pipe = True # Get token prior to the one being completed prior_token = raw_tokens[-2] # If a pipe is right before the token being completed, complete a shell command as the piped process - if prior_token == '|': + if prior_token == constants.REDIRECTION_PIPE: return self.shell_cmd_complete(text, line, begidx, endidx) # Otherwise do path completion either as files to redirectors or arguments to the piped process - elif prior_token in all_redirects or seen_pipe: + elif prior_token in constants.REDIRECTION_TOKENS or seen_pipe: return self.path_complete(text, line, begidx, endidx) # If there were redirection strings anywhere on the command line, then we @@ -1272,6 +1277,7 @@ class Cmd(cmd.Cmd): :param state: int - non-negative integer """ import functools + if state == 0 and rl_type != RlType.NONE: if state == 0: unclosed_quote = '' @@ -1806,7 +1812,7 @@ class Cmd(cmd.Cmd): # We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True. try: - self.pipe_proc = subprocess.Popen(shlex.split(statement.pipe_to), stdin=subproc_stdin) + self.pipe_proc = subprocess.Popen(statement.pipe_to, stdin=subproc_stdin) except Exception as ex: # Restore stdout to what it was and close the pipe self.stdout.close() @@ -1821,24 +1827,30 @@ class Cmd(cmd.Cmd): elif statement.output: import tempfile if (not statement.output_to) and (not can_clip): - raise EnvironmentError('Cannot redirect to paste buffer; install ``xclip`` and re-run to enable') + raise EnvironmentError("Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable") self.kept_state = Statekeeper(self, ('stdout',)) self.kept_sys = Statekeeper(sys, ('stdout',)) self.redirecting = True if statement.output_to: + # going to a file mode = 'w' - if statement.output == 2 * self.redirector: + # statement.output can only contain + # REDIRECTION_APPEND or REDIRECTION_OUTPUT + if statement.output == constants.REDIRECTION_APPEND: mode = 'a' sys.stdout = self.stdout = open(os.path.expanduser(statement.output_to), mode) else: + # going to a paste buffer sys.stdout = self.stdout = tempfile.TemporaryFile(mode="w+") - if statement.output == '>>': + if statement.output == constants.REDIRECTION_APPEND: self.poutput(get_paste_buffer()) def _restore_output(self, statement): - """Handles restoring state after output redirection as well as the actual pipe operation if present. + """Handles restoring state after output redirection as well as + the actual pipe operation if present. - :param statement: Statement object which contains the parsed input from the user + :param statement: Statement object which contains the parsed + input from the user """ # If we have redirected output to a file or the clipboard or piped it to a shell command, then restore state if self.kept_state is not None: @@ -2116,23 +2128,26 @@ Usage: Usage: alias [name] | [<name> <value>] name = arglist[0] value = ' '.join(arglist[1:]) - # Check for a valid name - for cur_char in name: - if cur_char not in self.identchars: - self.perror("Alias names can only contain the following characters: {}".format(self.identchars), - traceback_war=False) - return - - # Set the alias - self.aliases[name] = value - self.poutput("Alias {!r} created".format(name)) + # Validate the alias to ensure it doesn't include weird characters + # like terminators, output redirection, or whitespace + valid, invalidchars = self.statement_parser.is_valid_command(name) + if valid: + # Set the alias + self.aliases[name] = value + self.poutput("Alias {!r} created".format(name)) + else: + errmsg = "Aliases can not contain: {}".format(invalidchars) + self.perror(errmsg, traceback_war=False) def complete_alias(self, text, line, begidx, endidx): """ Tab completion for alias """ + alias_names = set(self.aliases.keys()) + visible_commands = set(self.get_visible_commands()) + index_dict = \ { - 1: self.aliases, - 2: self.get_visible_commands() + 1: alias_names, + 2: list(alias_names | visible_commands) } return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) @@ -2354,9 +2369,12 @@ Usage: Usage: unalias [-a] name [name ...] self.poutput(' %2d. %s\n' % (idx + 1, text)) while True: response = input(prompt) - hlen = readline.get_current_history_length() - if hlen >= 1 and response != '': - readline.remove_history_item(hlen - 1) + + if rl_type != RlType.NONE: + hlen = readline.get_current_history_length() + if hlen >= 1 and response != '': + readline.remove_history_item(hlen - 1) + try: response = int(response) result = fulloptions[response - 1][0] @@ -2613,9 +2631,21 @@ Paths or arguments that contain spaces must be enclosed in quotes Run python code from external files with ``run filename.py`` End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. """ - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) + from .pyscript_bridge import PyscriptBridge + bridge = PyscriptBridge(self) + + if self.locals_in_py: + def load_ipy(self, app): + banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' + exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) + embed(banner1=banner, exit_msg=exit_msg) + load_ipy(self, bridge) + else: + def load_ipy(app): + banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' + exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) + embed(banner1=banner, exit_msg=exit_msg) + load_ipy(bridge) history_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) history_parser_group = history_parser.add_mutually_exclusive_group() diff --git a/cmd2/constants.py b/cmd2/constants.py index 838650e5..b829000f 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -4,9 +4,14 @@ import re -# Used for command parsing, tab completion and word breaks. Do not change. +# Used for command parsing, output redirection, tab completion and word +# breaks. Do not change. QUOTES = ['"', "'"] -REDIRECTION_CHARS = ['|', '>'] +REDIRECTION_PIPE = '|' +REDIRECTION_OUTPUT = '>' +REDIRECTION_APPEND = '>>' +REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] +REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] # Regular expression to match ANSI escape codes ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') diff --git a/cmd2/parsing.py b/cmd2/parsing.py index f2c86ea8..655e0c58 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -45,7 +45,8 @@ class Statement(str): redirection, if any :type suffix: str or None :var pipe_to: if output was piped to a shell command, the shell command - :type pipe_to: str or None + as a list of tokens + :type pipe_to: list :var output: if output was redirected, the redirection token, i.e. '>>' :type output: str or None :var output_to: if output was redirected, the destination, usually a filename @@ -141,15 +142,67 @@ class StatementParser: re.DOTALL | re.MULTILINE ) - # aliases have to be a word, so make a regular expression - # that matches the first word in the line. This regex has two - # parts, the first parenthesis enclosed group matches one - # or more non-whitespace characters (which may be preceeded - # by whitespace) and the second group matches either a whitespace - # character or the end of the string. We use \A and \Z to ensure - # we always match the beginning and end of a string that may have - # multiple lines - self.command_pattern = re.compile(r'\A\s*(\S+)(\s|\Z)+') + # commands have to be a word, so make a regular expression + # that matches the first word in the line. This regex has three + # parts: + # - the '\A\s*' matches the beginning of the string (even + # if contains multiple lines) and gobbles up any leading + # whitespace + # - the first parenthesis enclosed group matches one + # or more non-whitespace characters with a non-greedy match + # (that's what the '+?' part does). The non-greedy match + # ensures that this first group doesn't include anything + # matched by the second group + # - the second parenthesis group must be dynamically created + # because it needs to match either whitespace, something in + # REDIRECTION_CHARS, one of the terminators, or the end of + # the string (\Z matches the end of the string even if it + # contains multiple lines) + # + invalid_command_chars = [] + invalid_command_chars.extend(constants.QUOTES) + invalid_command_chars.extend(constants.REDIRECTION_CHARS) + invalid_command_chars.extend(terminators) + # escape each item so it will for sure get treated as a literal + second_group_items = [re.escape(x) for x in invalid_command_chars] + # add the whitespace and end of string, not escaped because they + # are not literals + second_group_items.extend([r'\s', r'\Z']) + # join them up with a pipe + second_group = '|'.join(second_group_items) + # build the regular expression + expr = r'\A\s*(\S*?)({})'.format(second_group) + self._command_pattern = re.compile(expr) + + def is_valid_command(self, word: str) -> Tuple[bool, str]: + """Determine whether a word is a valid alias. + + Aliases can not include redirection characters, whitespace, + or termination characters. + + If word is not a valid command, return False and a comma + separated string of characters that can not appear in a command. + This string is suitable for inclusion in an error message of your + choice: + + valid, invalidchars = statement_parser.is_valid_command('>') + if not valid: + errmsg = "Aliases can not contain: {}".format(invalidchars) + """ + valid = False + + errmsg = 'whitespace, quotes, ' + errchars = [] + errchars.extend(constants.REDIRECTION_CHARS) + errchars.extend(self.terminators) + errmsg += ', '.join([shlex.quote(x) for x in errchars]) + + match = self._command_pattern.search(word) + if match: + if word == match.group(1): + valid = True + errmsg = None + return valid, errmsg def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. @@ -197,23 +250,27 @@ class StatementParser: tokens = self.tokenize(rawinput) # of the valid terminators, find the first one to occur in the input - terminator_pos = len(tokens)+1 - for test_terminator in self.terminators: - try: - pos = tokens.index(test_terminator) - if pos < terminator_pos: + terminator_pos = len(tokens) + 1 + for pos, cur_token in enumerate(tokens): + for test_terminator in self.terminators: + if cur_token.startswith(test_terminator): terminator_pos = pos terminator = test_terminator + # break the inner loop, and we want to break the + # outer loop too break - except ValueError: - # the terminator is not in the tokens - pass + else: + # this else clause is only run if the inner loop + # didn't execute a break. If it didn't, then + # continue to the next iteration of the outer loop + continue + # inner loop was broken, break the outer + break if terminator: if terminator == LINE_FEED: terminator_pos = len(tokens)+1 - else: - terminator_pos = tokens.index(terminator) + # everything before the first terminator is the command and the args argv = tokens[:terminator_pos] (command, args) = self._command_and_args(argv) @@ -231,12 +288,27 @@ class StatementParser: argv = tokens tokens = [] + # check for a pipe to a shell process + # if there is a pipe, everything after the pipe needs to be passed + # to the shell, even redirected output + # this allows '(Cmd) say hello | wc > countit.txt' + try: + # find the first pipe if it exists + pipe_pos = tokens.index(constants.REDIRECTION_PIPE) + # save everything after the first pipe as tokens + pipe_to = tokens[pipe_pos+1:] + # remove all the tokens after the pipe + tokens = tokens[:pipe_pos] + except ValueError: + # no pipe in the tokens + pipe_to = None + # check for output redirect output = None output_to = None try: - output_pos = tokens.index('>') - output = '>' + output_pos = tokens.index(constants.REDIRECTION_OUTPUT) + output = constants.REDIRECTION_OUTPUT output_to = ' '.join(tokens[output_pos+1:]) # remove all the tokens after the output redirect tokens = tokens[:output_pos] @@ -244,26 +316,14 @@ class StatementParser: pass try: - output_pos = tokens.index('>>') - output = '>>' + output_pos = tokens.index(constants.REDIRECTION_APPEND) + output = constants.REDIRECTION_APPEND output_to = ' '.join(tokens[output_pos+1:]) # remove all tokens after the output redirect tokens = tokens[:output_pos] except ValueError: pass - # check for pipes - try: - # find the first pipe if it exists - pipe_pos = tokens.index('|') - # save everything after the first pipe - pipe_to = ' '.join(tokens[pipe_pos+1:]) - # remove all the tokens after the pipe - tokens = tokens[:pipe_pos] - except ValueError: - # no pipe in the tokens - pipe_to = None - if terminator: # whatever is left is the suffix suffix = ' '.join(tokens) @@ -324,16 +384,24 @@ class StatementParser: command = None args = None - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - # the command_pattern regex is designed to match the spaces + # the match could be an empty string, if so, turn it into none + if not command: + command = None + # the _command_pattern regex is designed to match the spaces # between command and args with a second match group. Using # the end of the second match group ensures that args has # no leading whitespace. The rstrip() makes sure there is # no trailing whitespace args = line[match.end(2):].rstrip() + # if the command is none that means the input was either empty + # or something wierd like '>'. args should be None if we couldn't + # parse a command + if not command or not args: + args = None # build the statement # string representation of args must be an empty string instead of @@ -355,11 +423,11 @@ class StatementParser: for cur_alias in tmp_aliases: keep_expanding = False # apply our regex to line - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - if command == cur_alias: + if command and command == cur_alias: # rebuild line with the expanded alias line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):] tmp_aliases.remove(cur_alias) diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py index 055ae4ae..196be82b 100644 --- a/cmd2/pyscript_bridge.py +++ b/cmd2/pyscript_bridge.py @@ -8,10 +8,9 @@ Released under MIT license, see LICENSE file """ import argparse -from collections import namedtuple import functools import sys -from typing import List, Tuple +from typing import List, Tuple, Callable # Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout if sys.version_info < (3, 5): @@ -41,13 +40,15 @@ class CommandResult(namedtuple_with_defaults('CmdResult', ['stdout', 'stderr', ' class CopyStream(object): """Copies all data written to a stream""" - def __init__(self, inner_stream): + def __init__(self, inner_stream, echo: bool = False): self.buffer = '' self.inner_stream = inner_stream + self.echo = echo def write(self, s): self.buffer += s - self.inner_stream.write(s) + if self.echo: + self.inner_stream.write(s) def read(self): raise NotImplementedError @@ -55,22 +56,35 @@ class CopyStream(object): def clear(self): self.buffer = '' + def __getattr__(self, item: str): + if item in self.__dict__: + return self.__dict__[item] + else: + return getattr(self.inner_stream, item) + -def _exec_cmd(cmd2_app, func): +def _exec_cmd(cmd2_app, func: Callable, echo: bool): """Helper to encapsulate executing a command and capturing the results""" - copy_stdout = CopyStream(sys.stdout) - copy_stderr = CopyStream(sys.stderr) + copy_stdout = CopyStream(sys.stdout, echo) + copy_stderr = CopyStream(sys.stderr, echo) + + copy_cmd_stdout = CopyStream(cmd2_app.stdout, echo) cmd2_app._last_result = None - with redirect_stdout(copy_stdout): - with redirect_stderr(copy_stderr): - func() + try: + cmd2_app.stdout = copy_cmd_stdout + with redirect_stdout(copy_stdout): + with redirect_stderr(copy_stderr): + func() + finally: + cmd2_app.stdout = copy_cmd_stdout.inner_stream # if stderr is empty, set it to None - stderr = copy_stderr if copy_stderr.buffer else None + stderr = copy_stderr.buffer if copy_stderr.buffer else None - result = CommandResult(stdout=copy_stdout.buffer, stderr=stderr, data=cmd2_app._last_result) + outbuf = copy_cmd_stdout.buffer if copy_cmd_stdout.buffer else copy_stdout.buffer + result = CommandResult(stdout=outbuf, stderr=stderr, data=cmd2_app._last_result) return result @@ -78,9 +92,10 @@ class ArgparseFunctor: """ Encapsulates translating python object traversal """ - def __init__(self, cmd2_app, item, parser): + def __init__(self, echo: bool, cmd2_app, command_name: str, parser: argparse.ArgumentParser): + self._echo = echo self._cmd2_app = cmd2_app - self._item = item + self._command_name = command_name self._parser = parser # Dictionary mapping command argument name to value @@ -88,7 +103,15 @@ class ArgparseFunctor: # argparse object for the current command layer self.__current_subcommand_parser = parser - def __getattr__(self, item): + def __dir__(self): + """Returns a custom list of attribute names to match the sub-commands""" + commands = [] + for action in self.__current_subcommand_parser._actions: + if not action.option_strings and isinstance(action, argparse._SubParsersAction): + commands.extend(action.choices) + return commands + + def __getattr__(self, item: str): """Search for a subcommand matching this item and update internal state to track the traversal""" # look for sub-command under the current command/sub-command layer for action in self.__current_subcommand_parser._actions: @@ -101,7 +124,6 @@ class ArgparseFunctor: return self raise AttributeError(item) - # return super().__getattr__(item) def __call__(self, *args, **kwargs): """ @@ -182,7 +204,7 @@ class ArgparseFunctor: def _run(self): # look up command function - func = getattr(self._cmd2_app, 'do_' + self._item) + func = getattr(self._cmd2_app, 'do_' + self._command_name) # reconstruct the cmd2 command from the python call cmd_str = [''] @@ -208,7 +230,7 @@ class ArgparseFunctor: if action.option_strings: cmd_str[0] += '{} '.format(action.option_strings[0]) - if isinstance(value, List) or isinstance(value, Tuple): + if isinstance(value, List) or isinstance(value, tuple): for item in value: item = str(item).strip() if ' ' in item: @@ -228,7 +250,7 @@ class ArgparseFunctor: cmd_str[0] += '{} '.format(self._args[action.dest]) traverse_parser(action.choices[self._args[action.dest]]) elif isinstance(action, argparse._AppendAction): - if isinstance(self._args[action.dest], List) or isinstance(self._args[action.dest], Tuple): + if isinstance(self._args[action.dest], list) or isinstance(self._args[action.dest], tuple): for values in self._args[action.dest]: process_flag(action, values) else: @@ -238,9 +260,8 @@ class ArgparseFunctor: traverse_parser(self._parser) - # print('Command: {}'.format(cmd_str[0])) + return _exec_cmd(self._cmd2_app, functools.partial(func, cmd_str[0]), self._echo) - return _exec_cmd(self._cmd2_app, functools.partial(func, cmd_str[0])) class PyscriptBridge(object): """Preserves the legacy 'cmd' interface for pyscript while also providing a new python API wrapper for @@ -248,6 +269,7 @@ class PyscriptBridge(object): def __init__(self, cmd2_app): self._cmd2_app = cmd2_app self._last_result = None + self.cmd_echo = False def __getattr__(self, item: str): """Check if the attribute is a command. If so, return a callable.""" @@ -261,13 +283,19 @@ class PyscriptBridge(object): except AttributeError: # Command doesn't, we will accept parameters in the form of a command string def wrap_func(args=''): - return _exec_cmd(self._cmd2_app, functools.partial(func, args)) + return _exec_cmd(self._cmd2_app, functools.partial(func, args), self.cmd_echo) return wrap_func else: # Command does use argparse, return an object that can traverse the argparse subcommands and arguments - return ArgparseFunctor(self._cmd2_app, item, parser) + return ArgparseFunctor(self.cmd_echo, self._cmd2_app, item, parser) - raise AttributeError(item) + return super().__getattr__(item) + + def __dir__(self): + """Return a custom set of attribute names to match the available commands""" + commands = list(self._cmd2_app.get_all_commands()) + commands.insert(0, 'cmd_echo') + return commands - def __call__(self, args): - return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n')) + def __call__(self, args: str): + return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n'), self.cmd_echo) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 465fcaea..8ef65d28 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -34,11 +34,13 @@ if 'pyreadline' in sys.modules: rl_type = RlType.PYREADLINE elif 'gnureadline' in sys.modules or 'readline' in sys.modules: - rl_type = RlType.GNU + # We don't support libedit + if 'libedit' not in readline.__doc__: + rl_type = RlType.GNU - # Load the readline lib so we can access members of it - import ctypes - readline_lib = ctypes.CDLL(readline.__file__) + # Load the readline lib so we can access members of it + import ctypes + readline_lib = ctypes.CDLL(readline.__file__) def rl_force_redisplay() -> None: |