Skip to content

Commit cedc154

Browse files
committed
Added capability to redirect pipe commands and chain them together
1 parent f9ea58e commit cedc154

File tree

5 files changed

+84
-68
lines changed

5 files changed

+84
-68
lines changed

cmd2/cmd2.py

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2027,35 +2027,44 @@ def _redirect_output(self, statement: Statement) -> Tuple[bool, utils.Redirectio
20272027
subproc_stdin = io.open(read_fd, 'r')
20282028
new_stdout = io.open(write_fd, 'w')
20292029

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

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)
2058+
# Check if the pipe process already exited
2059+
if proc.returncode is not None:
2060+
self.perror('Pipe process exited with code {} before command could run'.format(proc.returncode))
20562061
subproc_stdin.close()
20572062
new_stdout.close()
20582063
redir_error = True
2064+
else:
2065+
saved_state.redirecting = True
2066+
saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
2067+
sys.stdout = self.stdout = new_stdout
20592068

20602069
elif statement.output:
20612070
import tempfile
@@ -3021,21 +3030,8 @@ def do_shell(self, args: argparse.Namespace) -> None:
30213030
# Create a list of arguments to shell
30223031
tokens = [args.command] + args.command_args
30233032

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-
3033+
# Expand ~ where needed
3034+
utils.expand_user_in_tokens(tokens)
30393035
expanded_command = ' '.join(tokens)
30403036

30413037
# Prevent KeyboardInterrupts while in the shell process. The shell process will

cmd2/parsing.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ def do_mycommand(stmt):
160160
# characters appearing after the terminator but before output redirection, if any
161161
suffix = attr.ib(default='', validator=attr.validators.instance_of(str))
162162

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))
163+
# if output was piped to a shell command, the shell command as a string
164+
pipe_to = attr.ib(default='', validator=attr.validators.instance_of(str))
165165

166166
# if output was redirected, the redirection token, i.e. '>>'
167167
output = attr.ib(default='', validator=attr.validators.instance_of(str))
@@ -208,12 +208,12 @@ def post_command(self) -> str:
208208
rtn += ' ' + self.suffix
209209

210210
if self.pipe_to:
211-
rtn += ' | ' + ' '.join(self.pipe_to)
211+
rtn += ' | ' + self.pipe_to
212212

213213
if self.output:
214214
rtn += ' ' + self.output
215215
if self.output_to:
216-
rtn += ' ' + self.output_to
216+
rtn += ' ' + utils.quote_string_if_needed(self.output_to)
217217

218218
return rtn
219219

@@ -460,18 +460,18 @@ def parse(self, line: str, expand: bool = True) -> Statement:
460460
try:
461461
# find the first pipe if it exists
462462
pipe_pos = tokens.index(constants.REDIRECTION_PIPE)
463-
# save everything after the first pipe as tokens
464-
pipe_to = tokens[pipe_pos + 1:]
465463

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)
464+
# Get the tokens for the pipe command and expand ~ where needed
465+
pipe_to_tokens = tokens[pipe_pos + 1:]
466+
utils.expand_user_in_tokens(pipe_to_tokens)
467+
468+
# Build the pipe command line string
469+
pipe_to = ' '.join(pipe_to_tokens)
469470

470471
# remove all the tokens after the pipe
471472
tokens = tokens[:pipe_pos]
472473
except ValueError:
473-
# no pipe in the tokens
474-
pipe_to = []
474+
pipe_to = ''
475475

476476
# check for output redirect
477477
output = ''

cmd2/utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ def unquote_specific_tokens(args: List[str], tokens_to_unquote: List[str]) -> No
275275
args[i] = unquoted_arg
276276

277277

278+
def expand_user_in_tokens(tokens: List[str]) -> None:
279+
"""
280+
Call os.path.expanduser() on all tokens in an already parsed list of command-line arguments.
281+
This also supports expanding user in quoted tokens.
282+
:param tokens: tokens to expand
283+
"""
284+
for index, _ in enumerate(tokens):
285+
if tokens[index]:
286+
# Check if the token is quoted. Since parsing already passed, there isn't
287+
# an unclosed quote. So we only need to check the first character.
288+
first_char = tokens[index][0]
289+
if first_char in constants.QUOTES:
290+
tokens[index] = strip_quotes(tokens[index])
291+
292+
tokens[index] = os.path.expanduser(tokens[index])
293+
294+
# Restore the quotes
295+
if first_char in constants.QUOTES:
296+
tokens[index] = first_char + tokens[index] + first_char
297+
298+
278299
def find_editor() -> str:
279300
"""Find a reasonable editor to use by default for the system that the cmd2 application is running on."""
280301
editor = os.environ.get('EDITOR')

tests/test_cmd2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ def test_pipe_to_shell_error(base_app):
580580
# Try to pipe command output to a shell command that doesn't exist in order to produce an error
581581
out, err = run_cmd(base_app, 'help | foobarbaz.this_does_not_exist')
582582
assert not out
583-
assert "Failed to open pipe because" in err[0]
583+
assert "Pipe process exited with code" in err[0]
584584

585585
@pytest.mark.skipif(not clipboard.can_clip,
586586
reason="Pyperclip could not find a copy/paste mechanism for your system")

tests/test_parsing.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
import cmd2
1313
from cmd2 import constants, utils
14-
from cmd2.constants import MULTILINE_TERMINATOR
1514
from cmd2.parsing import StatementParser, shlex_split
1615

1716
@pytest.fixture
@@ -46,7 +45,7 @@ def test_parse_empty_string(parser):
4645
assert statement.multiline_command == ''
4746
assert statement.terminator == ''
4847
assert statement.suffix == ''
49-
assert statement.pipe_to == []
48+
assert statement.pipe_to == ''
5049
assert statement.output == ''
5150
assert statement.output_to == ''
5251
assert statement.command_and_args == line
@@ -63,7 +62,7 @@ def test_parse_empty_string_default(default_parser):
6362
assert statement.multiline_command == ''
6463
assert statement.terminator == ''
6564
assert statement.suffix == ''
66-
assert statement.pipe_to == []
65+
assert statement.pipe_to == ''
6766
assert statement.output == ''
6867
assert statement.output_to == ''
6968
assert statement.command_and_args == line
@@ -130,7 +129,7 @@ def test_parse_single_word(parser, line):
130129
assert statement.multiline_command == ''
131130
assert statement.terminator == ''
132131
assert statement.suffix == ''
133-
assert statement.pipe_to == []
132+
assert statement.pipe_to == ''
134133
assert statement.output == ''
135134
assert statement.output_to == ''
136135
assert statement.command_and_args == line
@@ -224,8 +223,8 @@ def test_parse_simple_pipe(parser, line):
224223
assert statement.args == statement
225224
assert statement.argv == ['simple']
226225
assert not statement.arg_list
227-
assert statement.pipe_to == ['piped']
228-
assert statement.expanded_command_line == statement.command + ' | ' + ' '.join(statement.pipe_to)
226+
assert statement.pipe_to == 'piped'
227+
assert statement.expanded_command_line == statement.command + ' | ' + statement.pipe_to
229228

230229
def test_parse_double_pipe_is_not_a_pipe(parser):
231230
line = 'double-pipe || is not a pipe'
@@ -247,7 +246,7 @@ def test_parse_complex_pipe(parser):
247246
assert statement.arg_list == statement.argv[1:]
248247
assert statement.terminator == '&'
249248
assert statement.suffix == 'sufx'
250-
assert statement.pipe_to == ['piped']
249+
assert statement.pipe_to == 'piped'
251250

252251
@pytest.mark.parametrize('line,output', [
253252
('help > out.txt', '>'),
@@ -307,7 +306,7 @@ def test_parse_pipe_and_redirect(parser):
307306
assert statement.arg_list == statement.argv[1:]
308307
assert statement.terminator == ';'
309308
assert statement.suffix == 'sufx'
310-
assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt']
309+
assert statement.pipe_to == 'pipethrume plz > afile.txt'
311310
assert statement.output == ''
312311
assert statement.output_to == ''
313312

@@ -516,7 +515,7 @@ def test_parse_alias_pipe(parser, line):
516515
assert statement.command == 'help'
517516
assert statement == ''
518517
assert statement.args == statement
519-
assert statement.pipe_to == ['less']
518+
assert statement.pipe_to == 'less'
520519

521520
@pytest.mark.parametrize('line', [
522521
'helpalias;',
@@ -545,7 +544,7 @@ def test_parse_command_only_command_and_args(parser):
545544
assert statement.raw == line
546545
assert statement.terminator == ''
547546
assert statement.suffix == ''
548-
assert statement.pipe_to == []
547+
assert statement.pipe_to == ''
549548
assert statement.output == ''
550549
assert statement.output_to == ''
551550

@@ -561,7 +560,7 @@ def test_parse_command_only_strips_line(parser):
561560
assert statement.raw == line
562561
assert statement.terminator == ''
563562
assert statement.suffix == ''
564-
assert statement.pipe_to == []
563+
assert statement.pipe_to == ''
565564
assert statement.output == ''
566565
assert statement.output_to == ''
567566

@@ -577,7 +576,7 @@ def test_parse_command_only_expands_alias(parser):
577576
assert statement.raw == line
578577
assert statement.terminator == ''
579578
assert statement.suffix == ''
580-
assert statement.pipe_to == []
579+
assert statement.pipe_to == ''
581580
assert statement.output == ''
582581
assert statement.output_to == ''
583582

@@ -594,7 +593,7 @@ def test_parse_command_only_expands_shortcuts(parser):
594593
assert statement.multiline_command == ''
595594
assert statement.terminator == ''
596595
assert statement.suffix == ''
597-
assert statement.pipe_to == []
596+
assert statement.pipe_to == ''
598597
assert statement.output == ''
599598
assert statement.output_to == ''
600599

@@ -611,7 +610,7 @@ def test_parse_command_only_quoted_args(parser):
611610
assert statement.multiline_command == ''
612611
assert statement.terminator == ''
613612
assert statement.suffix == ''
614-
assert statement.pipe_to == []
613+
assert statement.pipe_to == ''
615614
assert statement.output == ''
616615
assert statement.output_to == ''
617616

@@ -635,7 +634,7 @@ def test_parse_command_only_specialchars(parser, line, args):
635634
assert statement.multiline_command == ''
636635
assert statement.terminator == ''
637636
assert statement.suffix == ''
638-
assert statement.pipe_to == []
637+
assert statement.pipe_to == ''
639638
assert statement.output == ''
640639
assert statement.output_to == ''
641640

@@ -664,7 +663,7 @@ def test_parse_command_only_empty(parser, line):
664663
assert statement.multiline_command == ''
665664
assert statement.terminator == ''
666665
assert statement.suffix == ''
667-
assert statement.pipe_to == []
666+
assert statement.pipe_to == ''
668667
assert statement.output == ''
669668
assert statement.output_to == ''
670669

@@ -692,7 +691,7 @@ def test_statement_initialization():
692691
assert statement.multiline_command == ''
693692
assert statement.terminator == ''
694693
assert statement.suffix == ''
695-
assert isinstance(statement.pipe_to, list)
694+
assert isinstance(statement.pipe_to, str)
696695
assert not statement.pipe_to
697696
assert statement.output == ''
698697
assert statement.output_to == ''

0 commit comments

Comments
 (0)