diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2019-05-13 23:59:03 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-05-13 23:59:03 -0400 |
commit | 3ee97d121887d3055fc6326b1d9bc290f5235866 (patch) | |
tree | f5695aece2c4e6173513da3f436df73099b88c09 | |
parent | cbf0313306c99c02f3c503f60d70df4bda2cce64 (diff) | |
parent | 6c051808d83b75108c0549acbc97fe2201f8de63 (diff) | |
download | cmd2-git-3ee97d121887d3055fc6326b1d9bc290f5235866.tar.gz |
Merge pull request #676 from python-cmd2/pipe_chaining
Pipe chaining
-rw-r--r-- | .travis.yml | 2 | ||||
-rw-r--r-- | CHANGELOG.md | 7 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 6 | ||||
-rw-r--r-- | cmd2/cmd2.py | 106 | ||||
-rw-r--r-- | cmd2/parsing.py | 87 | ||||
-rw-r--r-- | cmd2/utils.py | 30 | ||||
-rw-r--r-- | tasks.py | 2 | ||||
-rw-r--r-- | tests/conftest.py | 12 | ||||
-rw-r--r-- | tests/test_cmd2.py | 18 | ||||
-rw-r--r-- | tests/test_completion.py | 45 | ||||
-rw-r--r-- | tests/test_parsing.py | 161 |
11 files changed, 346 insertions, 130 deletions
diff --git a/.travis.yml b/.travis.yml index fda08075..fc9c65bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -46,7 +46,7 @@ before_script: # stop the build if there are Python syntax errors or undefined names # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide if [[ $TOXENV == py37 ]]; then - flake8 . --count --ignore=E252 --max-complexity=31 --max-line-length=127 --show-source --statistics ; + flake8 . --count --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics ; fi script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 26dd2041..a6f56821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ * History now shows what was typed for macros and not the resolved value by default. This is consistent with the behavior of aliases. Use the `expanded` or `verbose` arguments to `history` to see the resolved value for the macro. + * Fixed parsing issue in case where output redirection appears before a pipe. In that case, the pipe was given + precedence even though it appeared later in the command. + * Fixed issue where quotes around redirection file paths were being lost in `Statement.expanded_command_line()` * Enhancements + * Added capability to chain pipe commands and redirect their output (e.g. !ls -l | grep user | wc -l > out.txt) * `pyscript` limits a command's stdout capture to the same period that redirection does. Therefore output from a command's postparsing and finalization hooks isn't saved in the StdSim object. * `StdSim.buffer.write()` now flushes when the wrapped stream uses line buffering and the bytes being written @@ -18,6 +22,7 @@ * Potentially breaking changes * Replaced `unquote_redirection_tokens()` with `unquote_specific_tokens()`. This was to support the fix that allows terminators in alias and macro values. + * Changed `Statement.pipe_to` to a string instead of a list * **Python 3.4 EOL notice** * Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019 * This is the last release of `cmd2` which will support Python 3.4 @@ -87,7 +92,7 @@ sorted the ``CompletionItem`` list. Otherwise it will be sorted using ``self.matches_sort_key``. * Removed support for bash completion since this feature had slow performance. Also it relied on ``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods. - * Removed ability to call commands in ``pyscript`` as if they were functions (e.g ``app.help()``) in favor + * Removed ability to call commands in ``pyscript`` as if they were functions (e.g. ``app.help()``) in favor of only supporting one ``pyscript`` interface. This simplifies future maintenance. * No longer supporting C-style comments. Hash (#) is the only valid comment marker. * No longer supporting comments embedded in a command. Only command line input where the first diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index edfaeec4..feff4835 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -999,9 +999,7 @@ class ACArgumentParser(argparse.ArgumentParser): linum += 1 self.print_usage(sys.stderr) - sys.stderr.write(Fore.LIGHTRED_EX + '{}\n'.format(formatted_message) + Fore.RESET) - - sys.exit(1) + self.exit(2, Fore.LIGHTRED_EX + '{}\n\n'.format(formatted_message) + Fore.RESET) def format_help(self) -> str: """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters""" @@ -1051,7 +1049,7 @@ class ACArgumentParser(argparse.ArgumentParser): formatter.add_text(self.epilog) # determine help from format above - return formatter.format_help() + return formatter.format_help() + '\n' def _get_nargs_pattern(self, action) -> str: # Override _get_nargs_pattern behavior to use the nargs ranges provided by AutoCompleter diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 431c51ae..c29a1812 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1519,18 +1519,19 @@ class Cmd(cmd.Cmd): # Check if any portion of the display matches appears in the tab completion display_prefix = os.path.commonprefix(self.display_matches) - # For delimited matches, we check what appears before the display - # matches (common_prefix) as well as the display matches themselves. - if (' ' in common_prefix) or (display_prefix and ' ' in ''.join(self.display_matches)): + # For delimited matches, we check for a space in what appears before the display + # matches (common_prefix) as well as in the display matches themselves. + if ' ' in common_prefix or (display_prefix + and any(' ' in match for match in self.display_matches)): add_quote = True # If there is a tab completion and any match has a space, then add an opening quote - elif common_prefix and ' ' in ''.join(self.completion_matches): + elif common_prefix and any(' ' in match for match in self.completion_matches): add_quote = True if add_quote: # Figure out what kind of quote to add and save it as the unclosed_quote - if '"' in ''.join(self.completion_matches): + if any('"' in match for match in self.completion_matches): unclosed_quote = "'" else: unclosed_quote = '"' @@ -1540,7 +1541,7 @@ class Cmd(cmd.Cmd): # Check if we need to remove text from the beginning of tab completions elif text_to_remove: self.completion_matches = \ - [m.replace(text_to_remove, '', 1) for m in self.completion_matches] + [match.replace(text_to_remove, '', 1) for match in self.completion_matches] # Check if we need to restore a shortcut in the tab completions # so it doesn't get erased from the command line @@ -2027,35 +2028,44 @@ class Cmd(cmd.Cmd): subproc_stdin = io.open(read_fd, 'r') new_stdout = io.open(write_fd, 'w') - # We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True. + # Set options to not forward signals to the pipe process. If a Ctrl-C event occurs, + # our sigint handler will forward it only to the most recent pipe process. This makes + # sure pipe processes close in the right order (most recent first). + if sys.platform == 'win32': + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP + start_new_session = False + else: + creationflags = 0 + start_new_session = True + + # For any stream that is a StdSim, we will use a pipe so we can capture its output + proc = subprocess.Popen(statement.pipe_to, + stdin=subproc_stdin, + stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, + stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, + creationflags=creationflags, + start_new_session=start_new_session, + shell=True) + + # Popen was called with shell=True so the user can chain pipe commands and redirect their output + # like: !ls -l | grep user | wc -l > out.txt. But this makes it difficult to know if the pipe process + # started OK, since the shell itself always starts. Therefore, we will wait a short time and check + # if the pipe process is still running. try: - # Set options to not forward signals to the pipe process. If a Ctrl-C event occurs, - # our sigint handler will forward it only to the most recent pipe process. This makes - # sure pipe processes close in the right order (most recent first). - if sys.platform == 'win32': - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - start_new_session = False - else: - creationflags = 0 - start_new_session = True - - # For any stream that is a StdSim, we will use a pipe so we can capture its output - proc = \ - subprocess.Popen(statement.pipe_to, - stdin=subproc_stdin, - stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, - stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, - creationflags=creationflags, - start_new_session=start_new_session) + proc.wait(0.2) + except subprocess.TimeoutExpired: + pass - saved_state.redirecting = True - saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) - sys.stdout = self.stdout = new_stdout - except Exception as ex: - self.perror('Failed to open pipe because - {}'.format(ex), traceback_war=False) + # Check if the pipe process already exited + if proc.returncode is not None: + self.perror('Pipe process exited with code {} before command could run'.format(proc.returncode)) subproc_stdin.close() new_stdout.close() redir_error = True + else: + saved_state.redirecting = True + saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) + sys.stdout = self.stdout = new_stdout elif statement.output: import tempfile @@ -2072,7 +2082,7 @@ class Cmd(cmd.Cmd): if statement.output == constants.REDIRECTION_APPEND: mode = 'a' try: - new_stdout = open(statement.output_to, mode) + new_stdout = open(utils.strip_quotes(statement.output_to), mode) saved_state.redirecting = True sys.stdout = self.stdout = new_stdout except OSError as ex: @@ -3021,21 +3031,8 @@ class Cmd(cmd.Cmd): # Create a list of arguments to shell tokens = [args.command] + args.command_args - # Support expanding ~ in quoted paths - for index, _ in enumerate(tokens): - if tokens[index]: - # Check if the token is quoted. Since parsing already passed, there isn't - # an unclosed quote. So we only need to check the first character. - first_char = tokens[index][0] - if first_char in constants.QUOTES: - tokens[index] = utils.strip_quotes(tokens[index]) - - tokens[index] = os.path.expanduser(tokens[index]) - - # Restore the quotes - if first_char in constants.QUOTES: - tokens[index] = first_char + tokens[index] + first_char - + # Expand ~ where needed + utils.expand_user_in_tokens(tokens) expanded_command = ' '.join(tokens) # Prevent KeyboardInterrupts while in the shell process. The shell process will @@ -3334,18 +3331,21 @@ class Cmd(cmd.Cmd): help='output commands to a script file, implies -s'), ACTION_ARG_CHOICES, ('path_complete',)) setattr(history_action_group.add_argument('-t', '--transcript', - help='output commands and results to a transcript file, implies -s'), + help='output commands and results to a transcript file,\n' + 'implies -s'), ACTION_ARG_CHOICES, ('path_complete',)) history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') history_format_group = history_parser.add_argument_group(title='formatting') - history_script_help = 'output commands in script format, i.e. without command numbers' - history_format_group.add_argument('-s', '--script', action='store_true', help=history_script_help) - history_expand_help = 'output expanded commands instead of entered command' - history_format_group.add_argument('-x', '--expanded', action='store_true', help=history_expand_help) + history_format_group.add_argument('-s', '--script', action='store_true', + help='output commands in script format, i.e. without command\n' + 'numbers') + history_format_group.add_argument('-x', '--expanded', action='store_true', + help='output fully parsed commands with any aliases and\n' + 'macros expanded, instead of typed commands') history_format_group.add_argument('-v', '--verbose', action='store_true', - help='display history and include expanded commands if they' - ' differ from the typed command') + help='display history and include expanded commands if they\n' + 'differ from the typed command') history_arg_help = ("empty all history items\n" "a one history item by number\n" diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 934f1d26..8febd270 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- """Statement parsing classes for cmd2""" -import os import re import shlex from typing import Dict, Iterable, List, Optional, Tuple, Union @@ -160,13 +159,13 @@ class Statement(str): # characters appearing after the terminator but before output redirection, if any suffix = attr.ib(default='', validator=attr.validators.instance_of(str)) - # if output was piped to a shell command, the shell command as a list of tokens - pipe_to = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list)) + # if output was piped to a shell command, the shell command as a string + pipe_to = attr.ib(default='', validator=attr.validators.instance_of(str)) # if output was redirected, the redirection token, i.e. '>>' output = attr.ib(default='', validator=attr.validators.instance_of(str)) - # if output was redirected, the destination file + # if output was redirected, the destination file token (quotes preserved) output_to = attr.ib(default='', validator=attr.validators.instance_of(str)) def __new__(cls, value: object, *pos_args, **kw_args): @@ -208,7 +207,7 @@ class Statement(str): rtn += ' ' + self.suffix if self.pipe_to: - rtn += ' | ' + ' '.join(self.pipe_to) + rtn += ' | ' + self.pipe_to if self.output: rtn += ' ' + self.output @@ -453,56 +452,56 @@ class StatementParser: arg_list = tokens[1:] 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:] + pipe_to = '' + output = '' + output_to = '' - for pos, cur_token in enumerate(pipe_to): - unquoted_token = utils.strip_quotes(cur_token) - pipe_to[pos] = os.path.expanduser(unquoted_token) + # Find which redirector character appears first in the command + try: + pipe_index = tokens.index(constants.REDIRECTION_PIPE) + except ValueError: + pipe_index = len(tokens) - # remove all the tokens after the pipe - tokens = tokens[:pipe_pos] + try: + redir_index = tokens.index(constants.REDIRECTION_OUTPUT) except ValueError: - # no pipe in the tokens - pipe_to = [] + redir_index = len(tokens) - # check for output redirect - output = '' - output_to = '' try: - output_pos = tokens.index(constants.REDIRECTION_OUTPUT) - output = constants.REDIRECTION_OUTPUT + append_index = tokens.index(constants.REDIRECTION_APPEND) + except ValueError: + append_index = len(tokens) - # Check if we are redirecting to a file - if len(tokens) > output_pos + 1: - unquoted_path = utils.strip_quotes(tokens[output_pos + 1]) - output_to = os.path.expanduser(unquoted_path) + # Check if output should be piped to a shell command + if pipe_index < redir_index and pipe_index < append_index: - # remove all the tokens after the output redirect - tokens = tokens[:output_pos] - except ValueError: - pass + # Get the tokens for the pipe command and expand ~ where needed + pipe_to_tokens = tokens[pipe_index + 1:] + utils.expand_user_in_tokens(pipe_to_tokens) - try: - output_pos = tokens.index(constants.REDIRECTION_APPEND) - output = constants.REDIRECTION_APPEND + # Build the pipe command line string + pipe_to = ' '.join(pipe_to_tokens) + + # remove all the tokens after the pipe + tokens = tokens[:pipe_index] + + # Check for output redirect/append + elif redir_index != append_index: + if redir_index < append_index: + output = constants.REDIRECTION_OUTPUT + output_index = redir_index + else: + output = constants.REDIRECTION_APPEND + output_index = append_index # Check if we are redirecting to a file - if len(tokens) > output_pos + 1: - unquoted_path = utils.strip_quotes(tokens[output_pos + 1]) - output_to = os.path.expanduser(unquoted_path) + if len(tokens) > output_index + 1: + unquoted_path = utils.strip_quotes(tokens[output_index + 1]) + if unquoted_path: + output_to = utils.expand_user(tokens[output_index + 1]) - # remove all tokens after the output redirect - tokens = tokens[:output_pos] - except ValueError: - pass + # remove all the tokens after the output redirect + tokens = tokens[:output_index] if terminator: # whatever is left is the suffix diff --git a/cmd2/utils.py b/cmd2/utils.py index e8e8a611..54ad763d 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -275,6 +275,36 @@ def unquote_specific_tokens(args: List[str], tokens_to_unquote: List[str]) -> No args[i] = unquoted_arg +def expand_user(token: str) -> str: + """ + Wrap os.expanduser() to support expanding ~ in quoted strings + :param token: the string to expand + """ + if token: + if is_quoted(token): + quote_char = token[0] + token = strip_quotes(token) + else: + quote_char = '' + + token = os.path.expanduser(token) + + # Restore the quotes even if not needed to preserve what the user typed + if quote_char: + token = quote_char + token + quote_char + + return token + + +def expand_user_in_tokens(tokens: List[str]) -> None: + """ + Call expand_user() on all tokens in a list of strings + :param tokens: tokens to expand + """ + for index, _ in enumerate(tokens): + tokens[index] = expand_user(tokens[index]) + + def find_editor() -> str: """Find a reasonable editor to use by default for the system that the cmd2 application is running on.""" editor = os.environ.get('EDITOR') @@ -233,5 +233,5 @@ namespace.add_task(pypi_test) @invoke.task def flake8(context): "Run flake8 linter and tool for style guide enforcement" - context.run("flake8 --ignore=E252 --max-complexity=31 --max-line-length=127 --show-source --statistics") + context.run("flake8 --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics") namespace.add_task(flake8) diff --git a/tests/conftest.py b/tests/conftest.py index 56538718..9d55eb4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,13 +77,17 @@ optional arguments: -o, --output-file FILE output commands to a script file, implies -s -t, --transcript TRANSCRIPT - output commands and results to a transcript file, implies -s + output commands and results to a transcript file, + implies -s -c, --clear clear all history formatting: - -s, --script output commands in script format, i.e. without command numbers - -x, --expanded output expanded commands instead of entered command - -v, --verbose display history and include expanded commands if they differ from the typed command + -s, --script output commands in script format, i.e. without command + numbers + -x, --expanded output fully parsed commands with any aliases and + macros expanded, instead of typed commands + -v, --verbose display history and include expanded commands if they + differ from the typed command """ diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 5f6af8c5..7a17cfac 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -576,11 +576,26 @@ def test_pipe_to_shell(base_app): out, err = run_cmd(base_app, command) assert out and not err +def test_pipe_to_shell_and_redirect(base_app): + filename = 'out.txt' + if sys.platform == "win32": + # Windows + command = 'help | sort > {}'.format(filename) + else: + # Mac and Linux + # Get help on help and pipe it's output to the input of the word count shell command + command = 'help help | wc > {}'.format(filename) + + out, err = run_cmd(base_app, command) + assert not out and not err + assert os.path.exists(filename) + os.remove(filename) + def test_pipe_to_shell_error(base_app): # Try to pipe command output to a shell command that doesn't exist in order to produce an error out, err = run_cmd(base_app, 'help | foobarbaz.this_does_not_exist') assert not out - assert "Failed to open pipe because" in err[0] + assert "Pipe process exited with code" in err[0] @pytest.mark.skipif(not clipboard.can_clip, reason="Pyperclip could not find a copy/paste mechanism for your system") @@ -1713,7 +1728,6 @@ def test_macro_create_with_alias_name(base_app): assert "Macro cannot have the same name as an alias" in err[0] def test_macro_create_with_command_name(base_app): - macro = "my_macro" out, err = run_cmd(base_app, 'macro create help stuff') assert "Macro cannot have the same name as a command" in err[0] diff --git a/tests/test_completion.py b/tests/test_completion.py index 158856ec..6fd45ff9 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -20,7 +20,7 @@ from .conftest import base_app, complete_tester, normalize, run_cmd from examples.subcommands import SubcommandsExample # List of strings used with completion functions -food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] +food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato', 'Cheese "Pizza"'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] delimited_strs = \ [ @@ -83,6 +83,17 @@ class CompletionsExample(cmd2.Cmd): def complete_test_raise_exception(self, text, line, begidx, endidx): raise IndexError("You are out of bounds!!") + def do_test_no_completer(self, args): + """Completing this should result in completedefault() being called""" + pass + + def completedefault(self, *ignored): + """Method called to complete an input line when no command-specific + complete_*() method is available. + + """ + return ['default'] + @pytest.fixture def cmd2_app(): @@ -123,8 +134,9 @@ def test_complete_bogus_command(cmd2_app): endidx = len(line) begidx = endidx - len(text) + expected = ['default '] first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None + assert first_match is not None and cmd2_app.completion_matches == expected def test_complete_exception(cmd2_app, capsys): text = '' @@ -737,6 +749,16 @@ def test_add_opening_quote_basic_quote_added(cmd2_app): first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is not None and cmd2_app.completion_matches == expected +def test_add_opening_quote_basic_single_quote_added(cmd2_app): + text = 'Ch' + line = 'test_basic {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + expected = ["'Cheese \"Pizza\"' "] + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == expected + def test_add_opening_quote_basic_text_is_common_prefix(cmd2_app): # This tests when the text entered is the same as the common prefix of the matches text = 'Ham' @@ -816,6 +838,25 @@ def test_add_opening_quote_delimited_space_in_prefix(cmd2_app): os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix and \ cmd2_app.display_matches == expected_display +def test_no_completer(cmd2_app): + text = '' + line = 'test_no_completer {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + expected = ['default '] + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == expected + +def test_quote_as_command(cmd2_app): + text = '' + line = '" {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None and not cmd2_app.completion_matches + @pytest.fixture def sc_app(): c = SubcommandsExample() diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 09215804..de8d67af 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -11,7 +11,6 @@ import pytest import cmd2 from cmd2 import constants, utils -from cmd2.constants import MULTILINE_TERMINATOR from cmd2.parsing import StatementParser, shlex_split @pytest.fixture @@ -46,7 +45,7 @@ def test_parse_empty_string(parser): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' assert statement.command_and_args == line @@ -63,7 +62,7 @@ def test_parse_empty_string_default(default_parser): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' assert statement.command_and_args == line @@ -130,7 +129,7 @@ def test_parse_single_word(parser, line): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' assert statement.command_and_args == line @@ -224,8 +223,8 @@ def test_parse_simple_pipe(parser, line): assert statement.args == statement assert statement.argv == ['simple'] assert not statement.arg_list - assert statement.pipe_to == ['piped'] - assert statement.expanded_command_line == statement.command + ' | ' + ' '.join(statement.pipe_to) + assert statement.pipe_to == 'piped' + assert statement.expanded_command_line == statement.command + ' | ' + statement.pipe_to def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' @@ -247,7 +246,7 @@ def test_parse_complex_pipe(parser): assert statement.arg_list == statement.argv[1:] 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', '>'), @@ -297,7 +296,7 @@ def test_parse_redirect_append(parser): assert statement.output == '>>' assert statement.output_to == '/tmp/afile.txt' -def test_parse_pipe_and_redirect(parser): +def test_parse_pipe_then_redirect(parser): line = 'output into;sufx | pipethrume plz > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -307,10 +306,136 @@ def test_parse_pipe_and_redirect(parser): assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' assert statement.suffix == 'sufx' - assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt'] + assert statement.pipe_to == 'pipethrume plz > afile.txt' assert statement.output == '' assert statement.output_to == '' +def test_parse_multiple_pipes(parser): + line = 'output into;sufx | pipethrume plz | grep blah' + statement = parser.parse(line) + assert statement.command == 'output' + assert statement == 'into' + assert statement.args == statement + assert statement.argv == ['output', 'into'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == ';' + assert statement.suffix == 'sufx' + assert statement.pipe_to == 'pipethrume plz | grep blah' + assert statement.output == '' + assert statement.output_to == '' + +def test_redirect_then_pipe(parser): + line = 'help alias > file.txt | grep blah' + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>' + assert statement.output_to == 'file.txt' + +def test_append_then_pipe(parser): + line = 'help alias >> file.txt | grep blah' + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>>' + assert statement.output_to == 'file.txt' + +def test_append_then_redirect(parser): + line = 'help alias >> file.txt > file2.txt' + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>>' + assert statement.output_to == 'file.txt' + +def test_redirect_then_append(parser): + line = 'help alias > file.txt >> file2.txt' + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>' + assert statement.output_to == 'file.txt' + +def test_redirect_to_quoted_string(parser): + line = 'help alias > "file.txt"' + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>' + assert statement.output_to == '"file.txt"' + +def test_redirect_to_single_quoted_string(parser): + line = "help alias > 'file.txt'" + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>' + assert statement.output_to == "'file.txt'" + +def test_redirect_to_empty_quoted_string(parser): + line = 'help alias > ""' + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>' + assert statement.output_to == '' + +def test_redirect_to_empty_single_quoted_string(parser): + line = "help alias > ''" + statement = parser.parse(line) + assert statement.command == 'help' + assert statement == 'alias' + assert statement.args == statement + assert statement.argv == ['help', 'alias'] + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == '' + assert statement.output == '>' + assert statement.output_to == '' + def test_parse_output_to_paste_buffer(parser): line = 'output to paste buffer >> ' statement = parser.parse(line) @@ -516,7 +641,7 @@ def test_parse_alias_pipe(parser, line): assert statement.command == 'help' assert statement == '' assert statement.args == statement - assert statement.pipe_to == ['less'] + assert statement.pipe_to == 'less' @pytest.mark.parametrize('line', [ 'helpalias;', @@ -545,7 +670,7 @@ def test_parse_command_only_command_and_args(parser): assert statement.raw == line assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -561,7 +686,7 @@ def test_parse_command_only_strips_line(parser): assert statement.raw == line assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -577,7 +702,7 @@ def test_parse_command_only_expands_alias(parser): assert statement.raw == line assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -594,7 +719,7 @@ def test_parse_command_only_expands_shortcuts(parser): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -611,7 +736,7 @@ def test_parse_command_only_quoted_args(parser): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -635,7 +760,7 @@ def test_parse_command_only_specialchars(parser, line, args): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -664,7 +789,7 @@ def test_parse_command_only_empty(parser, line): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == [] + assert statement.pipe_to == '' assert statement.output == '' assert statement.output_to == '' @@ -692,7 +817,7 @@ def test_statement_initialization(): assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' - assert isinstance(statement.pipe_to, list) + assert isinstance(statement.pipe_to, str) assert not statement.pipe_to assert statement.output == '' assert statement.output_to == '' |