diff options
40 files changed, 614 insertions, 625 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 91710781..6bb29e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Changes * ``strip_ansi()`` and ``strip_quotes()`` functions have moved to new utils module * Several constants moved to new constants module + * Submenu support has been moved to a new [cmd2-submenu](https://github.com/python-cmd2/cmd2-submenu) plugin. If you use submenus, you will need to update your dependencies and modify your imports. * Deletions (potentially breaking changes) * Deleted all ``optparse`` code which had previously been deprecated in release 0.8.0 * The ``options`` decorator no longer exists @@ -28,6 +29,7 @@ * Deleted ``cmd_with_subs_completer``, ``get_subcommands``, and ``get_subcommand_completer`` * Replaced by default AutoCompleter implementation for all commands using argparse * Deleted support for old method of calling application commands with ``cmd()`` and ``self`` + * ``cmd2.redirector`` is no longer supported. Output redirection can only be done with '>' or '>>' * Python 2 no longer supported * ``cmd2`` now supports Python 3.4+ * Known Issues 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: diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 95ae127c..a03a1d08 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -100,26 +100,8 @@ As in a Unix shell, output of a command can be redirected: - appended to a file with ``>>``, as in ``mycommand args >> filename.txt`` - piped (``|``) as input to operating-system commands, as in ``mycommand args | wc`` - - sent to the paste buffer, ready for the next Copy operation, by - ending with a bare ``>``, as in ``mycommand args >``.. Redirecting - to paste buffer requires software to be installed on the operating - system, pywin32_ on Windows or xclip_ on \*nix. + - sent to the operating system paste buffer, by ending with a bare ``>``, as in ``mycommand args >``. You can even append output to the current contents of the paste buffer by ending your command with ``>>``. -If your application depends on mathematical syntax, ``>`` may be a bad -choice for redirecting output - it will prevent you from using the -greater-than sign in your actual user commands. You can override your -app's value of ``self.redirector`` to use a different string for output redirection:: - - class MyApp(cmd2.Cmd): - redirector = '->' - -:: - - (Cmd) say line1 -> out.txt - (Cmd) say line2 ->-> out.txt - (Cmd) !cat out.txt - line1 - line2 .. note:: @@ -136,8 +118,8 @@ app's value of ``self.redirector`` to use a different string for output redirect arguments after them from the command line arguments accordingly. But output from a command will not be redirected to a file or piped to a shell command. -.. _pywin32: http://sourceforge.net/projects/pywin32/ -.. _xclip: http://www.cyberciti.biz/faq/xclip-linux-insert-files-command-output-intoclipboard/ +If you need to include any of these redirection characters in your command, +you can enclose them in quotation marks, ``mycommand 'with > in the argument'``. Python ====== diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index a4776a53..41144c8f 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -10,13 +10,17 @@ commands whose names are listed in the parameter ``app.multiline_commands``. These commands will be executed only after the user has entered a *terminator*. -By default, the command terminators is +By default, the command terminator is ``;``; replacing or appending to the list ``app.terminators`` allows different terminators. A blank line is *always* considered a command terminator (cannot be overridden). +In multiline commands, output redirection characters +like ``>`` and ``|`` are part of the command +arguments unless they appear after the terminator. + Parsed statements ================= diff --git a/examples/subcommands.py b/examples/subcommands.py index 3dd2c683..55be7711 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -19,6 +19,10 @@ base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcomma parser_foo = base_subparsers.add_parser('foo', help='foo help') parser_foo.add_argument('-x', type=int, default=1, help='integer') parser_foo.add_argument('y', type=float, help='float') +input_file = parser_foo.add_argument('input_file', type=str, help='Input File') +if __name__ == '__main__': + from cmd2.argcomplete_bridge import bash_complete + bash_complete(input_file) # create the parser for the "bar" subcommand parser_bar = base_subparsers.add_parser('bar', help='bar help') diff --git a/examples/submenus.py b/examples/submenus.py deleted file mode 100755 index 27c8cb10..00000000 --- a/examples/submenus.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -""" -Create a CLI with a nested command structure as follows. The commands 'second' and 'third' navigate the CLI to the scope -of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decorator. - - (Top Level)----second----->(2nd Level)----third----->(3rd Level) - | | | - ---> say ---> say ---> say -""" -from __future__ import print_function -import sys - -from cmd2 import cmd2 -from IPython import embed - - -class ThirdLevel(cmd2.Cmd): - """To be used as a third level command class. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.prompt = '3rdLevel ' - self.top_level_attr = None - self.second_level_attr = None - - def do_say(self, line): - print("You called a command in ThirdLevel with '%s'. " - "It has access to top_level_attr: %s " - "and second_level_attr: %s" % (line, self.top_level_attr, self.second_level_attr)) - - def help_say(self): - print("This is a third level submenu (submenu_ab). Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(ThirdLevel(), - command='third', - aliases=('third_alias',), - shared_attributes=dict(second_level_attr='second_level_attr', top_level_attr='top_level_attr')) -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - self.second_level_attr = 987654321 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - 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) - - def do_say(self, line): - print("You called a command in SecondLevel with '%s'. " - "It has access to top_level_attr: %s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a SecondLevel menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(SecondLevel(), - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class TopLevel(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - 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) - - def do_say(self, line): - print("You called a command in TopLevel with '%s'. " - "TopLevel has attribute top_level_attr=%s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -if __name__ == '__main__': - - root = TopLevel() - root.cmdloop() - diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index f3302533..adfe9702 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -96,6 +96,15 @@ class TabCompleteExample(cmd2.Cmd): }, } + file_list = \ + [ + '/home/user/file.db', + '/home/user/file space.db', + '/home/user/another.db', + '/home/other user/maps.db', + '/home/other user/tests.db' + ] + def instance_query_actors(self) -> List[str]: """Simulating a function that queries and returns a completion values""" return actors @@ -225,9 +234,23 @@ class TabCompleteExample(cmd2.Cmd): required=True) actor_action = vid_movies_add_parser.add_argument('actor', help='Actors', nargs='*') + vid_movies_load_parser = vid_movies_commands_subparsers.add_parser('load') + vid_movie_file_action = vid_movies_load_parser.add_argument('movie_file', help='Movie database') + + vid_movies_read_parser = vid_movies_commands_subparsers.add_parser('read') + vid_movie_fread_action = vid_movies_read_parser.add_argument('movie_file', help='Movie database') + # tag the action objects with completion providers. This can be a collection or a callable setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors) - setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, instance_query_actors) + setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors') + + # tag the file property with a custom completion function 'delimeter_complete' provided by cmd2. + setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES, + ('delimiter_complete', + {'delimiter': '/', + 'match_against': file_list})) + setattr(vid_movie_fread_action, argparse_completer.ACTION_ARG_CHOICES, + ('path_complete', [False, False])) vid_movies_delete_parser = vid_movies_commands_subparsers.add_parser('delete') @@ -306,6 +329,9 @@ class TabCompleteExample(cmd2.Cmd): movies_delete_parser = movies_commands_subparsers.add_parser('delete') + movies_load_parser = movies_commands_subparsers.add_parser('load') + movie_file_action = movies_load_parser.add_argument('movie_file', help='Movie database') + shows_parser = media_types_subparsers.add_parser('shows') shows_parser.set_defaults(func=_do_media_shows) @@ -333,7 +359,8 @@ class TabCompleteExample(cmd2.Cmd): def complete_media(self, text, line, begidx, endidx): """ Adds tab completion to media""" choices = {'actor': query_actors, # function - 'director': TabCompleteExample.static_list_directors # static list + 'director': TabCompleteExample.static_list_directors, # static list + 'movie_file': (self.path_complete, [False, False]) } completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices) diff --git a/tests/pyscript/bar1.py b/tests/pyscript/bar1.py index c6276a87..521e2c29 100644 --- a/tests/pyscript/bar1.py +++ b/tests/pyscript/bar1.py @@ -1 +1,2 @@ +app.cmd_echo = True app.bar('11', '22') diff --git a/tests/pyscript/custom_echo.py b/tests/pyscript/custom_echo.py new file mode 100644 index 00000000..14040e4c --- /dev/null +++ b/tests/pyscript/custom_echo.py @@ -0,0 +1,2 @@ +custom.cmd_echo = True +custom.echo('blah!') diff --git a/tests/pyscript/foo1.py b/tests/pyscript/foo1.py index 6e345d95..d9345354 100644 --- a/tests/pyscript/foo1.py +++ b/tests/pyscript/foo1.py @@ -1 +1,2 @@ +app.cmd_echo = True app.foo('aaa', 'bbb', counter=3, trueval=True, constval=True) diff --git a/tests/pyscript/foo2.py b/tests/pyscript/foo2.py index d4df7616..d3600a60 100644 --- a/tests/pyscript/foo2.py +++ b/tests/pyscript/foo2.py @@ -1 +1,2 @@ +app.cmd_echo = True app.foo('11', '22', '33', '44', counter=3, trueval=True, constval=True) diff --git a/tests/pyscript/foo3.py b/tests/pyscript/foo3.py index db69edaf..fc0e084a 100644 --- a/tests/pyscript/foo3.py +++ b/tests/pyscript/foo3.py @@ -1 +1,2 @@ +app.cmd_echo = True app.foo('11', '22', '33', '44', '55', '66', counter=3, trueval=False, constval=False) diff --git a/tests/pyscript/foo4.py b/tests/pyscript/foo4.py index 88fd3ce8..e4b7d01c 100644 --- a/tests/pyscript/foo4.py +++ b/tests/pyscript/foo4.py @@ -1,3 +1,4 @@ +app.cmd_echo = True result = app.foo('aaa', 'bbb', counter=3) out_text = 'Fail' if result: diff --git a/tests/pyscript/help.py b/tests/pyscript/help.py index 3f67793c..664c0488 100644 --- a/tests/pyscript/help.py +++ b/tests/pyscript/help.py @@ -1 +1,2 @@ -app.help()
\ No newline at end of file +app.cmd_echo = True +app.help() diff --git a/tests/pyscript/help_media.py b/tests/pyscript/help_media.py index 78025bdd..d8d97c42 100644 --- a/tests/pyscript/help_media.py +++ b/tests/pyscript/help_media.py @@ -1 +1,2 @@ +app.cmd_echo = True app.help('media') diff --git a/tests/pyscript/media_movies_add1.py b/tests/pyscript/media_movies_add1.py index a9139cb1..7249c0ef 100644 --- a/tests/pyscript/media_movies_add1.py +++ b/tests/pyscript/media_movies_add1.py @@ -1 +1,2 @@ +app.cmd_echo = True app.media.movies.add('My Movie', 'PG-13', director=('George Lucas', 'J. J. Abrams')) diff --git a/tests/pyscript/media_movies_add2.py b/tests/pyscript/media_movies_add2.py index 5c4617ae..681095d7 100644 --- a/tests/pyscript/media_movies_add2.py +++ b/tests/pyscript/media_movies_add2.py @@ -1 +1,2 @@ +app.cmd_echo = True app.media.movies.add('My Movie', 'PG-13', actor=('Mark Hamill'), director=('George Lucas', 'J. J. Abrams')) diff --git a/tests/pyscript/media_movies_list1.py b/tests/pyscript/media_movies_list1.py index 0124bbcb..edbc2021 100644 --- a/tests/pyscript/media_movies_list1.py +++ b/tests/pyscript/media_movies_list1.py @@ -1 +1,2 @@ -app.media.movies.list()
\ No newline at end of file +app.cmd_echo = True +app.media.movies.list() diff --git a/tests/pyscript/media_movies_list2.py b/tests/pyscript/media_movies_list2.py index 83f6c8ff..5ad01b7b 100644 --- a/tests/pyscript/media_movies_list2.py +++ b/tests/pyscript/media_movies_list2.py @@ -1 +1,2 @@ -app.media().movies().list()
\ No newline at end of file +app.cmd_echo = True +app.media().movies().list() diff --git a/tests/pyscript/media_movies_list3.py b/tests/pyscript/media_movies_list3.py index 4fcf1288..bdbdfceb 100644 --- a/tests/pyscript/media_movies_list3.py +++ b/tests/pyscript/media_movies_list3.py @@ -1 +1,2 @@ -app('media movies list')
\ No newline at end of file +app.cmd_echo = True +app('media movies list') diff --git a/tests/pyscript/media_movies_list4.py b/tests/pyscript/media_movies_list4.py index 1165b0c5..5f7bdaa9 100644 --- a/tests/pyscript/media_movies_list4.py +++ b/tests/pyscript/media_movies_list4.py @@ -1 +1,2 @@ +app.cmd_echo = True app.media.movies.list(actor='Mark Hamill') diff --git a/tests/pyscript/media_movies_list5.py b/tests/pyscript/media_movies_list5.py index 962b1516..fa4efa5b 100644 --- a/tests/pyscript/media_movies_list5.py +++ b/tests/pyscript/media_movies_list5.py @@ -1 +1,2 @@ +app.cmd_echo = True app.media.movies.list(actor=('Mark Hamill', 'Carrie Fisher')) diff --git a/tests/pyscript/media_movies_list6.py b/tests/pyscript/media_movies_list6.py index 5f8d3654..ef1851cd 100644 --- a/tests/pyscript/media_movies_list6.py +++ b/tests/pyscript/media_movies_list6.py @@ -1 +1,2 @@ +app.cmd_echo = True app.media.movies.list(rating='PG') diff --git a/tests/pyscript/media_movies_list7.py b/tests/pyscript/media_movies_list7.py index bb0e28bb..7c827b7f 100644 --- a/tests/pyscript/media_movies_list7.py +++ b/tests/pyscript/media_movies_list7.py @@ -1 +1,2 @@ +app.cmd_echo = True app.media.movies.list(rating=('PG', 'PG-13')) diff --git a/tests/pyscript/pyscript_dir1.py b/tests/pyscript/pyscript_dir1.py new file mode 100644 index 00000000..14a70a31 --- /dev/null +++ b/tests/pyscript/pyscript_dir1.py @@ -0,0 +1,3 @@ +out = dir(app) +out.sort() +print(out) diff --git a/tests/pyscript/pyscript_dir2.py b/tests/pyscript/pyscript_dir2.py new file mode 100644 index 00000000..28c61c8e --- /dev/null +++ b/tests/pyscript/pyscript_dir2.py @@ -0,0 +1,3 @@ +out = dir(app.media) +out.sort() +print(out) diff --git a/tests/scripts/recursive.py b/tests/scripts/recursive.py index 32c981b6..4c29d317 100644 --- a/tests/scripts/recursive.py +++ b/tests/scripts/recursive.py @@ -3,4 +3,5 @@ """ Example demonstrating that running a Python script recursively inside another Python script isn't allowed """ +app.cmd_echo = True app('pyscript ../script.py') diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index 1d0c9678..e0a71831 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -168,7 +168,7 @@ def test_autocomp_subcmd_nested(cmd2_app): first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is not None and \ - cmd2_app.completion_matches == ['add', 'delete', 'list'] + cmd2_app.completion_matches == ['add', 'delete', 'list', 'load'] def test_autocomp_subcmd_flag_choices_append(cmd2_app): @@ -246,7 +246,7 @@ def test_autcomp_pos_consumed(cmd2_app): def test_autcomp_pos_after_flag(cmd2_app): text = 'Joh' - line = 'media movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text) + line = 'video movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text) endidx = len(line) begidx = endidx - len(text) diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index ceae2aa9..298bdf1e 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -139,15 +139,13 @@ def test_invalid_ifs(parser1, mock): @pytest.mark.parametrize('comp_line, exp_out, exp_err', [ ('media ', 'movies\013shows', ''), ('media mo', 'movies', ''), + ('media movies list -a "J', '"John Boyega"\013"Jake Lloyd"', ''), + ('media movies list ', '', ''), ('media movies add ', '\013\013 ', ''' Hint: TITLE Movie Title'''), - ('media movies list -a "J', '"John Boyega"\013"Jake Lloyd"', ''), - ('media movies list ', '', '') ]) def test_commands(parser1, capfd, mock, comp_line, exp_out, exp_err): - completer = CompletionFinder() - mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', '_ARGCOMPLETE_IFS': '\013', 'COMP_TYPE': '63', @@ -157,6 +155,8 @@ def test_commands(parser1, capfd, mock, comp_line, exp_out, exp_err): mock.patch.object(os, 'fdopen', my_fdopen) with pytest.raises(SystemExit): + completer = CompletionFinder() + choices = {'actor': query_actors, # function } autocompleter = AutoCompleter(parser1, arg_choices=choices) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 11c2cad8..c66b1264 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1427,7 +1427,7 @@ def test_clipboard_failure(capsys): # Make sure we got the error output out, err = capsys.readouterr() assert out == '' - assert 'Cannot redirect to paste buffer; install ``xclip`` and re-run to enable' in err + assert "Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable" in err class CmdResultApp(cmd2.Cmd): @@ -1685,12 +1685,6 @@ def test_alias_lookup_invalid_alias(base_app, capsys): out, err = capsys.readouterr() assert "not found" in err -def test_alias_with_invalid_name(base_app, capsys): - run_cmd(base_app, 'alias @ help') - out, err = capsys.readouterr() - assert "can only contain the following characters" in err - - def test_unalias(base_app): # Create an alias run_cmd(base_app, 'alias fake pyscript') @@ -1708,6 +1702,18 @@ def test_unalias_non_existing(base_app, capsys): out, err = capsys.readouterr() assert "does not exist" in err +@pytest.mark.parametrize('alias_name', [ + '">"', + '"no>pe"', + '"no spaces"', + '"nopipe|"', + '"noterm;"', + 'noembedded"quotes', +]) +def test_create_invalid_alias(base_app, alias_name, capsys): + run_cmd(base_app, 'alias {} help'.format(alias_name)) + out, err = capsys.readouterr() + assert "can not contain" in err def test_ppaged(base_app): msg = 'testing...' diff --git a/tests/test_completion.py b/tests/test_completion.py index 2d1ee2b7..2faa4a08 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -13,8 +13,7 @@ import os import sys import pytest -from cmd2 import cmd2 -import cmd2.submenu +import cmd2 from .conftest import complete_tester, StdOut from examples.subcommands import SubcommandsExample @@ -950,124 +949,3 @@ def test_subcommand_tab_completion_space_in_text_scu(scu_app): assert first_match is not None and \ scu_app.completion_matches == ['Ball" '] and \ scu_app.display_matches == ['Space Ball'] - -#################################################### - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - - def do_foo(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_foo(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_foo(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -second_level_cmd = SecondLevel() - - -@cmd2.submenu.AddSubmenu(second_level_cmd, - command='second', - require_predefined_shares=False) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - - -@pytest.fixture -def sb_app(): - app = SubmenuApp() - return app - - -def test_cmd2_submenu_completion_single_end(sb_app): - text = 'f' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - # It is at end of line, so extra space is present - assert first_match is not None and sb_app.completion_matches == ['foo '] - - -def test_cmd2_submenu_completion_multiple(sb_app): - text = 'e' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - expected = ['edit', 'eof', 'eos'] - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - assert first_match is not None and sb_app.completion_matches == expected - - -def test_cmd2_submenu_completion_nomatch(sb_app): - text = 'z' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_submenu_completion_after_submenu_match(sb_app): - text = 'a' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is not None and sb_app.completion_matches == ['asd '] - - -def test_cmd2_submenu_completion_after_submenu_nomatch(sb_app): - text = 'b' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_help_submenu_completion_multiple(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] - - -def test_cmd2_help_submenu_completion_nomatch(sb_app): - text = 'fake' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - assert sb_app.complete_help(text, line, begidx, endidx) == [] - - -def test_cmd2_help_submenu_completion_subcommands(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 19237f6e..7b361b7e 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -16,7 +16,7 @@ from cmd2 import utils def parser(): parser = StatementParser( allow_redirection=True, - terminators=[';'], + terminators=[';', '&'], multiline_commands=['multiline'], aliases={'helpalias': 'help', '42': 'theanswer', @@ -38,7 +38,13 @@ def test_parse_empty_string(parser): ('command /* with some comment */ arg', ['command', 'arg']), ('command arg1 arg2 # comment at the end', ['command', 'arg1', 'arg2']), ('42 arg1 arg2', ['theanswer', 'arg1', 'arg2']), - ('l', ['shell', 'ls', '-al']) + ('l', ['shell', 'ls', '-al']), + ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare & > /tmp/output', ['termbare', '&', '>', '/tmp/output']), + ('termbare& > /tmp/output', ['termbare', '&', '>', '/tmp/output']), + ('help|less', ['help', '|', 'less']), + ('l|less', ['shell', 'ls', '-al', '|', 'less']), ]) def test_tokenize(parser, line, tokens): tokens_to_test = parser.tokenize(line) @@ -46,7 +52,7 @@ def test_tokenize(parser, line, tokens): def test_tokenize_unclosed_quotes(parser): with pytest.raises(ValueError): - tokens = parser.tokenize('command with "unclosed quotes') + _ = parser.tokenize('command with "unclosed quotes') @pytest.mark.parametrize('tokens,command,args', [ ([], None, None), @@ -69,18 +75,28 @@ def test_parse_single_word(parser, line): assert not statement.args assert statement.argv == [utils.strip_quotes(line)] -def test_parse_word_plus_terminator(parser): - line = 'termbare;' +@pytest.mark.parametrize('line,terminator', [ + ('termbare;', ';'), + ('termbare ;', ';'), + ('termbare&', '&'), + ('termbare &', '&'), +]) +def test_parse_word_plus_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == ';' + assert statement.terminator == terminator assert statement.argv == ['termbare'] -def test_parse_suffix_after_terminator(parser): - line = 'termbare; suffx' +@pytest.mark.parametrize('line,terminator', [ + ('termbare; suffx', ';'), + ('termbare ;suffx', ';'), + ('termbare& suffx', '&'), + ('termbare &suffx', '&'), +]) +def test_parse_suffix_after_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == ';' + assert statement.terminator == terminator assert statement.suffix == 'suffx' assert statement.argv == ['termbare'] @@ -134,12 +150,16 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): assert not statement.pipe_to assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] -def test_parse_simple_piped(parser): - statement = parser.parse('simple | piped') +@pytest.mark.parametrize('line',[ + 'simple | piped', + 'simple|piped', +]) +def test_parse_simple_pipe(parser, line): + statement = parser.parse(line) assert statement.command == 'simple' assert not statement.args assert statement.argv == ['simple'] - assert statement.pipe_to == 'piped' + assert statement.pipe_to == ['piped'] def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' @@ -150,16 +170,29 @@ def test_parse_double_pipe_is_not_a_pipe(parser): assert not statement.pipe_to def test_parse_complex_pipe(parser): - line = 'command with args, terminator;sufx | piped' + line = 'command with args, terminator&sufx | piped' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == "with args, terminator" assert statement.argv == ['command', 'with', 'args,', 'terminator'] - assert statement.terminator == ';' + assert statement.terminator == '&' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'piped' + assert statement.pipe_to == ['piped'] + +@pytest.mark.parametrize('line,output', [ + ('help > out.txt', '>'), + ('help>out.txt', '>'), + ('help >> out.txt', '>>'), + ('help>>out.txt', '>>'), +]) +def test_parse_redirect(parser,line, output): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.output == output + assert statement.output_to == 'out.txt' -def test_parse_output_redirect(parser): +def test_parse_redirect_with_args(parser): line = 'output into > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -168,7 +201,7 @@ def test_parse_output_redirect(parser): assert statement.output == '>' assert statement.output_to == 'afile.txt' -def test_parse_output_redirect_with_dash_in_path(parser): +def test_parse_redirect_with_dash_in_path(parser): line = 'output into > python-cmd2/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -177,7 +210,7 @@ def test_parse_output_redirect_with_dash_in_path(parser): assert statement.output == '>' assert statement.output_to == 'python-cmd2/afile.txt' -def test_parse_output_redirect_append(parser): +def test_parse_redirect_append(parser): line = 'output appended to >> /tmp/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -194,9 +227,9 @@ def test_parse_pipe_and_redirect(parser): assert statement.argv == ['output', 'into'] assert statement.terminator == ';' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'pipethrume plz' - assert statement.output == '>' - assert statement.output_to == 'afile.txt' + assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt'] + assert not statement.output + assert not statement.output_to def test_parse_output_to_paste_buffer(parser): line = 'output to paste buffer >> ' @@ -207,8 +240,9 @@ def test_parse_output_to_paste_buffer(parser): assert statement.output == '>>' def test_parse_redirect_inside_terminator(parser): - """The terminator designates the end of the commmand/arguments portion. If a redirector - occurs before a terminator, then it will be treated as part of the arguments and not as a redirector.""" + """The terminator designates the end of the commmand/arguments portion. + If a redirector occurs before a terminator, then it will be treated as + part of the arguments and not as a redirector.""" line = 'has > inside;' statement = parser.parse(line) assert statement.command == 'has' @@ -216,6 +250,23 @@ def test_parse_redirect_inside_terminator(parser): assert statement.argv == ['has', '>', 'inside'] assert statement.terminator == ';' +@pytest.mark.parametrize('line,terminator',[ + ('multiline with | inside;', ';'), + ('multiline with | inside ;', ';'), + ('multiline with | inside;;;', ';'), + ('multiline with | inside;; ;;', ';'), + ('multiline with | inside&', '&'), + ('multiline with | inside &;', '&'), + ('multiline with | inside&&;', '&'), + ('multiline with | inside &; &;', '&'), +]) +def test_parse_multiple_terminators(parser, line, terminator): + statement = parser.parse(line) + assert statement.multiline_command == 'multiline' + assert statement.args == 'with | inside' + assert statement.argv == ['multiline', 'with', '|', 'inside'] + assert statement.terminator == terminator + def test_parse_unfinished_multiliine_command(parser): line = 'multiline has > inside an unfinished command' statement = parser.parse(line) @@ -225,13 +276,19 @@ def test_parse_unfinished_multiliine_command(parser): assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command'] assert not statement.terminator -def test_parse_multiline_command_ignores_redirectors_within_it(parser): - line = 'multiline has > inside;' +@pytest.mark.parametrize('line,terminator',[ + ('multiline has > inside;', ';'), + ('multiline has > inside;;;', ';'), + ('multiline has > inside;; ;;', ';'), + ('multiline has > inside &', '&'), + ('multiline has > inside & &', '&'), +]) +def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.args == 'has > inside' assert statement.argv == ['multiline', 'has', '>', 'inside'] - assert statement.terminator == ';' + assert statement.terminator == terminator def test_parse_multiline_with_incomplete_comment(parser): """A terminator within a comment will be ignored and won't terminate a multiline command. @@ -296,7 +353,7 @@ def test_parse_redirect_to_unicode_filename(parser): def test_parse_unclosed_quotes(parser): with pytest.raises(ValueError): - tokens = parser.tokenize("command with 'unclosed quotes") + _ = parser.tokenize("command with 'unclosed quotes") def test_empty_statement_raises_exception(): app = cmd2.Cmd() @@ -315,12 +372,12 @@ def test_empty_statement_raises_exception(): ('!ls -al /tmp', 'shell', 'ls -al /tmp'), ('l', 'shell', 'ls -al') ]) -def test_alias_and_shortcut_expansion(parser, line, command, args): +def test_parse_alias_and_shortcut_expansion(parser, line, command, args): statement = parser.parse(line) assert statement.command == command assert statement.args == args -def test_alias_on_multiline_command(parser): +def test_parse_alias_on_multiline_command(parser): line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) assert statement.multiline_command == 'multiline' @@ -328,6 +385,43 @@ def test_alias_on_multiline_command(parser): assert statement.args == 'has > inside an unfinished command' assert not statement.terminator +@pytest.mark.parametrize('line,output', [ + ('helpalias > out.txt', '>'), + ('helpalias>out.txt', '>'), + ('helpalias >> out.txt', '>>'), + ('helpalias>>out.txt', '>>'), +]) +def test_parse_alias_redirection(parser, line, output): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.output == output + assert statement.output_to == 'out.txt' + +@pytest.mark.parametrize('line', [ + 'helpalias | less', + 'helpalias|less', +]) +def test_parse_alias_pipe(parser, line): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.pipe_to == ['less'] + +@pytest.mark.parametrize('line', [ + 'helpalias;', + 'helpalias;;', + 'helpalias;; ;', + 'helpalias ;', + 'helpalias ; ;', + 'helpalias ;; ;', +]) +def test_parse_alias_terminator_no_whitespace(parser, line): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.terminator == ';' + def test_parse_command_only_command_and_args(parser): line = 'help history' statement = parser.parse_command_only(line) @@ -373,3 +467,34 @@ def test_parse_command_only_quoted_args(parser): assert statement.command == 'shell' assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' assert statement.command_and_args == line.replace('l', 'shell ls -al') + +@pytest.mark.parametrize('line', [ + 'helpalias > out.txt', + 'helpalias>out.txt', + 'helpalias >> out.txt', + 'helpalias>>out.txt', + 'help|less', + 'helpalias;', + 'help ;;', + 'help; ;;', +]) +def test_parse_command_only_specialchars(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == 'help' + +@pytest.mark.parametrize('line', [ + ';', + ';;', + ';; ;', + '&', + '& &', + ' && &', + '>', + "'", + '"', + '|', +]) +def test_parse_command_only_none(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == None + assert statement.args == None diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py index 8d0cefd8..73c1a62a 100644 --- a/tests/test_pyscript.py +++ b/tests/test_pyscript.py @@ -101,7 +101,14 @@ class PyscriptExample(Cmd): @with_argparser(bar_parser) def do_bar(self, args): - print('bar ' + str(args.__dict__)) + out = 'bar ' + arg_dict = args.__dict__ + keys = list(arg_dict.keys()) + keys.sort() + out += '{' + for key in keys: + out += "'{}':'{}'".format(key, arg_dict[key]) + print(out) @pytest.fixture @@ -160,7 +167,7 @@ def test_pyscript_help(ps_app, capsys, request, command, pyscript_file): ('foo aaa bbb -ccc -t -n', 'foo1.py'), ('foo 11 22 33 44 -ccc -t -n', 'foo2.py'), ('foo 11 22 33 44 55 66 -ccc', 'foo3.py'), - ('bar 11 22', 'bar1.py') + ('bar 11 22', 'bar1.py'), ]) def test_pyscript_out(ps_app, capsys, request, command, pyscript_file): test_dir = os.path.dirname(request.module.__file__) @@ -204,11 +211,30 @@ def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out): assert exp_out in expected -def test_pyscript_custom_name(ps_echo, capsys): +@pytest.mark.parametrize('expected, pyscript_file', [ + ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts', 'unalias']", + 'pyscript_dir1.py'), + ("['movies', 'shows']", 'pyscript_dir2.py') +]) +def test_pyscript_dir(ps_app, capsys, request, expected, pyscript_file): + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', pyscript_file) + + run_cmd(ps_app, 'pyscript {}'.format(python_script)) + out, _ = capsys.readouterr() + out = out.strip() + assert len(out) > 0 + assert out == expected + + +def test_pyscript_custom_name(ps_echo, capsys, request): message = 'blah!' - run_cmd(ps_echo, 'py custom.echo("{}")'.format(message)) + + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', 'custom_echo.py') + + run_cmd(ps_echo, 'pyscript {}'.format(python_script)) expected, _ = capsys.readouterr() assert len(expected) > 0 expected = expected.splitlines() assert message == expected[0] - diff --git a/tests/test_submenu.py b/tests/test_submenu.py deleted file mode 100644 index 2603c536..00000000 --- a/tests/test_submenu.py +++ /dev/null @@ -1,182 +0,0 @@ -# coding=utf-8 -""" -Cmd2 testing for argument parsing -""" -import pytest - -from cmd2 import cmd2 -import cmd2.submenu -from .conftest import run_cmd, StdOut, normalize - - -class SecondLevelB(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel B ' - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_set_top_level_attr(self, line): - self.top_level_attr = 987654321 - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - - def do_say(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_get_prompt(self, line): - self.poutput(self.prompt) - - -second_level_cmd = SecondLevel() -second_level_b_cmd = SecondLevelB() - - -@cmd2.submenu.AddSubmenu(SecondLevelB(), - command='should_work_with_default_kwargs') -@cmd2.submenu.AddSubmenu(second_level_b_cmd, - command='secondb', - shared_attributes=dict(top_level_attr='top_level_attr'), - require_predefined_shares=False, - preserve_shares=True - ) -@cmd2.submenu.AddSubmenu(second_level_cmd, - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_say(self, line): - self.poutput("You called a command in TopLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@pytest.fixture -def submenu_app(): - app = SubmenuApp() - app.stdout = StdOut() - second_level_cmd.stdout = StdOut() - second_level_b_cmd.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app(): - app = SecondLevel() - app.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app_b(): - app = SecondLevelB() - app.stdout = StdOut() - return app - - -def run_submenu_cmd(app, second_level_app, cmd): - """ Clear StdOut buffers, run the command, extract the buffer contents.""" - app.stdout.clear() - second_level_app.stdout.clear() - app.onecmd_plus_hooks(cmd) - out1 = app.stdout.buffer - out2 = second_level_app.stdout.buffer - app.stdout.clear() - second_level_app.stdout.clear() - return normalize(out1), normalize(out2) - - -def test_submenu_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'say ' + line) - assert len(out1) == 1 - assert len(out2) == 0 - assert out1[0] == "You called a command in TopLevel with {!r}.".format(line) - - -def test_submenu_second_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second say ' + line) - - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert len(out2) == 1 - assert out2[0] == "You called a command in SecondLevel with {!r}.".format(line) - - -def test_submenu_say_from_second_level(secondlevel_app): - line = 'testing' - out = run_cmd(secondlevel_app, 'say ' + line) - assert out == ["You called a command in SecondLevel with '%s'." % line] - - -def test_submenu_help_second_say_from_top_level(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert out2 == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_say_from_second_level(secondlevel_app): - out = run_cmd(secondlevel_app, 'help say') - assert out == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_second(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second') - out3 = run_cmd(second_level_cmd, 'help') - assert out2 == out3 - - -def test_submenu_from_top_help_second_say(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - out3 = run_cmd(second_level_cmd, 'help say') - assert out2 == out3 - - -def test_submenu_shared_attribute(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - - -def test_submenu_shared_attribute_preserve(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb set_top_level_attr') - assert submenu_app.top_level_attr == 987654321 - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(987654321)] |