Skip to content

Commit 3ee97d1

Browse files
authored
Merge pull request #676 from python-cmd2/pipe_chaining
Pipe chaining
2 parents cbf0313 + 6c05180 commit 3ee97d1

File tree

11 files changed

+346
-130
lines changed

11 files changed

+346
-130
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ before_script:
4646
# stop the build if there are Python syntax errors or undefined names
4747
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
4848
if [[ $TOXENV == py37 ]]; then
49-
flake8 . --count --ignore=E252 --max-complexity=31 --max-line-length=127 --show-source --statistics ;
49+
flake8 . --count --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics ;
5050
fi
5151

5252
script:

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
* History now shows what was typed for macros and not the resolved value by default. This is consistent with
66
the behavior of aliases. Use the `expanded` or `verbose` arguments to `history` to see the resolved value for
77
the macro.
8+
* Fixed parsing issue in case where output redirection appears before a pipe. In that case, the pipe was given
9+
precedence even though it appeared later in the command.
10+
* Fixed issue where quotes around redirection file paths were being lost in `Statement.expanded_command_line()`
811
* Enhancements
12+
* Added capability to chain pipe commands and redirect their output (e.g. !ls -l | grep user | wc -l > out.txt)
913
* `pyscript` limits a command's stdout capture to the same period that redirection does.
1014
Therefore output from a command's postparsing and finalization hooks isn't saved in the StdSim object.
1115
* `StdSim.buffer.write()` now flushes when the wrapped stream uses line buffering and the bytes being written
@@ -18,6 +22,7 @@
1822
* Potentially breaking changes
1923
* Replaced `unquote_redirection_tokens()` with `unquote_specific_tokens()`. This was to support the fix
2024
that allows terminators in alias and macro values.
25+
* Changed `Statement.pipe_to` to a string instead of a list
2126
* **Python 3.4 EOL notice**
2227
* Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019
2328
* This is the last release of `cmd2` which will support Python 3.4
@@ -87,7 +92,7 @@
8792
sorted the ``CompletionItem`` list. Otherwise it will be sorted using ``self.matches_sort_key``.
8893
* Removed support for bash completion since this feature had slow performance. Also it relied on
8994
``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods.
90-
* Removed ability to call commands in ``pyscript`` as if they were functions (e.g ``app.help()``) in favor
95+
* Removed ability to call commands in ``pyscript`` as if they were functions (e.g. ``app.help()``) in favor
9196
of only supporting one ``pyscript`` interface. This simplifies future maintenance.
9297
* No longer supporting C-style comments. Hash (#) is the only valid comment marker.
9398
* No longer supporting comments embedded in a command. Only command line input where the first

cmd2/argparse_completer.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -999,9 +999,7 @@ def error(self, message: str) -> None:
999999
linum += 1
10001000

10011001
self.print_usage(sys.stderr)
1002-
sys.stderr.write(Fore.LIGHTRED_EX + '{}\n'.format(formatted_message) + Fore.RESET)
1003-
1004-
sys.exit(1)
1002+
self.exit(2, Fore.LIGHTRED_EX + '{}\n\n'.format(formatted_message) + Fore.RESET)
10051003

10061004
def format_help(self) -> str:
10071005
"""Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters"""
@@ -1051,7 +1049,7 @@ def format_help(self) -> str:
10511049
formatter.add_text(self.epilog)
10521050

10531051
# determine help from format above
1054-
return formatter.format_help()
1052+
return formatter.format_help() + '\n'
10551053

10561054
def _get_nargs_pattern(self, action) -> str:
10571055
# Override _get_nargs_pattern behavior to use the nargs ranges provided by AutoCompleter

cmd2/cmd2.py

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,18 +1519,19 @@ def _complete_worker(self, text: str, state: int) -> Optional[str]:
15191519
# Check if any portion of the display matches appears in the tab completion
15201520
display_prefix = os.path.commonprefix(self.display_matches)
15211521

1522-
# For delimited matches, we check what appears before the display
1523-
# matches (common_prefix) as well as the display matches themselves.
1524-
if (' ' in common_prefix) or (display_prefix and ' ' in ''.join(self.display_matches)):
1522+
# For delimited matches, we check for a space in what appears before the display
1523+
# matches (common_prefix) as well as in the display matches themselves.
1524+
if ' ' in common_prefix or (display_prefix
1525+
and any(' ' in match for match in self.display_matches)):
15251526
add_quote = True
15261527

15271528
# If there is a tab completion and any match has a space, then add an opening quote
1528-
elif common_prefix and ' ' in ''.join(self.completion_matches):
1529+
elif common_prefix and any(' ' in match for match in self.completion_matches):
15291530
add_quote = True
15301531

15311532
if add_quote:
15321533
# Figure out what kind of quote to add and save it as the unclosed_quote
1533-
if '"' in ''.join(self.completion_matches):
1534+
if any('"' in match for match in self.completion_matches):
15341535
unclosed_quote = "'"
15351536
else:
15361537
unclosed_quote = '"'
@@ -1540,7 +1541,7 @@ def _complete_worker(self, text: str, state: int) -> Optional[str]:
15401541
# Check if we need to remove text from the beginning of tab completions
15411542
elif text_to_remove:
15421543
self.completion_matches = \
1543-
[m.replace(text_to_remove, '', 1) for m in self.completion_matches]
1544+
[match.replace(text_to_remove, '', 1) for match in self.completion_matches]
15441545

15451546
# Check if we need to restore a shortcut in the tab completions
15461547
# so it doesn't get erased from the command line
@@ -2027,35 +2028,44 @@ def _redirect_output(self, statement: Statement) -> Tuple[bool, utils.Redirectio
20272028
subproc_stdin = io.open(read_fd, 'r')
20282029
new_stdout = io.open(write_fd, 'w')
20292030

2030-
# We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True.
2031+
# Set options to not forward signals to the pipe process. If a Ctrl-C event occurs,
2032+
# our sigint handler will forward it only to the most recent pipe process. This makes
2033+
# sure pipe processes close in the right order (most recent first).
2034+
if sys.platform == 'win32':
2035+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
2036+
start_new_session = False
2037+
else:
2038+
creationflags = 0
2039+
start_new_session = True
2040+
2041+
# For any stream that is a StdSim, we will use a pipe so we can capture its output
2042+
proc = subprocess.Popen(statement.pipe_to,
2043+
stdin=subproc_stdin,
2044+
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout,
2045+
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
2046+
creationflags=creationflags,
2047+
start_new_session=start_new_session,
2048+
shell=True)
2049+
2050+
# Popen was called with shell=True so the user can chain pipe commands and redirect their output
2051+
# like: !ls -l | grep user | wc -l > out.txt. But this makes it difficult to know if the pipe process
2052+
# started OK, since the shell itself always starts. Therefore, we will wait a short time and check
2053+
# if the pipe process is still running.
20312054
try:
2032-
# Set options to not forward signals to the pipe process. If a Ctrl-C event occurs,
2033-
# our sigint handler will forward it only to the most recent pipe process. This makes
2034-
# sure pipe processes close in the right order (most recent first).
2035-
if sys.platform == 'win32':
2036-
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
2037-
start_new_session = False
2038-
else:
2039-
creationflags = 0
2040-
start_new_session = True
2041-
2042-
# For any stream that is a StdSim, we will use a pipe so we can capture its output
2043-
proc = \
2044-
subprocess.Popen(statement.pipe_to,
2045-
stdin=subproc_stdin,
2046-
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout,
2047-
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
2048-
creationflags=creationflags,
2049-
start_new_session=start_new_session)
2055+
proc.wait(0.2)
2056+
except subprocess.TimeoutExpired:
2057+
pass
20502058

2051-
saved_state.redirecting = True
2052-
saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
2053-
sys.stdout = self.stdout = new_stdout
2054-
except Exception as ex:
2055-
self.perror('Failed to open pipe because - {}'.format(ex), traceback_war=False)
2059+
# Check if the pipe process already exited
2060+
if proc.returncode is not None:
2061+
self.perror('Pipe process exited with code {} before command could run'.format(proc.returncode))
20562062
subproc_stdin.close()
20572063
new_stdout.close()
20582064
redir_error = True
2065+
else:
2066+
saved_state.redirecting = True
2067+
saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
2068+
sys.stdout = self.stdout = new_stdout
20592069

20602070
elif statement.output:
20612071
import tempfile
@@ -2072,7 +2082,7 @@ def _redirect_output(self, statement: Statement) -> Tuple[bool, utils.Redirectio
20722082
if statement.output == constants.REDIRECTION_APPEND:
20732083
mode = 'a'
20742084
try:
2075-
new_stdout = open(statement.output_to, mode)
2085+
new_stdout = open(utils.strip_quotes(statement.output_to), mode)
20762086
saved_state.redirecting = True
20772087
sys.stdout = self.stdout = new_stdout
20782088
except OSError as ex:
@@ -3021,21 +3031,8 @@ def do_shell(self, args: argparse.Namespace) -> None:
30213031
# Create a list of arguments to shell
30223032
tokens = [args.command] + args.command_args
30233033

3024-
# Support expanding ~ in quoted paths
3025-
for index, _ in enumerate(tokens):
3026-
if tokens[index]:
3027-
# Check if the token is quoted. Since parsing already passed, there isn't
3028-
# an unclosed quote. So we only need to check the first character.
3029-
first_char = tokens[index][0]
3030-
if first_char in constants.QUOTES:
3031-
tokens[index] = utils.strip_quotes(tokens[index])
3032-
3033-
tokens[index] = os.path.expanduser(tokens[index])
3034-
3035-
# Restore the quotes
3036-
if first_char in constants.QUOTES:
3037-
tokens[index] = first_char + tokens[index] + first_char
3038-
3034+
# Expand ~ where needed
3035+
utils.expand_user_in_tokens(tokens)
30393036
expanded_command = ' '.join(tokens)
30403037

30413038
# Prevent KeyboardInterrupts while in the shell process. The shell process will
@@ -3334,18 +3331,21 @@ def load_ipy(app):
33343331
help='output commands to a script file, implies -s'),
33353332
ACTION_ARG_CHOICES, ('path_complete',))
33363333
setattr(history_action_group.add_argument('-t', '--transcript',
3337-
help='output commands and results to a transcript file, implies -s'),
3334+
help='output commands and results to a transcript file,\n'
3335+
'implies -s'),
33383336
ACTION_ARG_CHOICES, ('path_complete',))
33393337
history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history')
33403338

33413339
history_format_group = history_parser.add_argument_group(title='formatting')
3342-
history_script_help = 'output commands in script format, i.e. without command numbers'
3343-
history_format_group.add_argument('-s', '--script', action='store_true', help=history_script_help)
3344-
history_expand_help = 'output expanded commands instead of entered command'
3345-
history_format_group.add_argument('-x', '--expanded', action='store_true', help=history_expand_help)
3340+
history_format_group.add_argument('-s', '--script', action='store_true',
3341+
help='output commands in script format, i.e. without command\n'
3342+
'numbers')
3343+
history_format_group.add_argument('-x', '--expanded', action='store_true',
3344+
help='output fully parsed commands with any aliases and\n'
3345+
'macros expanded, instead of typed commands')
33463346
history_format_group.add_argument('-v', '--verbose', action='store_true',
3347-
help='display history and include expanded commands if they'
3348-
' differ from the typed command')
3347+
help='display history and include expanded commands if they\n'
3348+
'differ from the typed command')
33493349

33503350
history_arg_help = ("empty all history items\n"
33513351
"a one history item by number\n"

cmd2/parsing.py

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# -*- coding: utf-8 -*-
33
"""Statement parsing classes for cmd2"""
44

5-
import os
65
import re
76
import shlex
87
from typing import Dict, Iterable, List, Optional, Tuple, Union
@@ -160,13 +159,13 @@ def do_mycommand(stmt):
160159
# characters appearing after the terminator but before output redirection, if any
161160
suffix = attr.ib(default='', validator=attr.validators.instance_of(str))
162161

163-
# if output was piped to a shell command, the shell command as a list of tokens
164-
pipe_to = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list))
162+
# if output was piped to a shell command, the shell command as a string
163+
pipe_to = attr.ib(default='', validator=attr.validators.instance_of(str))
165164

166165
# if output was redirected, the redirection token, i.e. '>>'
167166
output = attr.ib(default='', validator=attr.validators.instance_of(str))
168167

169-
# if output was redirected, the destination file
168+
# if output was redirected, the destination file token (quotes preserved)
170169
output_to = attr.ib(default='', validator=attr.validators.instance_of(str))
171170

172171
def __new__(cls, value: object, *pos_args, **kw_args):
@@ -208,7 +207,7 @@ def post_command(self) -> str:
208207
rtn += ' ' + self.suffix
209208

210209
if self.pipe_to:
211-
rtn += ' | ' + ' '.join(self.pipe_to)
210+
rtn += ' | ' + self.pipe_to
212211

213212
if self.output:
214213
rtn += ' ' + self.output
@@ -453,56 +452,56 @@ def parse(self, line: str, expand: bool = True) -> Statement:
453452
arg_list = tokens[1:]
454453
tokens = []
455454

456-
# check for a pipe to a shell process
457-
# if there is a pipe, everything after the pipe needs to be passed
458-
# to the shell, even redirected output
459-
# this allows '(Cmd) say hello | wc > countit.txt'
460-
try:
461-
# find the first pipe if it exists
462-
pipe_pos = tokens.index(constants.REDIRECTION_PIPE)
463-
# save everything after the first pipe as tokens
464-
pipe_to = tokens[pipe_pos + 1:]
455+
pipe_to = ''
456+
output = ''
457+
output_to = ''
465458

466-
for pos, cur_token in enumerate(pipe_to):
467-
unquoted_token = utils.strip_quotes(cur_token)
468-
pipe_to[pos] = os.path.expanduser(unquoted_token)
459+
# Find which redirector character appears first in the command
460+
try:
461+
pipe_index = tokens.index(constants.REDIRECTION_PIPE)
462+
except ValueError:
463+
pipe_index = len(tokens)
469464

470-
# remove all the tokens after the pipe
471-
tokens = tokens[:pipe_pos]
465+
try:
466+
redir_index = tokens.index(constants.REDIRECTION_OUTPUT)
472467
except ValueError:
473-
# no pipe in the tokens
474-
pipe_to = []
468+
redir_index = len(tokens)
475469

476-
# check for output redirect
477-
output = ''
478-
output_to = ''
479470
try:
480-
output_pos = tokens.index(constants.REDIRECTION_OUTPUT)
481-
output = constants.REDIRECTION_OUTPUT
471+
append_index = tokens.index(constants.REDIRECTION_APPEND)
472+
except ValueError:
473+
append_index = len(tokens)
482474

483-
# Check if we are redirecting to a file
484-
if len(tokens) > output_pos + 1:
485-
unquoted_path = utils.strip_quotes(tokens[output_pos + 1])
486-
output_to = os.path.expanduser(unquoted_path)
475+
# Check if output should be piped to a shell command
476+
if pipe_index < redir_index and pipe_index < append_index:
487477

488-
# remove all the tokens after the output redirect
489-
tokens = tokens[:output_pos]
490-
except ValueError:
491-
pass
478+
# Get the tokens for the pipe command and expand ~ where needed
479+
pipe_to_tokens = tokens[pipe_index + 1:]
480+
utils.expand_user_in_tokens(pipe_to_tokens)
492481

493-
try:
494-
output_pos = tokens.index(constants.REDIRECTION_APPEND)
495-
output = constants.REDIRECTION_APPEND
482+
# Build the pipe command line string
483+
pipe_to = ' '.join(pipe_to_tokens)
484+
485+
# remove all the tokens after the pipe
486+
tokens = tokens[:pipe_index]
487+
488+
# Check for output redirect/append
489+
elif redir_index != append_index:
490+
if redir_index < append_index:
491+
output = constants.REDIRECTION_OUTPUT
492+
output_index = redir_index
493+
else:
494+
output = constants.REDIRECTION_APPEND
495+
output_index = append_index
496496

497497
# Check if we are redirecting to a file
498-
if len(tokens) > output_pos + 1:
499-
unquoted_path = utils.strip_quotes(tokens[output_pos + 1])
500-
output_to = os.path.expanduser(unquoted_path)
498+
if len(tokens) > output_index + 1:
499+
unquoted_path = utils.strip_quotes(tokens[output_index + 1])
500+
if unquoted_path:
501+
output_to = utils.expand_user(tokens[output_index + 1])
501502

502-
# remove all tokens after the output redirect
503-
tokens = tokens[:output_pos]
504-
except ValueError:
505-
pass
503+
# remove all the tokens after the output redirect
504+
tokens = tokens[:output_index]
506505

507506
if terminator:
508507
# whatever is left is the suffix

0 commit comments

Comments
 (0)